~jan0sch/smederee

Showing details for patch 115c9f0f01e9f7c103de0e9190e0359afc4920c5.
2024-04-14 (Sun), 1:04 PM - Jens Grassel - 115c9f0f01e9f7c103de0e9190e0359afc4920c5

Refactoring: Remove tickets module and merge it into the hub module.

- remove tickets module from build configuration
- move source code and other needed files into the appropriate folders in the
  hub module
- adjust and refactor configuration
- update README accordingly
- sync user account data to ticket database also upon account validation
Summary of changes
51 files added
  • modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql
  • modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql
  • modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql
  • modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql
  • modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
  • modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Label.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Project.scala
  • modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala
  • modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala
  • modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
  • modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
  • modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
  • modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
  • modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
  • modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala
  • modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala
  • modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
61 files modified with 6,897 lines added and 169 lines removed
  • README.md with 19 added and 13 removed lines
  • build.sbt with 2 added and 61 removed lines
  • modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql with 3 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql with 200 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql with 4 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql with 4 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql with 4 added and 0 removed lines
  • modules/hub/src/main/resources/reference.conf with 32 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 4 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala with 165 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala with 81 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala with 145 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala with 127 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 275 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala with 59 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Label.scala with 187 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala with 78 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 1 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala with 167 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala with 107 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 1 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Project.scala with 355 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala with 96 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala with 91 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala with 163 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala with 426 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala with 207 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 1 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala with 48 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala with 41 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala with 45 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala with 37 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala with 70 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala with 69 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala with 54 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala with 106 added and 0 removed lines
  • modules/hub/src/test/resources/application.conf with 13 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala with 369 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala with 42 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala with 312 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala with 446 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala with 227 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala with 899 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala with 107 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala with 290 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala with 47 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala with 47 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala with 36 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala with 40 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala with 36 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala with 47 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala with 36 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala with 136 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala with 36 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala with 41 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala with 41 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala with 36 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala with 65 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala with 71 added and 0 removed lines
  • modules/hub/src/universal/conf/application.conf.sample with 2 added and 83 removed lines
56 files removed
  • modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala
  • modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
  • modules/tickets/src/test/scala/de/smederee/TestTags.scala
  • modules/tickets/src/test/resources/application.conf
  • modules/tickets/src/test/resources/logback-test.xml
  • modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Label.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Project.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala
  • modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql
  • modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql
  • modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql
  • modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql
  • modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
  • modules/tickets/src/main/resources/logback.xml
  • modules/tickets/src/main/resources/reference.conf
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/build.sbt	2024-05-18 22:05:18.505443612 +0000
@@ -64,7 +64,7 @@
             publish      := {},
             publishLocal := {}
         )
-        .aggregate(darcs, email, htmlUtils, hub, i18n, security, tickets, twirl)
+        .aggregate(darcs, email, htmlUtils, hub, i18n, security, twirl)
 
 lazy val darcs =
     project
@@ -135,7 +135,7 @@
 lazy val hub =
     project
         .in(file("modules/hub"))
-        .dependsOn(darcs, email, htmlUtils, i18n, security, tickets, twirl)
+        .dependsOn(darcs, email, htmlUtils, i18n, security, twirl)
         .enablePlugins(
             AutomateHeaderPlugin,
             BuildInfoPlugin,
@@ -270,65 +270,6 @@
             )
         )
 
-lazy val tickets =
-    project
-        .in(file("modules/tickets"))
-        .dependsOn(email, htmlUtils, i18n, security, twirl)
-        .enablePlugins(
-            AutomateHeaderPlugin,
-            BuildInfoPlugin,
-            SbtTwirl,
-        )
-        .settings(commonSettings)
-        .settings(
-            name             := "smederee-tickets",
-            buildInfoKeys    := Seq[BuildInfoKey](name, version, scalaVersion),
-            buildInfoPackage := "de.smederee.tickets",
-            libraryDependencies ++= Seq(
-                library.catsCore,
-                library.circeCore,
-                library.circeGeneric,
-                library.circeParser,
-                library.doobieCore,
-                library.doobieHikari,
-                library.doobiePostgres,
-                library.flywayCore,
-                library.flywayPostgreSQL,
-                library.http4sCirce,
-                library.http4sCore,
-                library.http4sDsl,
-                library.http4sEmberClient,
-                library.http4sEmberServer,
-                // library.http4sTwirl,
-                library.jclOverSlf4j, // Bridge Java Commons Logging to SLF4J.
-                library.log4catsSlf4j,
-                library.logback,
-                library.postgresql,
-                library.pureConfig,
-                library.springSecurityCrypto,
-                library.munit           % Test,
-                library.munitCatsEffect % Test,
-                library.munitScalaCheck % Test,
-                library.scalaCheck      % Test
-            )
-        )
-        .settings(
-            libraryDependencies := libraryDependencies.value.map {
-                case module if module.name == "twirl-api" =>
-                    module.cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-xml_2.13")
-                case module => module
-            } ++ Seq("org.scala-lang.modules" %% "scala-xml" % "2.2.0"),
-            TwirlKeys.templateImports ++= Seq(
-                "cats.*",
-                "cats.data.*",
-                "cats.syntax.all.*",
-                "de.smederee.html.*",
-                "de.smederee.i18n.*",
-                "de.smederee.security.{ CsrfToken, UserId, Username }",
-                "org.http4s.Uri"
-            )
-        )
-
 // FIXME: This is a workaround until http4s-twirl gets published properly for Scala 3!
 lazy val twirl =
     project
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V1__create_schema.sql	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,3 @@
+CREATE SCHEMA IF NOT EXISTS tickets;
+
+COMMENT ON SCHEMA tickets IS 'Data related to ticket tracking.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V2__base_tables.sql	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,200 @@
+CREATE TABLE tickets.users
+(
+  uid              UUID                     NOT NULL,
+  name             CHARACTER VARYING(32)    NOT NULL,
+  email            CHARACTER VARYING(128)   NOT NULL,
+  created_at       TIMESTAMP WITH TIME ZONE NOT NULL,
+  updated_at       TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT users_pk           PRIMARY KEY (uid),
+  CONSTRAINT users_unique_name  UNIQUE (name),
+  CONSTRAINT users_unique_email UNIQUE (email)
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.users IS 'All users for the ticket system live within this table.';
+COMMENT ON COLUMN tickets.users.uid IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.';
+COMMENT ON COLUMN tickets.users.name IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
+COMMENT ON COLUMN tickets.users.email IS 'A globally unique email address associated with the account.';
+COMMENT ON COLUMN tickets.users.created_at IS 'The timestamp of when the account was created.';
+COMMENT ON COLUMN tickets.users.updated_at IS 'A timestamp when the account was last changed.';
+
+CREATE TABLE tickets.sessions
+(
+  id         VARCHAR(32)              NOT NULL,
+  uid        UUID                     NOT NULL,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT sessions_pk     PRIMARY KEY (id),
+  CONSTRAINT sessions_fk_uid FOREIGN KEY (uid)
+    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.sessions IS 'Keeps the sessions of users.';
+COMMENT ON COLUMN tickets.sessions.id IS 'A globally unique session ID.';
+COMMENT ON COLUMN tickets.sessions.uid IS 'The unique ID of the user account to whom the session belongs.';
+COMMENT ON COLUMN tickets.sessions.created_at IS 'The timestamp of when the session was created.';
+COMMENT ON COLUMN tickets.sessions.updated_at IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
+
+CREATE TABLE tickets.projects
+(
+  id                 BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  name               CHARACTER VARYING(64)    NOT NULL,
+  owner              UUID                     NOT NULL,
+  is_private         BOOLEAN                  NOT NULL DEFAULT FALSE,
+  description        CHARACTER VARYING(254),
+  created_at         TIMESTAMP WITH TIME ZONE NOT NULL,
+  updated_at         TIMESTAMP WITH TIME ZONE NOT NULL,
+  next_ticket_number INTEGER                  NOT NULL DEFAULT 1,
+  CONSTRAINT projects_unique_owner_name UNIQUE (owner, name),
+  CONSTRAINT projects_fk_uid FOREIGN KEY (owner)
+    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.projects IS 'All projects which are basically mirrored repositories from the hub are stored within this table.';
+COMMENT ON COLUMN tickets.projects.id IS 'An auto generated primary key.';
+COMMENT ON COLUMN tickets.projects.name IS '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.';
+COMMENT ON COLUMN tickets.projects.owner IS 'The unique ID of the user account that owns the project.';
+COMMENT ON COLUMN tickets.projects.is_private IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.';
+COMMENT ON COLUMN tickets.projects.description IS 'An optional short text description of the project.';
+COMMENT ON COLUMN tickets.projects.created_at IS 'The timestamp of when the project was created.';
+COMMENT ON COLUMN tickets.projects.updated_at IS 'A timestamp when the project was last changed.';
+COMMENT ON COLUMN tickets.projects.next_ticket_number IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.';
+
+CREATE TABLE tickets.labels
+(
+  id          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  project     BIGINT                 NOT NULL,
+  name        CHARACTER VARYING(40)  NOT NULL,
+  description CHARACTER VARYING(254) DEFAULT NULL,
+  colour      CHARACTER VARYING(7)   NOT NULL,
+  CONSTRAINT labels_unique_project_label UNIQUE (project, name),
+  CONSTRAINT labels_fk_project FOREIGN KEY (project)
+    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.labels IS 'Labels used to add information to tickets.';
+COMMENT ON COLUMN tickets.labels.id IS 'An auto generated primary key.';
+COMMENT ON COLUMN tickets.labels.project IS 'The project to which this label belongs.'; 
+COMMENT ON COLUMN tickets.labels.name IS 'A short descriptive name for the label which is supposed to be unique in a project context.';
+COMMENT ON COLUMN tickets.labels.description IS 'An optional description if needed.';
+COMMENT ON COLUMN tickets.labels.colour IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.';
+
+CREATE TABLE tickets.milestones
+(
+  id          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  project     BIGINT                 NOT NULL,
+  title       CHARACTER VARYING(64)  NOT NULL,
+  due_date    DATE                   DEFAULT NULL,
+  description TEXT                   DEFAULT NULL,
+  CONSTRAINT milestones_unique_project_title UNIQUE (project, title),
+  CONSTRAINT milestones_fk_project FOREIGN KEY (project)
+    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.milestones IS 'Milestones used to organise tickets';
+COMMENT ON COLUMN tickets.milestones.project IS 'The project to which this milestone belongs.';
+COMMENT ON COLUMN tickets.milestones.title IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.';
+COMMENT ON COLUMN tickets.milestones.due_date IS 'An optional date on which the milestone is supposed to be reached.';
+COMMENT ON COLUMN tickets.milestones.description IS 'An optional longer description of the milestone.';
+
+CREATE TABLE tickets.tickets
+(
+  id         BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  project    BIGINT                   NOT NULL,
+  number     INT                      NOT NULL,
+  title      CHARACTER VARYING(72)    NOT NULL,
+  content    TEXT                     DEFAULT NULL,
+  status     CHARACTER VARYING(16)    NOT NULL,
+  submitter  UUID                     DEFAULT NULL,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  CONSTRAINT tickets_unique_project_ticket UNIQUE (project, number),
+  CONSTRAINT tickets_fk_project FOREIGN KEY (project)
+    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT tickets_fk_submitter FOREIGN KEY (submitter)
+    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE SET NULL
+)
+WITH (
+  OIDS=FALSE
+);
+
+CREATE INDEX tickets_status ON tickets.tickets (status);
+
+COMMENT ON TABLE tickets.tickets IS 'Information about tickets for projects.';
+COMMENT ON COLUMN tickets.tickets.id IS 'An auto generated primary key.';
+COMMENT ON COLUMN tickets.tickets.project IS 'The unique ID of the project which is associated with the ticket.';
+COMMENT ON COLUMN tickets.tickets.number IS 'The number of the ticket which must be unique within the scope of the project.';
+COMMENT ON COLUMN tickets.tickets.title IS 'A concise and short description of the ticket which should not exceed 72 characters.';
+COMMENT ON COLUMN tickets.tickets.content IS 'An optional field to describe the ticket in great detail if needed.';
+COMMENT ON COLUMN tickets.tickets.status IS 'The current status of the ticket describing its life cycle.';
+COMMENT ON COLUMN tickets.tickets.submitter IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.';
+COMMENT ON COLUMN tickets.tickets.created_at IS 'The timestamp when the ticket was created / submitted.';
+COMMENT ON COLUMN tickets.tickets.updated_at IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.';
+
+CREATE TABLE tickets.milestone_tickets
+(
+  milestone BIGINT NOT NULL,
+  ticket    BIGINT NOT NULL,
+  CONSTRAINT milestone_tickets_pk PRIMARY KEY (milestone, ticket),
+  CONSTRAINT milestone_tickets_fk_milestone FOREIGN KEY (milestone)
+    REFERENCES tickets.milestones (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT milestone_tickets_fk_ticket FOREIGN KEY (ticket)
+    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.milestone_tickets IS 'This table stores the relation between milestones and their tickets.';
+COMMENT ON COLUMN tickets.milestone_tickets.milestone IS 'The unique ID of the milestone.';
+COMMENT ON COLUMN tickets.milestone_tickets.ticket IS 'The unique ID of the ticket that is attached to the milestone.';
+
+CREATE TABLE tickets.ticket_assignees
+(
+  ticket     BIGINT NOT NULL,
+  assignee   UUID NOT NULL,
+  CONSTRAINT ticket_assignees_pk PRIMARY KEY (ticket, assignee),
+  CONSTRAINT ticket_assignees_fk_ticket FOREIGN KEY (ticket)
+    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT ticket_assignees_fk_assignee FOREIGN KEY (assignee)
+    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.ticket_assignees IS 'This table stores the relation between tickets and their assignees.';
+COMMENT ON COLUMN tickets.ticket_assignees.ticket IS 'The unqiue ID of the ticket.';
+COMMENT ON COLUMN tickets.ticket_assignees.assignee IS 'The unique ID of the user account that is assigned to the ticket.';
+
+CREATE TABLE tickets.ticket_labels
+(
+  ticket BIGINT NOT NULL,
+  label  BIGINT NOT NULL,
+  CONSTRAINT ticket_labels_pk PRIMARY KEY (ticket, label),
+  CONSTRAINT ticket_labels_fk_ticket FOREIGN KEY (ticket)
+    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT ticket_labels_fk_label FOREIGN KEY (label)
+    REFERENCES tickets.labels (id) ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE tickets.ticket_labels IS 'This table stores the relation between tickets and their labels.';
+COMMENT ON COLUMN tickets.ticket_labels.ticket IS 'The unqiue ID of the ticket.';
+COMMENT ON COLUMN tickets.ticket_labels.label IS 'The unique ID of the label that is attached to the ticket.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V3__add_language.sql	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,4 @@
+ALTER TABLE tickets.users
+  ADD COLUMN language CHARACTER VARYING(3) DEFAULT NULL;
+
+COMMENT ON COLUMN tickets.users.language IS 'The ISO-639 language code of the preferred language of the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V4__add_resolution.sql	2024-05-18 22:05:18.509443570 +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/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql new-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,4 @@
+ALTER TABLE tickets.milestones
+  ADD COLUMN closed BOOLEAN DEFAULT FALSE;
+
+COMMENT ON COLUMN tickets.milestones.closed IS 'This flag indicates if the milestone is closed e.g. considered done or obsolete.';
diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf
--- old-smederee/modules/hub/src/main/resources/reference.conf	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/resources/reference.conf	2024-05-18 22:05:18.505443612 +0000
@@ -170,8 +170,39 @@
     ticket-integration {
       enabled = false
       # The base URI used to build links to the ticket service.
-      base-uri = "http://localhost:8081"
+      base-uri = "http://localhost:8080"
       base-uri = ${?SMEDEREE_TICKET_BASE_URI}
     }
   }
 }
+
+tickets {
+  # Configuration of the database.
+  # Defaults are given except for password and can also be overridden via
+  # environment variables.
+  database {
+    # The class name of the JDBC driver to be used.
+    driver = "org.postgresql.Driver"
+    driver = ${?SMEDEREE_TICKETS_DB_DRIVER}
+    # The JDBC connection URL **without** username and password.
+    url = "jdbc:postgresql://localhost:5432/smederee"
+    url = ${?SMEDEREE_TICKETS_DB_URL}
+    # The username (login) needed to authenticate against the database.
+    user = "smederee_tickets"
+    user = ${?SMEDEREE_TICKETS_DB_USER}
+    # The password needed to authenticate against the database.
+    pass = "secret"
+    pass = ${?SMEDEREE_TICKETS_DB_PASS}
+  }
+
+  # Settings affecting how the service will communicate several information to
+  # the "outside world" e.g. if it runs behind a reverse proxy.
+  external = ${hub.service.external}
+
+  # Configuration regarding the integration with the hub service.
+  hub-integration {
+    # The base URI used to build links to the hub service.
+    base-uri = "http://localhost:8080"
+    base-uri = ${?SMEDEREE_HUB_BASE_URI}
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2024-05-18 22:05:18.509443570 +0000
@@ -473,7 +473,10 @@
                 csrf    <- Sync[F].delay(req.getCsrfToken)
                 account <- accountManagementRepo.findByValidationToken(token)
                 _       <- account.traverse(user => accountManagementRepo.markAsValidated(user.uid))
-                resp    <- SeeOther(Location(configuration.external.createFullUri(uri"/")))
+                _ <- account.traverse(user =>
+                    ticketServiceApi.createOrUpdateUser(TicketsUser(user.uid, user.name, user.email, user.language))
+                )
+                resp <- SeeOther(Location(configuration.external.createFullUri(uri"/")))
             } yield resp
     }
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2024-05-18 22:05:18.509443570 +0000
@@ -441,7 +441,7 @@
                     cryptoClock = java.time.Clock.systemUTC
                     csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
                     csrfOriginCheck = createCsrfOriginCheck(
-                        NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.externalUrl))
+                        NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.external))
                     )
                     csrfBuilder = CSRF[IO, IO](csrfKey, csrfOriginCheck)
                     // Present an error page to the user in case of CSRF check failure that recommends deleting the
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,165 @@
+/*
+ * 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.*
+import cats.data.*
+import cats.syntax.all.*
+
+/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type AssigneeId = UUID
+object AssigneeId {
+    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+    given Eq[AssigneeId] = Eq.fromUniversalEquals
+
+    /** Create an instance of AssigneeId from the given UUID type.
+      *
+      * @param source
+      *   An instance of type UUID which will be returned as a AssigneeId.
+      * @return
+      *   The appropriate instance of AssigneeId.
+      */
+    def apply(source: UUID): AssigneeId = source
+
+    /** Try to create an instance of AssigneeId from the given UUID.
+      *
+      * @param source
+      *   A UUID that should fulfil the requirements to be converted into a AssigneeId.
+      * @return
+      *   An option to the successfully converted AssigneeId.
+      */
+    def from(source: UUID): Option[AssigneeId] = Option(source)
+
+    /** Try to create an instance of AssigneeId from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a AssigneeId.
+      * @return
+      *   An option to the successfully converted AssigneeId.
+      */
+    def fromString(source: String): Either[String, AssigneeId] =
+        Option(source)
+            .filter(s => Format.matches(s))
+            .flatMap { uuidString =>
+                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+            }
+            .toRight("Illegal value for AssigneeId!")
+
+    /** Generate a new random user id.
+      *
+      * @return
+      *   A user id which is pseudo randomly generated.
+      */
+    def randomAssigneeId: AssigneeId = UUID.randomUUID
+
+    extension (uid: AssigneeId) {
+        def toUUID: UUID = uid
+    }
+}
+
+/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
+  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
+  * lowercase.
+  */
+opaque type AssigneeName = String
+object AssigneeName {
+    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
+
+    /** Create an instance of AssigneeName from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a AssigneeName.
+      * @return
+      *   The appropriate instance of AssigneeName.
+      */
+    def apply(source: String): AssigneeName = source
+
+    /** Try to create an instance of AssigneeName from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a AssigneeName.
+      * @return
+      *   An option to the successfully converted AssigneeName.
+      */
+    def from(s: String): Option[AssigneeName] = validate(s).toOption
+
+    /** Validate the given string and return either the validated username or a list of errors.
+      *
+      * @param s
+      *   An arbitrary string which should be a username.
+      * @return
+      *   Either a list of errors or the validated username.
+      */
+    def validate(s: String): ValidatedNec[String, AssigneeName] =
+        Option(s).map(_.trim.nonEmpty) match {
+            case Some(true) =>
+                val input = s.trim
+                val miniumLength =
+                    if (input.length >= 2)
+                        input.validNec
+                    else
+                        "AssigneeName too short (min. 2 characters)!".invalidNec
+                val maximumLength =
+                    if (input.length < 32)
+                        input.validNec
+                    else
+                        "AssigneeName too long (max. 31 characters)!".invalidNec
+                val alphanumeric =
+                    if (isAlphanumeric.matches(input))
+                        input.validNec
+                    else
+                        "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+                    name
+                }
+            case _ => "AssigneeName must not be empty!".invalidNec
+        }
+}
+
+/** Extractor to retrieve an AssigneeName from a path parameter.
+  */
+object AssigneeNamePathParameter {
+    def unapply(str: String): Option[AssigneeName] =
+        Option(str).flatMap { string =>
+            if (string.startsWith("~"))
+                AssigneeName.from(string.drop(1))
+            else
+                None
+        }
+}
+
+/** The assignee for a ticket i.e. the person supposed to be working on it.
+  *
+  * @param id
+  *   A globally unique ID identifying the assignee.
+  * @param name
+  *   The name associated with the assignee which is supposed to be unique.
+  */
+final case class Assignee(id: AssigneeId, name: AssigneeName)
+
+object Assignee {
+    given Eq[Assignee] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,45 @@
+/*
+ * 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.config
+
+/** A configuration path describes a path within a configuration file and is used to determine locations of certain
+  * configurations within a combined configuration file.
+  */
+opaque type ConfigurationPath = String
+object ConfigurationPath {
+
+    given Conversion[ConfigurationPath, String] = _.toString
+
+    /** Create an instance of ConfigurationPath from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a ConfigurationPath.
+      * @return
+      *   The appropriate instance of ConfigurationPath.
+      */
+    def apply(source: String): ConfigurationPath = source
+
+    /** Try to create an instance of ConfigurationPath from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ConfigurationPath.
+      * @return
+      *   An option to the successfully converted ConfigurationPath.
+      */
+    def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,37 @@
+/*
+ * 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.config
+
+import pureconfig.*
+
+/** Configuration specifying the database access.
+  *
+  * @param driver
+  *   The class name of the JDBC driver to be used.
+  * @param url
+  *   The JDBC connection URL **without** username and password.
+  * @param user
+  *   The username (login) needed to authenticate against the database.
+  * @param pass
+  *   The password needed to authenticate against the database.
+  */
+final case class DatabaseConfig(driver: String, url: String, user: String, pass: String)
+
+object DatabaseConfig {
+    given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,70 @@
+/*
+ * 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.config
+
+import cats.effect.*
+import cats.syntax.all.*
+import org.flywaydb.core.Flyway
+import org.flywaydb.core.api.configuration.FluentConfiguration
+import org.flywaydb.core.api.output.MigrateResult
+
+/** Provide functionality to migrate the database used by the service.
+  */
+final class DatabaseMigrator[F[_]: Sync] {
+
+    /** Apply pending migrations to the database if needed using the underlying Flyway library.
+      *
+      * @param url
+      *   The JDBC connection URL **without** username and password.
+      * @param user
+      *   The username (login) needed to authenticate against the database.
+      * @param pass
+      *   The password needed to authenticate against the database.
+      * @return
+      *   A migrate result object holding information about executed migrations and the schema. See the Java-Doc of
+      *   Flyway for details.
+      */
+    def migrate(url: String, user: String, pass: String): F[MigrateResult] =
+        for {
+            flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load())
+            result <- Sync[F].delay(flyway.migrate())
+        } yield result
+}
+
+object DatabaseMigrator {
+
+    /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The
+      * returned instance must be activated by calling the `.load()` method.
+      *
+      * @param url
+      *   The JDBC connection URL **without** username and password.
+      * @param user
+      *   The username (login) needed to authenticate against the database.
+      * @param pass
+      *   The password needed to authenticate against the database.
+      * @return
+      *   An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method.
+      */
+    def configureFlyway(url: String, user: String, pass: String): FluentConfiguration =
+        Flyway
+            .configure()
+            .defaultSchema("tickets")
+            .locations("classpath:db/migration/tickets")
+            .dataSource(url, user, pass)
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,69 @@
+/*
+ * 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.config
+
+import com.comcast.ip4s.*
+import de.smederee.html.ExternalUrlConfiguration
+import org.http4s.Uri
+import pureconfig.*
+
+/** Configuration regarding the integration with the hub service.
+  *
+  * @param baseUri
+  *   The base URI used to build links to the hub service.
+  */
+final case class HubIntegrationConfiguration(baseUri: Uri)
+
+object HubIntegrationConfiguration {
+    given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+    given ConfigReader[HubIntegrationConfiguration] =
+        ConfigReader.forProduct1("base-uri")(HubIntegrationConfiguration.apply)
+}
+
+/** Wrapper class for the confiuration of the Smederee tickets module.
+  *
+  * @param database
+  *   The configuration needed to access the database.
+  * @param external
+  *   Configuration regarding support for generating "external urls" which is usually needed if the service runs behind
+  *   a reverse proxy.
+  * @param hub
+  *   Configuration regarding the integration with the hub service.
+  */
+final case class SmedereeTicketsConfiguration(
+    database: DatabaseConfig,
+    external: ExternalUrlConfiguration,
+    hub: HubIntegrationConfiguration
+)
+
+object SmedereeTicketsConfiguration {
+    val location: ConfigurationPath = ConfigurationPath("tickets")
+
+    given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
+    given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
+    given ConfigReader[Uri]        = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+    given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
+
+    given ConfigReader[ExternalUrlConfiguration] =
+        ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
+
+    given ConfigReader[SmedereeTicketsConfiguration] =
+        ConfigReader.forProduct3("database", "external", "hub-integration")(
+            SmedereeTicketsConfiguration.apply
+        )
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,81 @@
+/*
+ * 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 cats.effect.*
+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[F] = Slf4jLogHandler.createLogHandler[F](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(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(projectId: ProjectId)(label: Label): F[Int] =
+        sql"""INSERT INTO tickets.labels
+          (
+            project,
+            name,
+            description,
+            colour
+          )
+          VALUES (
+            $projectId,
+            ${label.name},
+            ${label.description},
+            ${label.colour}
+          )""".update.run.transact(tx)
+
+    override def deleteLabel(label: Label): F[Int] =
+        label.id match {
+            case None => Sync[F].pure(0)
+            case Some(id) =>
+                sql"""DELETE FROM tickets.labels WHERE id = $id""".update.run.transact(tx)
+        }
+
+    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)
+
+    override def updateLabel(label: Label): F[Int] =
+        label.id match {
+            case None => Sync[F].pure(0)
+            case Some(id) =>
+                sql"""UPDATE tickets.labels 
+              SET name = ${label.name},
+              description = ${label.description},
+              colour = ${label.colour}
+              WHERE id = $id""".update.run.transact(tx)
+        }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,145 @@
+/*
+ * 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 cats.syntax.all.*
+import doobie.*
+import doobie.Fragments.*
+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[F] = Slf4jLogHandler.createLogHandler[F](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"
+          LEFT OUTER JOIN "tickets"."users" AS "submitters"
+          ON "tickets".submitter = "submitters".uid"""
+
+    override def allMilestones(projectId: ProjectId): Stream[F, Milestone] =
+        sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC"""
+            .query[Milestone]
+            .stream
+            .transact(tx)
+
+    override def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] = {
+        val milestoneFilter =
+            fr""""tickets".id IN (SELECT ticket FROM "tickets".milestone_tickets AS "milestone_tickets" WHERE milestone = $milestoneId)"""
+        val tickets = filter match {
+            case None => selectTicketColumns ++ whereAnd(milestoneFilter)
+            case Some(filter) =>
+                val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr""""tickets".number""", numbers))
+                val statusFilter = filter.status.toNel.map(status => Fragments.in(fr""""tickets".status""", status))
+                val resolutionFilter =
+                    filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions))
+                val submitterFilter =
+                    filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters))
+                selectTicketColumns ++ whereAndOpt(
+                    milestoneFilter.some,
+                    numberFilter,
+                    statusFilter,
+                    resolutionFilter,
+                    submitterFilter
+                )
+        }
+        tickets.query[Ticket].stream.transact(tx)
+    }
+
+    override def closeMilestone(milestoneId: MilestoneId): F[Int] =
+        sql"""UPDATE "tickets"."milestones" SET closed = TRUE WHERE id = $milestoneId""".update.run.transact(tx)
+
+    override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] =
+        sql"""INSERT INTO "tickets"."milestones"
+          (
+            project,
+            title,
+            due_date,
+            description,
+            closed
+          )
+          VALUES (
+            $projectId,
+            ${milestone.title},
+            ${milestone.dueDate},
+            ${milestone.description},
+            ${milestone.closed}
+          )""".update.run.transact(tx)
+
+    override def deleteMilestone(milestone: Milestone): F[Int] =
+        milestone.id match {
+            case None     => Sync[F].pure(0)
+            case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx)
+        }
+
+    override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] =
+        sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1"""
+            .query[Milestone]
+            .option
+            .transact(tx)
+
+    override def openMilestone(milestoneId: MilestoneId): F[Int] =
+        sql"""UPDATE "tickets"."milestones" SET closed = FALSE WHERE id = $milestoneId""".update.run.transact(tx)
+
+    override def updateMilestone(milestone: Milestone): F[Int] =
+        milestone.id match {
+            case None => Sync[F].pure(0)
+            case Some(id) =>
+                sql"""UPDATE "tickets"."milestones"
+              SET title = ${milestone.title},
+                due_date = ${milestone.dueDate},
+                description = ${milestone.description}
+              WHERE id = $id""".update.run.transact(tx)
+        }
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,127 @@
+/*
+ * 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 de.smederee.email.EmailAddress
+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[F] = Slf4jLogHandler.createLogHandler[F](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)
+    given Meta[TicketNumber]       = Meta[Int].timap(TicketNumber.apply)(_.toInt)
+
+    override def createProject(project: Project): F[Int] =
+        sql"""INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at)
+          VALUES (
+            ${project.name},
+            ${project.owner.uid},
+            ${project.isPrivate},
+            ${project.description},
+            NOW(),
+            NOW()
+          )""".update.run.transact(tx)
+
+    override def deleteProject(project: Project): F[Int] =
+        sql"""DELETE FROM tickets.projects WHERE owner = ${project.owner.uid} AND name = ${project.name}""".update.run
+            .transact(tx)
+
+    override def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] =
+        sql"""SELECT
+            users.uid AS owner_id,
+            users.name AS owner_name,
+            users.email AS owner_email,
+            projects.name,
+            projects.description,
+            projects.is_private
+          FROM tickets.projects AS projects
+          JOIN tickets.users AS users
+            ON projects.owner = users.uid
+          WHERE
+            projects.owner = ${owner.uid}
+          AND
+            projects.name = $name""".query[Project].option.transact(tx)
+
+    override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] =
+        sql"""SELECT
+            projects.id
+          FROM tickets.projects AS projects
+          JOIN tickets.users AS users
+            ON projects.owner = users.uid
+          WHERE
+            projects.owner = ${owner.uid}
+          AND
+            projects.name = $name""".query[ProjectId].option.transact(tx)
+
+    override def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] =
+        sql"""SELECT
+            users.uid,
+            users.name,
+            users.email
+          FROM tickets.users
+          WHERE name = $name""".query[ProjectOwner].option.transact(tx)
+
+    override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = {
+        // TODO: Find out which of the queries is more reliable and more performant.
+        /*
+        val sqlQuery1 = sql"""UPDATE tickets.projects AS alias1
+                            SET next_ticket_number = alias2.next_ticket_number + 1
+                          FROM (
+                            SELECT
+                              id,
+                              next_ticket_number
+                            FROM tickets.projects
+                            WHERE id = $projectId
+                          ) AS alias2
+                          WHERE alias1.id = alias2.id
+                          RETURNING alias2.next_ticket_number AS next_ticket_number"""
+         */
+        val sqlQuery2 = sql"""WITH old_number AS (
+                            SELECT next_ticket_number FROM tickets.projects WHERE id = $projectId
+                          )
+                          UPDATE tickets.projects
+                            SET next_ticket_number = next_ticket_number + 1
+                          WHERE id = $projectId
+                          RETURNING (
+                            SELECT next_ticket_number FROM old_number
+                          )"""
+        sqlQuery2.query[TicketNumber].unique.transact(tx)
+    }
+
+    override def updateProject(project: Project): F[Int] =
+        sql"""UPDATE tickets.projects SET
+            is_private = ${project.isPrivate},
+            description = ${project.description},
+            updated_at = NOW()
+          WHERE
+            owner = ${project.owner.uid}
+          AND name = ${project.name}""".update.run.transact(tx)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,275 @@
+/*
+ * 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.*
+import cats.data.*
+import cats.effect.*
+import cats.syntax.all.*
+import doobie.*
+import doobie.Fragments.*
+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[F] = Slf4jLogHandler.createLogHandler[F](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
+          LEFT OUTER 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(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] = {
+        val projectFilter = fr"""tickets.project = $projectId"""
+        val tickets = filter match {
+            case None => selectTicketColumns ++ whereAnd(projectFilter)
+            case Some(filter) =>
+                val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr"""tickets.number""", numbers))
+                val statusFilter = filter.status.toNel.map(status => Fragments.in(fr"""tickets.status""", status))
+                val resolutionFilter =
+                    filter.resolution.toNel.map(resolutions => Fragments.in(fr"""tickets.resolution""", resolutions))
+                val submitterFilter =
+                    filter.submitter.toNel.map(submitters => Fragments.in(fr"""submitters.name""", submitters))
+                selectTicketColumns ++ whereAndOpt(
+                    projectFilter.some,
+                    numberFilter,
+                    statusFilter,
+                    resolutionFilter,
+                    submitterFilter
+                )
+        }
+        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,
+             milestones.closed AS closed
+           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)},
+            updated_at = NOW()
+          WHERE project = $projectId
+          AND number = ${ticket.number}""".update.run.transact(tx)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,59 @@
+/*
+ * 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 de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.UserId
+import de.smederee.security.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[F] = Slf4jLogHandler.createLogHandler[F](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)
+    given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
+
+    override def createOrUpdateUser(user: TicketsUser): F[Int] =
+        sql"""INSERT INTO tickets.users (uid, name, email, language, created_at, updated_at)
+          VALUES (
+            ${user.uid},
+            ${user.name},
+            ${user.email},
+            ${user.language},
+            NOW(),
+            NOW()
+          ) ON CONFLICT (uid) DO UPDATE SET
+            name = EXCLUDED.name,
+            email = EXCLUDED.email,
+            language = EXCLUDED.language,
+            updated_at = EXCLUDED.updated_at""".update.run.transact(tx)
+
+    override def deleteUser(uid: UserId): F[Int] =
+        sql"""DELETE FROM tickets.users WHERE uid = $uid""".update.run.transact(tx)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,54 @@
+/*
+ * 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.forms
+
+import cats.data.*
+import de.smederee.tickets.forms.types.*
+
+/** A base class for form validators.
+  *
+  * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets
+  * submitted as raw stringified map.</p>
+  *
+  * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir
+  * endpoints.</p>
+  *
+  * <p>However, sometimes you want to have more fine grained control...</p>
+  *
+  * @tparam T
+  *   The concrete type of the validated form output.
+  */
+abstract class FormValidator[T] {
+    final val fieldGlobal: FormField = FormValidator.fieldGlobal
+
+    /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a
+      * list of [[de.smederee.tickets.forms.types.FormErrors]].
+      *
+      * @param data
+      *   The stringified map which was submitted.
+      * @return
+      *   Either the validated form as concrete type T or a list of form errors.
+      */
+    def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T]
+
+}
+
+object FormValidator {
+    // A constant for the field name used for global errors.
+    val fieldGlobal: FormField = FormField("global")
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/forms/types.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,106 @@
+/*
+ * 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.forms
+
+import cats.data.*
+import cats.syntax.all.*
+
+object types {
+
+    type FormErrors = Map[FormField, List[FormFieldError]]
+    object FormErrors {
+        val empty: FormErrors = Map.empty[FormField, List[FormFieldError]]
+
+        /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned
+          * from validators.
+          *
+          * @param errors
+          *   A non empty chain of FormErrors.
+          * @return
+          *   A single FormErrors instance containing all the errors.
+          */
+        def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
+
+        /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from
+          * validators.
+          *
+          * @param errors
+          *   A non empty list of FormErrors.
+          * @return
+          *   A single FormErrors instance containing all the errors.
+          */
+        def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
+    }
+
+    opaque type FormField = String
+    object FormField {
+
+        /** Create an instance of FormField from the given String type.
+          *
+          * @param source
+          *   An instance of type String which will be returned as a FormField.
+          * @return
+          *   The appropriate instance of FormField.
+          */
+        def apply(source: String): FormField = source
+
+        /** Try to create an instance of FormField from the given String.
+          *
+          * @param source
+          *   A String that should fulfil the requirements to be converted into a FormField.
+          * @return
+          *   An option to the successfully converted FormField.
+          */
+        def from(source: String): Option[FormField] =
+            Option(source).map(_.trim.nonEmpty) match {
+                case Some(true) => Option(source.trim)
+                case _          => None
+            }
+    }
+
+    given Conversion[FormField, String] = _.toString
+
+    opaque type FormFieldError = String
+    object FormFieldError {
+
+        /** Create an instance of FormFieldError from the given String type.
+          *
+          * @param source
+          *   An instance of type String which will be returned as a FormFieldError.
+          * @return
+          *   The appropriate instance of FormFieldError.
+          */
+        def apply(source: String): FormFieldError = source
+
+        /** Try to create an instance of FormFieldError from the given String.
+          *
+          * @param source
+          *   A String that should fulfil the requirements to be converted into a FormFieldError.
+          * @return
+          *   An option to the successfully converted FormFieldError.
+          */
+        def from(source: String): Option[FormFieldError] =
+            Option(source).map(_.trim.nonEmpty) match {
+                case Some(true) => Option(source.trim)
+                case _          => None
+            }
+    }
+
+    given Conversion[FormFieldError, String] = _.toString
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,78 @@
+/*
+ * 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 fs2.Stream
+
+/** The base class that defines the needed functionality to handle labels within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class LabelRepository[F[_]] {
+
+    /** Return all labels associated with the given repository.
+      *
+      * @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(projectId: ProjectId): Stream[F, Label]
+
+    /** Create a database entry for the given label definition.
+      *
+      * @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(projectId: ProjectId)(label: Label): F[Int]
+
+    /** Delete the label from the database.
+      *
+      * @param label
+      *   The label definition that shall be deleted from the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def deleteLabel(label: Label): F[Int]
+
+    /** Find the label with the given name for the given vcs repository.
+      *
+      * @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(projectId: ProjectId)(name: LabelName): F[Option[Label]]
+
+    /** Update the database entry for the given label.
+      *
+      * @param label
+      *   The label definition that shall be updated within the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def updateLabel(label: Label): F[Int]
+
+}
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	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2024-05-18 22:05:18.509443570 +0000
@@ -54,10 +54,8 @@
 ) extends Http4sDsl[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
-    given CsrfProtectionConfiguration = configuration.csrfProtection
-
     private val linkToHubService = configuration.hub.baseUri
-    private val linkConfig       = configuration.externalUrl
+    private val linkConfig       = configuration.external
 
     /** Logic for rendering a list of all labels for a project and optionally management functionality.
       *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,187 @@
+/*
+ * 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 cats.*
+import cats.syntax.all.*
+
+import scala.util.matching.Regex
+
+opaque type LabelId = Long
+object LabelId {
+    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
+
+    /** Create an instance of LabelId from the given Long type.
+      *
+      * @param source
+      *   An instance of type Long which will be returned as a LabelId.
+      * @return
+      *   The appropriate instance of LabelId.
+      */
+    def apply(source: Long): LabelId = source
+
+    /** Try to create an instance of LabelId from the given Long.
+      *
+      * @param source
+      *   A Long that should fulfil the requirements to be converted into a LabelId.
+      * @return
+      *   An option to the successfully converted LabelId.
+      */
+    def from(source: Long): Option[LabelId] = Option(source)
+
+    /** Try to create an instance of LabelId from the given String.
+      *
+      * @param source
+      *   A string that should fulfil the requirements to be converted into a LabelId.
+      * @return
+      *   An option to the successfully converted LabelId.
+      */
+    def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
+
+    extension (id: LabelId) {
+        def toLong: Long = id
+    }
+}
+
+/** Extractor to retrieve an LabelId from a path parameter.
+  */
+object LabelIdPathParameter {
+    def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString)
+}
+
+/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and
+  * not exceed 40 characters in length.
+  */
+opaque type LabelName = String
+object LabelName {
+    given Eq[LabelName]       = Eq.fromUniversalEquals
+    given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y)
+    given Order[LabelName]    = Order.fromOrdering[LabelName]
+
+    val MaxLength: Int = 40
+
+    /** Create an instance of LabelName from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a LabelName.
+      * @return
+      *   The appropriate instance of LabelName.
+      */
+    def apply(source: String): LabelName = source
+
+    /** Try to create an instance of LabelName from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a LabelName.
+      * @return
+      *   An option to the successfully converted LabelName.
+      */
+    def from(source: String): Option[LabelName] =
+        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+
+}
+
+/** Extractor to retrieve an LabelName from a path parameter.
+  */
+object LabelNamePathParameter {
+    def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from)
+}
+
+/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length.
+  */
+opaque type LabelDescription = String
+object LabelDescription {
+    given Eq[LabelDescription] = Eq.fromUniversalEquals
+
+    val MaxLength: Int = 254
+
+    /** Create an instance of LabelDescription from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a LabelDescription.
+      * @return
+      *   The appropriate instance of LabelDescription.
+      */
+    def apply(source: String): LabelDescription = source
+
+    /** Try to create an instance of LabelDescription from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a LabelDescription.
+      * @return
+      *   An option to the successfully converted LabelDescription.
+      */
+    def from(source: String): Option[LabelDescription] =
+        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+}
+
+/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag
+  * followed by three 2-digit hexadecimal codes (`00-ff`).
+  */
+opaque type ColourCode = String
+object ColourCode {
+    given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b))
+
+    val Format: Regex = "^#[0-9a-fA-F]{6}$".r
+
+    /** Create an instance of ColourCode from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a ColourCode.
+      * @return
+      *   The appropriate instance of ColourCode.
+      */
+    def apply(source: String): ColourCode = source
+
+    /** Try to create an instance of ColourCode from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ColourCode.
+      * @return
+      *   An option to the successfully converted ColourCode.
+      */
+    def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string))
+
+}
+
+/** A label is intended to mark tickets with keywords and colours to allow filtering on them.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the label.
+  * @param name
+  *   A short descriptive name for the label which is supposed to be unique in a project context.
+  * @param description
+  *   An optional description if needed.
+  * @param colour
+  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
+  */
+final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode)
+
+object Label {
+    given Eq[Label] =
+        Eq.instance((thisLabel, thatLabel) =>
+            thisLabel.id === thatLabel.id &&
+                thisLabel.name === thatLabel.name &&
+                thisLabel.description === thatLabel.description &&
+                thisLabel.colour === thatLabel.colour
+        )
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,107 @@
+/*
+ * 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 fs2.Stream
+
+/** The base class that defines the needed functionality to handle milestones within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class MilestoneRepository[F[_]] {
+
+    /** Return all milestones associated with the given repository.
+      *
+      * @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(projectId: ProjectId): Stream[F, Milestone]
+
+    /** Return all tickets associated with the given milestone.
+      *
+      * @param filter
+      *   A ticket filter containing possible values which will be used to filter the list of tickets.
+      * @param milestoneId
+      *   The unique internal ID of a milestone for which all tickets shall be returned.
+      * @return
+      *   A stream of tickets associated with the vcs repository which may be empty.
+      */
+    def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket]
+
+    /** Change the milestone status with the given id to closed.
+      *
+      * @param milestoneId
+      *   The unique internal ID of a milestone for which all tickets shall be returned.
+      * @return
+      *   The number of affected database rows.
+      */
+    def closeMilestone(milestoneId: MilestoneId): F[Int]
+
+    /** Create a database entry for the given milestone definition.
+      *
+      * @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(projectId: ProjectId)(milestone: Milestone): F[Int]
+
+    /** Delete the milestone from the database.
+      *
+      * @param milestone
+      *   The milestone definition that shall be deleted from the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def deleteMilestone(milestone: Milestone): F[Int]
+
+    /** Find the milestone with the given title for the given vcs repository.
+      *
+      * @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(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]]
+
+    /** Change the milestone status with the given id to open.
+      *
+      * @param milestoneId
+      *   The unique internal ID of a milestone for which all tickets shall be returned.
+      * @return
+      *   The number of affected database rows.
+      */
+    def openMilestone(milestoneId: MilestoneId): F[Int]
+
+    /** Update the database entry for the given milestone.
+      *
+      * @param milestone
+      *   The milestone definition that shall be updated within the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def updateMilestone(milestone: Milestone): F[Int]
+
+}
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	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2024-05-18 22:05:18.509443570 +0000
@@ -54,10 +54,8 @@
 ) extends Http4sDsl[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
-    given CsrfProtectionConfiguration = configuration.csrfProtection
-
     private val linkToHubService = configuration.hub.baseUri
-    private val linkConfig       = configuration.externalUrl
+    private val linkConfig       = configuration.external
 
     /** Logic for rendering a detail page for a single milestone.
       *
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,167 @@
+/*
+ * 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.LocalDate
+
+import cats.*
+import cats.syntax.all.*
+
+import scala.util.matching.Regex
+
+opaque type MilestoneId = Long
+object MilestoneId {
+    given Eq[MilestoneId] = Eq.fromUniversalEquals
+
+    val Format: Regex = "^-?\\d+$".r
+
+    /** Create an instance of MilestoneId from the given Long type.
+      *
+      * @param source
+      *   An instance of type Long which will be returned as a MilestoneId.
+      * @return
+      *   The appropriate instance of MilestoneId.
+      */
+    def apply(source: Long): MilestoneId = source
+
+    /** Try to create an instance of MilestoneId from the given Long.
+      *
+      * @param source
+      *   A Long that should fulfil the requirements to be converted into a MilestoneId.
+      * @return
+      *   An option to the successfully converted MilestoneId.
+      */
+    def from(source: Long): Option[MilestoneId] = Option(source)
+
+    /** Try to create an instance of MilestoneId from the given String.
+      *
+      * @param source
+      *   A string that should fulfil the requirements to be converted into a MilestoneId.
+      * @return
+      *   An option to the successfully converted MilestoneId.
+      */
+    def fromString(source: String): Option[MilestoneId] =
+        Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
+
+    extension (id: MilestoneId) {
+        def toLong: Long = id
+    }
+}
+
+/** Extractor to retrieve an MilestoneId from a path parameter.
+  */
+object MilestoneIdPathParameter {
+    def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString)
+}
+
+/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a
+  * project context. It must not be empty and not exceed 64 characters in length.
+  */
+opaque type MilestoneTitle = String
+object MilestoneTitle {
+    given Eq[MilestoneTitle]       = Eq.fromUniversalEquals
+    given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y)
+    given Order[MilestoneTitle]    = Order.fromOrdering[MilestoneTitle]
+
+    val MaxLength: Int = 64
+
+    /** Create an instance of MilestoneTitle from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a MilestoneTitle.
+      * @return
+      *   The appropriate instance of MilestoneTitle.
+      */
+    def apply(source: String): MilestoneTitle = source
+
+    /** Try to create an instance of MilestoneTitle from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a MilestoneTitle.
+      * @return
+      *   An option to the successfully converted MilestoneTitle.
+      */
+    def from(source: String): Option[MilestoneTitle] =
+        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+
+}
+
+/** Extractor to retrieve an MilestoneTitle from a path parameter.
+  */
+object MilestoneTitlePathParameter {
+    def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from)
+}
+
+/** A longer detailed description of a project milestone which must not be empty.
+  */
+opaque type MilestoneDescription = String
+object MilestoneDescription {
+    given Eq[MilestoneDescription] = Eq.fromUniversalEquals
+
+    /** Create an instance of MilestoneDescription from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a MilestoneDescription.
+      * @return
+      *   The appropriate instance of MilestoneDescription.
+      */
+    def apply(source: String): MilestoneDescription = source
+
+    /** Try to create an instance of MilestoneDescription from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a MilestoneDescription.
+      * @return
+      *   An option to the successfully converted MilestoneDescription.
+      */
+    def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty)
+
+}
+
+/** A milestone can be used to organise tickets and progress inside a project.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the milestone.
+  * @param title
+  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
+  *   a project context.
+  * @param description
+  *   An optional longer description of the milestone.
+  * @param dueDate
+  *   An optional date on which the milestone is supposed to be reached.
+  * @param closed
+  *   This flag indicates if the milestone is closed e.g. considered done or obsolete.
+  */
+final case class Milestone(
+    id: Option[MilestoneId],
+    title: MilestoneTitle,
+    description: Option[MilestoneDescription],
+    dueDate: Option[LocalDate],
+    closed: Boolean
+)
+
+object Milestone {
+
+    given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
+
+    given Eq[Milestone] =
+        Eq.instance((a, b) =>
+            a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description
+        )
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,96 @@
+/*
+ * 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
+
+/** A base class for a database repository that should handle all functionality regarding projects in the database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class ProjectRepository[F[_]] {
+
+    /** Create the given project within the database.
+      *
+      * @param project
+      *   The project that shall be created.
+      * @return
+      *   The number of affected database rows.
+      */
+    def createProject(project: Project): F[Int]
+
+    /** Delete the given project from the database.
+      *
+      * @param project
+      *   The project that shall be deleted.
+      * @return
+      *   The number of affected database rows.
+      */
+    def deleteProject(project: Project): F[Int]
+
+    /** Search for the project entry with the given owner and name.
+      *
+      * @param owner
+      *   Data about the owner of the project containing information needed to query the database.
+      * @param name
+      *   The project name which must be unique in regard to the owner.
+      * @return
+      *   An option to the successfully found project entry.
+      */
+    def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]]
+
+    /** Search for the internal database specific (auto generated) ID of the given owner / project combination which
+      * serves as a primary key for the database table.
+      *
+      * @param owner
+      *   Data about the owner of the project containing information needed to query the database.
+      * @param name
+      *   The project name which must be unique in regard to the owner.
+      * @return
+      *   An option to the internal database ID.
+      */
+    def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]]
+
+    /** Search for a project owner of whom we only know the name.
+      *
+      * @param name
+      *   The name of the project owner which is the username of the actual owners account.
+      * @return
+      *   An option to successfully found project owner.
+      */
+    def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]]
+
+    /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was
+      * incremented).
+      *
+      * @param projectId
+      *   The internal database id of the project.
+      * @return
+      *   The ticket number _before_ it was incremented.
+      */
+    def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber]
+
+    /** Update the database entry for the given project.
+      *
+      * @param project
+      *   The project that shall be updated within the database.
+      * @return
+      *   The number of affected database rows.
+      */
+    def updateProject(project: Project): F[Int]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Project.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,355 @@
+/*
+ * 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.*
+import cats.data.*
+import cats.syntax.all.*
+import de.smederee.email.EmailAddress
+import de.smederee.security.UserId
+import de.smederee.security.Username
+
+import scala.util.Try
+import scala.util.matching.Regex
+
+opaque type ProjectDescription = String
+object ProjectDescription {
+    val MaximumLength: Int = 8192
+
+    /** Create an instance of ProjectDescription from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a ProjectDescription.
+      * @return
+      *   The appropriate instance of ProjectDescription.
+      */
+    def apply(source: String): ProjectDescription = source
+
+    /** Try to create an instance of ProjectDescription from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ProjectDescription.
+      * @return
+      *   An option to the successfully converted ProjectDescription.
+      */
+    def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength))
+
+}
+
+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 {
+
+    given Eq[ProjectName] = Eq.fromUniversalEquals
+
+    given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString))
+
+    // TODO: Can we rewrite this in a Scala-3 way (i.e. without implicitly)?
+    given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering
+
+    val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r
+
+    /** Create an instance of ProjectName from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a ProjectName.
+      * @return
+      *   The appropriate instance of ProjectName.
+      */
+    def apply(source: String): ProjectName = source
+
+    /** Try to create an instance of ProjectName from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ProjectName.
+      * @return
+      *   An option to the successfully converted ProjectName.
+      */
+    def from(source: String): Option[ProjectName] = validate(source).toOption
+
+    /** Validate the given string and return either the validated repository name or a list of errors.
+      *
+      * @param s
+      *   An arbitrary string which should be a repository name.
+      * @return
+      *   Either a list of errors or the validated repository name.
+      */
+    def validate(s: String): ValidatedNec[String, ProjectName] =
+        Option(s).map(_.trim.nonEmpty) match {
+            case Some(true) =>
+                val input = s.trim
+                val miniumLength =
+                    if (input.length > 1)
+                        input.validNec
+                    else
+                        "Repository name too short (min. 2 characters)!".invalidNec
+                val maximumLength =
+                    if (input.length < 65)
+                        input.validNec
+                    else
+                        "Repository name too long (max. 64 characters)!".invalidNec
+                val validFormat =
+                    if (Format.matches(input))
+                        input.validNec
+                    else
+                        "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec
+                (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) =>
+                    name
+                }
+            case _ => "Repository name must not be empty!".invalidNec
+        }
+}
+
+/** Extractor to retrieve a ProjectName from a path parameter.
+  */
+object ProjectNamePathParameter {
+    def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from)
+}
+
+/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type ProjectOwnerId = UUID
+object ProjectOwnerId {
+    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+    given Eq[ProjectOwnerId] = Eq.fromUniversalEquals
+
+    // given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId
+
+    /** Create an instance of ProjectOwnerId from the given UUID type.
+      *
+      * @param source
+      *   An instance of type UUID which will be returned as a ProjectOwnerId.
+      * @return
+      *   The appropriate instance of ProjectOwnerId.
+      */
+    def apply(source: UUID): ProjectOwnerId = source
+
+    /** Try to create an instance of ProjectOwnerId from the given UUID.
+      *
+      * @param source
+      *   A UUID that should fulfil the requirements to be converted into a ProjectOwnerId.
+      * @return
+      *   An option to the successfully converted ProjectOwnerId.
+      */
+    def from(source: UUID): Option[ProjectOwnerId] = Option(source)
+
+    /** Try to create an instance of ProjectOwnerId from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ProjectOwnerId.
+      * @return
+      *   An option to the successfully converted ProjectOwnerId.
+      */
+    def fromString(source: String): Either[String, ProjectOwnerId] =
+        Option(source)
+            .filter(s => Format.matches(s))
+            .flatMap { uuidString =>
+                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+            }
+            .toRight("Illegal value for ProjectOwnerId!")
+
+    /** Create an instance of ProjectOwnerId from the given UserId type.
+      *
+      * @param uid
+      *   An instance of type UserId which will be returned as a ProjectOwnerId.
+      * @return
+      *   The appropriate instance of ProjectOwnerId.
+      */
+    def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID
+
+    /** Generate a new random project owner id.
+      *
+      * @return
+      *   A project owner id which is pseudo randomly generated.
+      */
+    def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID
+
+    extension (uid: ProjectOwnerId) {
+        def toUUID: UUID = uid
+    }
+}
+
+/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix
+  * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31
+  * characters long and be all lowercase.
+  */
+opaque type ProjectOwnerName = String
+object ProjectOwnerName {
+    given Eq[ProjectOwnerName] = Eq.fromUniversalEquals
+
+    given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername
+
+    val isAlphanumeric = "^[a-z][a-z0-9]+$".r
+
+    /** Create an instance of ProjectOwnerName from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a ProjectOwnerName.
+      * @return
+      *   The appropriate instance of ProjectOwnerName.
+      */
+    def apply(source: String): ProjectOwnerName = source
+
+    /** Try to create an instance of ProjectOwnerName from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a ProjectOwnerName.
+      * @return
+      *   An option to the successfully converted ProjectOwnerName.
+      */
+    def from(s: String): Option[ProjectOwnerName] = validate(s).toOption
+
+    /** Create an instance of ProjectOwnerName from the given Username type.
+      *
+      * @param username
+      *   An instance of the type Username which will be returned as a ProjectOwnerName.
+      * @return
+      *   The appropriate instance of ProjectOwnerName.
+      */
+    def fromUsername(username: Username): ProjectOwnerName = username.toString
+
+    /** Validate the given string and return either the validated project owner name or a list of errors.
+      *
+      * @param s
+      *   An arbitrary string which should be a project owner name.
+      * @return
+      *   Either a list of errors or the validated project owner name.
+      */
+    def validate(s: String): ValidatedNec[String, ProjectOwnerName] =
+        Option(s).map(_.trim.nonEmpty) match {
+            case Some(true) =>
+                val input = s.trim
+                val miniumLength =
+                    if (input.length >= 2)
+                        input.validNec
+                    else
+                        "ProjectOwnerName too short (min. 2 characters)!".invalidNec
+                val maximumLength =
+                    if (input.length < 32)
+                        input.validNec
+                    else
+                        "ProjectOwnerName too long (max. 31 characters)!".invalidNec
+                val alphanumeric =
+                    if (isAlphanumeric.matches(input))
+                        input.validNec
+                    else
+                        "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+                    name
+                }
+            case _ => "ProjectOwnerName must not be empty!".invalidNec
+        }
+
+    extension (ownername: ProjectOwnerName) {
+
+        /** Convert this project owner name into a username.
+          *
+          * @return
+          *   A syntactically valid username.
+          */
+        def toUsername: Username = Username(ownername.toString)
+    }
+}
+
+/** Extractor to retrieve an ProjectOwnerName from a path parameter.
+  */
+object ProjectOwnerNamePathParameter {
+    def unapply(str: String): Option[ProjectOwnerName] =
+        Option(str).flatMap { string =>
+            if (string.startsWith("~"))
+                ProjectOwnerName.from(string.drop(1))
+            else
+                None
+        }
+}
+
+/** Descriptive information about the owner of a project.
+  *
+  * @param owner
+  *   The unique ID of the project owner.
+  * @param name
+  *   The name of the project owner which is supposed to be unique.
+  * @param email
+  *   The email address of the project owner.
+  */
+final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress)
+
+object ProjectOwner {
+    given Eq[ProjectOwner] = Eq.fromUniversalEquals
+}
+
+/** A project is the base entity for tracking tickets.
+  *
+  * @param owner
+  *   The owner of the project.
+  * @param name
+  *   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.
+  * @param description
+  *   An optional short text description of the project.
+  * @param isPrivate
+  *   A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate
+  *   permissions.
+  */
+final case class Project(
+    owner: ProjectOwner,
+    name: ProjectName,
+    description: Option[ProjectDescription],
+    isPrivate: Boolean
+)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,91 @@
+/*
+ * 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 cats.effect.*
+import doobie.util.log.*
+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[F[_]: Sync](log: Logger): LogHandler[F] =
+        new LogHandler[F] {
+            def run(logEvent: LogEvent): F[Unit] =
+                Sync[F].delay {
+                    logEvent match {
+                        case Success(sqlQuery, arguments, label, executionTime, processingTime) =>
+                            log.debug(s"""SQL command successful:
+                           |
+                           | ${sqlQueryToLogString(sqlQuery)}
+                           |
+                           | arguments: [${sqlArgumentsToLogString(arguments)}]
+                           | label: $label
+                           |
+                           | execution time : ${executionTime.toMillis} ms
+                           | processing time: ${processingTime.toMillis} ms
+                           | total time     : ${(executionTime + processingTime).toMillis} ms
+                           |""".stripMargin)
+                        case ProcessingFailure(sqlQuery, arguments, label, executionTime, processingTime, failure) =>
+                            log.error(
+                                s"""SQL PROCESSING FAILURE:
+                   |
+                   | ${sqlQueryToLogString(sqlQuery)}
+                   |
+                   | arguments: [${sqlArgumentsToLogString(arguments)}]
+                   | label: $label
+                   |
+                   | execution time : ${executionTime.toMillis} ms
+                   | processing time: ${processingTime.toMillis} ms
+                   | total time     : ${(executionTime + processingTime).toMillis} ms
+                   |""".stripMargin,
+                                failure
+                            )
+                        case ExecFailure(sqlQuery, arguments, label, executionTime, failure) =>
+                            log.error(
+                                s"""SQL EXECUTION FAILURE:
+                   |
+                   | ${sqlQueryToLogString(sqlQuery)}
+                   |
+                   | arguments: [${sqlArgumentsToLogString(arguments)}]
+                   | label: $label
+                   |
+                   | execution time : ${executionTime.toMillis} ms
+                   |""".stripMargin,
+                                failure
+                            )
+                    }
+                }
+        }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,163 @@
+/*
+ * 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.*
+import cats.data.*
+import cats.syntax.all.*
+
+/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
+opaque type SubmitterId = UUID
+object SubmitterId {
+    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+    given Eq[SubmitterId] = Eq.fromUniversalEquals
+
+    /** Create an instance of SubmitterId from the given UUID type.
+      *
+      * @param source
+      *   An instance of type UUID which will be returned as a SubmitterId.
+      * @return
+      *   The appropriate instance of SubmitterId.
+      */
+    def apply(source: UUID): SubmitterId = source
+
+    /** Try to create an instance of SubmitterId from the given UUID.
+      *
+      * @param source
+      *   A UUID that should fulfil the requirements to be converted into a SubmitterId.
+      * @return
+      *   An option to the successfully converted SubmitterId.
+      */
+    def from(source: UUID): Option[SubmitterId] = Option(source)
+
+    /** Try to create an instance of SubmitterId from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a SubmitterId.
+      * @return
+      *   An option to the successfully converted SubmitterId.
+      */
+    def fromString(source: String): Either[String, SubmitterId] =
+        Option(source)
+            .filter(s => Format.matches(s))
+            .flatMap { uuidString =>
+                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+            }
+            .toRight("Illegal value for SubmitterId!")
+
+    /** Generate a new random user id.
+      *
+      * @return
+      *   A user id which is pseudo randomly generated.
+      */
+    def randomSubmitterId: SubmitterId = UUID.randomUUID
+
+    extension (uid: SubmitterId) {
+        def toUUID: UUID = uid
+    }
+}
+
+/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
+  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
+  * lowercase.
+  */
+opaque type SubmitterName = String
+object SubmitterName {
+    given Eq[SubmitterName] = Eq.fromUniversalEquals
+
+    val isAlphanumeric = "^[a-z][a-z0-9]+$".r
+
+    /** Create an instance of SubmitterName from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a SubmitterName.
+      * @return
+      *   The appropriate instance of SubmitterName.
+      */
+    def apply(source: String): SubmitterName = source
+
+    /** Try to create an instance of SubmitterName from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a SubmitterName.
+      * @return
+      *   An option to the successfully converted SubmitterName.
+      */
+    def from(s: String): Option[SubmitterName] = validate(s).toOption
+
+    /** Validate the given string and return either the validated username or a list of errors.
+      *
+      * @param s
+      *   An arbitrary string which should be a username.
+      * @return
+      *   Either a list of errors or the validated username.
+      */
+    def validate(s: String): ValidatedNec[String, SubmitterName] =
+        Option(s).map(_.trim.nonEmpty) match {
+            case Some(true) =>
+                val input = s.trim
+                val miniumLength =
+                    if (input.length >= 2)
+                        input.validNec
+                    else
+                        "SubmitterName too short (min. 2 characters)!".invalidNec
+                val maximumLength =
+                    if (input.length < 32)
+                        input.validNec
+                    else
+                        "SubmitterName too long (max. 31 characters)!".invalidNec
+                val alphanumeric =
+                    if (isAlphanumeric.matches(input))
+                        input.validNec
+                    else
+                        "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec
+                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
+                    name
+                }
+            case _ => "SubmitterName must not be empty!".invalidNec
+        }
+}
+
+/** Extractor to retrieve an SubmitterName from a path parameter.
+  */
+object SubmitterNamePathParameter {
+    def unapply(str: String): Option[SubmitterName] =
+        Option(str).flatMap { string =>
+            if (string.startsWith("~"))
+                SubmitterName.from(string.drop(1))
+            else
+                None
+        }
+}
+
+/** The submitter for a ticket i.e. the person supposed to be working on it.
+  *
+  * @param id
+  *   A globally unique ID identifying the submitter.
+  * @param name
+  *   The name associated with the submitter which is supposed to be unique.
+  */
+final case class Submitter(id: SubmitterId, name: SubmitterName)
+
+object Submitter {
+    given Eq[Submitter] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,207 @@
+/*
+ * 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 fs2.Stream
+
+/** The base class that defines the needed functionality to handle tickets and related data types within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class TicketRepository[F[_]] {
+
+    /** Add the given assignee to the ticket of the given repository 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.
+      * @param assignee
+      *   The assignee to be added to the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int]
+
+    /** Add the given label to the ticket of the given repository 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.
+      * @param label
+      *   The label to be added to the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int]
+
+    /** Add the given milestone to the ticket of the given repository 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.
+      * @param milestone
+      *   The milestone to be added to the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int]
+
+    /** Return all tickets associated with the given repository.
+      *
+      * @param filter
+      *   A ticket filter containing possible values which will be used to filter the list of tickets.
+      * @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(filter: Option[TicketFilter])(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 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(projectId: ProjectId)(ticket: Ticket): F[Int]
+
+    /** Delete the ticket of the repository with the given id from the database.
+      *
+      * @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(projectId: ProjectId)(ticket: Ticket): F[Int]
+
+    /** Find the ticket with the given number of the repository with the given 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
+      *   An option to the found 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 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(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee]
+
+    /** Load all labels that are attached to the ticket with the given number and repository 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 stream of labels that may be empty.
+      */
+    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 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(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone]
+
+    /** Remove the given assignee from the ticket of the given repository 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.
+      * @param assignee
+      *   The assignee to be removed from the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int]
+
+    /** Remove the given label from the ticket of the given repository 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.
+      * @param label
+      *   The label to be removed from the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int]
+
+    /** Remove the given milestone from the ticket of the given repository 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.
+      * @param milestone
+      *   The milestone to be removed from the ticket.
+      * @return
+      *   The number of affected database rows.
+      */
+    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 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(projectId: ProjectId)(ticket: Ticket): F[Int]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2024-05-18 22:05:18.509443570 +0000
@@ -63,10 +63,8 @@
 ) extends Http4sDsl[F] {
     private val log = LoggerFactory.getLogger(getClass)
 
-    given CsrfProtectionConfiguration = configuration.csrfProtection
-
     private val linkToHubService = configuration.hub.baseUri
-    private val linkConfig       = configuration.externalUrl
+    private val linkConfig       = configuration.external
 
     /** 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.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,426 @@
+/*
+ * 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.*
+import cats.syntax.all.*
+import org.http4s.QueryParamDecoder
+import org.http4s.QueryParamEncoder
+import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher
+
+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
+object TicketContent {
+
+    /** Create an instance of TicketContent from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a TicketContent.
+      * @return
+      *   The appropriate instance of TicketContent.
+      */
+    def apply(source: String): TicketContent = source
+
+    /** Try to create an instance of TicketContent from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a TicketContent.
+      * @return
+      *   An option to the successfully converted TicketContent.
+      */
+    def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty)
+
+}
+
+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 Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y)
+    given Order[TicketNumber]    = Order.fromOrdering
+
+    val Format: Regex = "^-?\\d+$".r
+
+    /** Create an instance of TicketNumber from the given Int type.
+      *
+      * @param source
+      *   An instance of type Int which will be returned as a TicketNumber.
+      * @return
+      *   The appropriate instance of TicketNumber.
+      */
+    def apply(source: Int): TicketNumber = source
+
+    /** Try to create an instance of TicketNumber from the given Int.
+      *
+      * @param source
+      *   A Int that should fulfil the requirements to be converted into a TicketNumber.
+      * @return
+      *   An option to the successfully converted TicketNumber.
+      */
+    def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0)
+
+    /** Try to create an instance of TicketNumber from the given String.
+      *
+      * @param source
+      *   A string that should fulfil the requirements to be converted into a TicketNumber.
+      * @return
+      *   An option to the successfully converted TicketNumber.
+      */
+    def fromString(source: String): Option[TicketNumber] =
+        Option(source).filter(Format.matches).map(_.toInt).flatMap(from)
+
+    extension (number: TicketNumber) {
+        def toInt: Int = number.toInt
+    }
+}
+
+/** Extractor to retrieve a TicketNumber from a path parameter.
+  */
+object TicketNumberPathParameter {
+    def unapply(str: String): Option[TicketNumber] = Option(str).flatMap(TicketNumber.fromString)
+}
+
+/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things
+  * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket.
+  */
+enum TicketStatus {
+
+    /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be
+      * present.
+      */
+    case Confirmed
+
+    /** The ticket is being worked on i.e. it is in progress.
+      */
+    case InProgress
+
+    /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on
+      * the circumstances. This could be used to model the "blocked" state of Kanban.
+      */
+    case Pending
+
+    /** The ticket is resolved (i.e. closed) and considered done.
+      */
+    case Resolved
+
+    /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog"
+      * nowadays.
+      */
+    case Submitted
+}
+
+object TicketStatus {
+    given Eq[TicketStatus] = Eq.fromUniversalEquals
+
+    /** Try to parse a ticket status instance from the given string without throwin an exception like `valueOf`.
+      *
+      * @param source
+      *   A string that should contain the name of a ticket status.
+      * @return
+      *   An option to the successfully deserialised instance.
+      */
+    def fromString(source: String): Option[TicketStatus] =
+        TicketStatus.values.map(_.toString).find(_ === source).map(TicketStatus.valueOf)
+}
+
+/** Possible types of "resolved states" of a ticket.
+  */
+enum TicketResolution {
+
+    /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered
+      * to be a bug.
+      */
+    case ByDesign
+
+    /** The ticket is finally closed and considered done.
+      *
+      * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and
+      * tester can later move the ticket to `Closed`.
+      */
+    case Closed
+
+    /** The ticket is a duplicate of an already existing one.
+      */
+    case Duplicate
+
+    /** The bug described in the ticket was fixed.
+      */
+    case Fixed
+
+    /** The feature described in the ticket was implemented.
+      */
+    case Implemented
+
+    /** The ticket is considered to be invalid.
+      */
+    case Invalid
+
+    /** The issue described in the ticket will not be fixed.
+      */
+    case WontFix
+}
+
+object TicketResolution {
+    given Eq[TicketResolution] = Eq.fromUniversalEquals
+
+    /** Try to parse a ticket resolution instance from the given string without throwin an exception like `valueOf`.
+      *
+      * @param source
+      *   A string that should contain the name of a ticket resolution.
+      * @return
+      *   An option to the successfully deserialised instance.
+      */
+    def fromString(source: String): Option[TicketResolution] =
+        TicketResolution.values.map(_.toString).find(_ === source).map(TicketResolution.valueOf)
+}
+
+/** A concise and short description of the ticket which should not exceed 80 characters.
+  */
+opaque type TicketTitle = String
+object TicketTitle {
+
+    val MaxLength: Int = 72
+
+    /** Create an instance of TicketTitle from the given String type.
+      *
+      * @param source
+      *   An instance of type String which will be returned as a TicketTitle.
+      * @return
+      *   The appropriate instance of TicketTitle.
+      */
+    def apply(source: String): TicketTitle = source
+
+    /** Try to create an instance of TicketTitle from the given String.
+      *
+      * @param source
+      *   A String that should fulfil the requirements to be converted into a TicketTitle.
+      * @return
+      *   An option to the successfully converted TicketTitle.
+      */
+    def from(source: String): Option[TicketTitle] =
+        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
+}
+
+/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project.
+  *
+  * @param number
+  *   The unique identifier of a ticket within the project scope is its number.
+  * @param title
+  *   A concise and short description of the ticket which should not exceed 72 characters.
+  * @param content
+  *   An optional field to describe the ticket in great detail if needed.
+  * @param status
+  *   The current status of the ticket describing its life cycle.
+  * @param resolution
+  *   An optional resolution state of the ticket that should be set if it is closed.
+  * @param submitter
+  *   The person who submitted (created) this ticket which is optional because of possible account deletion or other
+  *   reasons.
+  * @param createdAt
+  *   The timestamp when the ticket was created / submitted.
+  * @param updatedAt
+  *   The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.
+  */
+final case class Ticket(
+    number: TicketNumber,
+    title: TicketTitle,
+    content: Option[TicketContent],
+    status: TicketStatus,
+    resolution: Option[TicketResolution],
+    submitter: Option[Submitter],
+    createdAt: OffsetDateTime,
+    updatedAt: OffsetDateTime
+)
+
+/** A data container for values that can be used to filter a list of tickets by.
+  *
+  * @param number
+  *   A list of ticket numbers that must be matched.
+  * @param status
+  *   A list of ticket status flags that must be matched.
+  * @param resolution
+  *   A list of ticket resolution kinds that must be matched.
+  * @param submitter
+  *   A list of usernames from whom the ticket must have been submitted.
+  */
+final case class TicketFilter(
+    number: List[TicketNumber],
+    status: List[TicketStatus],
+    resolution: List[TicketResolution],
+    submitter: List[SubmitterName]
+)
+
+object TicketFilter {
+    given QueryParamDecoder[TicketFilter] = QueryParamDecoder[String].map(TicketFilter.fromQueryParameter)
+    given QueryParamEncoder[TicketFilter] = QueryParamEncoder[String].contramap(_.toQueryParameter)
+
+    /** Decode an optional possibly existing query parameter into a `TicketFilter`.
+      *
+      * Usage: `case GET -> Root / "..." :? OptionalUrlParamter(maybeFilter) => ...`
+      */
+    object OptionalUrlParameter extends OptionalQueryParamDecoderMatcher[TicketFilter]("q")
+
+    // Only "open" tickets.
+    val OpenTicketsOnly = TicketFilter(
+        number = Nil,
+        status = TicketStatus.values.filterNot(_ === TicketStatus.Resolved).toList,
+        resolution = Nil,
+        submitter = Nil
+    )
+    // Only resolved (closed) tickets.
+    val ResolvedTicketsOnly =
+        TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil)
+
+    /** Parse the given query string which must contain a serialised ticket filter instance and return a ticket filter
+      * with the successfully parsed filters.
+      *
+      * @param queryString
+      *   A query string parameter passed via an URL.
+      * @return
+      *   A ticket filter instance which may be empty.
+      */
+    def fromQueryParameter(queryString: String): TicketFilter = {
+        val number =
+            if (queryString.contains("numbers: "))
+                queryString
+                    .drop(queryString.indexOf("numbers: ") + 9)
+                    .takeWhile(char => !char.isWhitespace)
+                    .split(",")
+                    .map(TicketNumber.fromString)
+                    .flatten
+                    .toList
+            else
+                Nil
+        val status =
+            if (queryString.contains("status: "))
+                queryString
+                    .drop(queryString.indexOf("status: ") + 8)
+                    .takeWhile(char => !char.isWhitespace)
+                    .split(",")
+                    .map(TicketStatus.fromString)
+                    .flatten
+                    .toList
+            else
+                Nil
+        val resolution =
+            if (queryString.contains("resolution: "))
+                queryString
+                    .drop(queryString.indexOf("resolution: ") + 12)
+                    .takeWhile(char => !char.isWhitespace)
+                    .split(",")
+                    .map(TicketResolution.fromString)
+                    .flatten
+                    .toList
+            else
+                Nil
+        val submitter =
+            if (queryString.contains("by: "))
+                queryString
+                    .drop(queryString.indexOf("by: ") + 4)
+                    .takeWhile(char => !char.isWhitespace)
+                    .split(",")
+                    .map(SubmitterName.from)
+                    .flatten
+                    .toList
+            else
+                Nil
+        TicketFilter(number, status, resolution, submitter)
+    }
+
+    extension (filter: TicketFilter) {
+
+        /** Convert this ticket filter instance into a query string representation that can be passed as query parameter
+          * in a URL and parsed back again.
+          *
+          * @return
+          *   A string containing a serialised form of the ticket filter that can be used as a URL query parameter.
+          */
+        def toQueryParameter: String = {
+            val numbers =
+                if (filter.number.isEmpty)
+                    None
+                else
+                    filter.number.map(_.toString).mkString(",").some
+            val status =
+                if (filter.status.isEmpty)
+                    None
+                else
+                    filter.status.map(_.toString).mkString(",").some
+            val resolution =
+                if (filter.resolution.isEmpty)
+                    None
+                else
+                    filter.resolution.map(_.toString).mkString(",").some
+            val submitter =
+                if (filter.submitter.isEmpty)
+                    None
+                else
+                    filter.submitter.map(_.toString).mkString(",").some
+            List(
+                numbers.map(string => s"numbers: $string"),
+                status.map(string => s"status: $string"),
+                resolution.map(string => s"resolution: $string"),
+                submitter.map(string => s"by: $string")
+            ).flatten.mkString(" ")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketServiceApi.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,48 @@
+/*
+ * 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 de.smederee.security.UserId
+
+/** Definition of a programmatic API for the ticket service which can be used to initialise and synchronise data with
+  * the hub service.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class TicketServiceApi[F[_]] {
+
+    /** Create a user in the ticket service or update an existing one if an account with the unique id already exists.
+      *
+      * @param user
+      *   The user account that shall be created.
+      * @return
+      *   The number of affected database rows.
+      */
+    def createOrUpdateUser(user: TicketsUser): F[Int]
+
+    /** Delete the given user from the ticket service.
+      *
+      * @param uid
+      *   The unique id of the user account.
+      * @return
+      *   The number of affected database rows.
+      */
+    def deleteUser(uid: UserId): F[Int]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketsUser.scala	2024-05-18 22:05:18.509443570 +0000
@@ -0,0 +1,41 @@
+/*
+ * 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 cats.*
+import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.UserId
+import de.smederee.security.Username
+
+/** A user of the tickets service.
+  *
+  * @param uid
+  *   The unique ID of the user.
+  * @param name
+  *   A unique name which can be used for login and to identify the user.
+  * @param email
+  *   The email address of the user which must also be unique.
+  * @param language
+  *   The language code of the users preferred language.
+  */
+final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress, language: Option[LanguageCode])
+
+object TicketsUser {
+    given Eq[TicketsUser] = Eq.fromUniversalEquals
+}
diff -rN -u old-smederee/modules/hub/src/test/resources/application.conf new-smederee/modules/hub/src/test/resources/application.conf
--- old-smederee/modules/hub/src/test/resources/application.conf	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/hub/src/test/resources/application.conf	2024-05-18 22:05:18.513443528 +0000
@@ -10,3 +10,16 @@
     pass = ${?SMEDEREE_HUB_TEST_DB_PASS}
   }
 }
+
+tickets {
+  database {
+	host = localhost
+	host = ${?SMEDEREE_DB_HOST}
+	url  = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it"
+	url  = ${?SMEDEREE_TICKETS_TEST_DB_URL}
+	user = "smederee_tickets"
+	user = ${?SMEDEREE_TICKETS_TEST_DB_USER}
+	pass = "secret"
+	pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS}
+  }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/BaseSpec.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,369 @@
+/*
+ * 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.net.ServerSocket
+
+import cats.effect.*
+import cats.syntax.all.*
+import com.comcast.ip4s.*
+import com.typesafe.config.ConfigFactory
+import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.UserId
+import de.smederee.security.Username
+import de.smederee.tickets.config.*
+import org.flywaydb.core.Flyway
+import pureconfig.*
+
+import munit.*
+
+import scala.annotation.nowarn
+
+/** Base class for our integration test suites.
+  *
+  * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and
+  * does initialise the test database for each suite. The latter means a possibly existing database with the name
+  * configured **will be deleted**!
+  */
+abstract class BaseSpec extends CatsEffectSuite {
+    protected final val configuration: SmedereeTicketsConfiguration =
+        ConfigSource
+            .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+            .at(SmedereeTicketsConfiguration.location)
+            .loadOrThrow[SmedereeTicketsConfiguration]
+
+    protected final val flyway: Flyway =
+        DatabaseMigrator
+            .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass)
+            .cleanDisabled(false)
+            .load()
+
+    /** Connect to the DBMS using the generic "template1" database which should always be present.
+      *
+      * @param dbConfig
+      *   The database configuration.
+      * @return
+      *   The connection to the database ("template1").
+      */
+    private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] =
+        for {
+            _        <- IO(Class.forName(dbConfig.driver))
+            database <- IO(dbConfig.url.split("/").reverse.take(1).mkString)
+            connection <- IO(
+                java.sql.DriverManager
+                    .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass)
+            )
+        } yield connection
+
+    @nowarn("msg=discarded non-Unit value.*")
+    override def beforeAll(): Unit = {
+        // Extract the database name from the URL.
+        val database = configuration.database.url.split("/").reverse.take(1).mkString
+        val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
+        // Create the test database if it does not already exist.
+        db.use { connection =>
+            for {
+                statement <- IO(connection.createStatement())
+                exists <- IO(
+                    statement.executeQuery(
+                        s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'"""
+                    )
+                )
+                _ <- IO {
+                    if (!exists.next())
+                        statement.execute(s"""CREATE DATABASE "$database"""")
+                }
+                _ <- IO(exists.close)
+                _ <- IO(statement.close)
+            } yield ()
+        }.unsafeRunSync()
+    }
+
+    override def afterAll(): Unit = {
+        // Extract the database name from the URL.
+        val database = configuration.database.url.split("/").reverse.take(1).mkString
+        val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
+        // Drop the test database after all tests have been run.
+        db.use { connection =>
+            for {
+                statement <- IO(connection.createStatement())
+                _         <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database"""))
+                _         <- IO(statement.close)
+            } yield ()
+        }.unsafeRunSync()
+    }
+
+    override def beforeEach(context: BeforeEach): Unit = {
+        val _ = flyway.migrate()
+    }
+
+    override def afterEach(context: AfterEach): Unit = {
+        val _ = flyway.clean()
+    }
+
+    /** Find and return a free port on the local machine by starting a server socket and closing it. The port number
+      * used by the socket is marked to allow reuse, considered free and returned.
+      *
+      * @return
+      *   An optional port number if a free one can be found.
+      */
+    protected def findFreePort(): Option[Port] = {
+        val socket = new ServerSocket(0)
+        val port   = socket.getLocalPort
+        socket.setReuseAddress(true) // Allow instant rebinding of the socket.
+        socket.close()               // Free the socket for further use by closing it.
+        Port.fromInt(port)
+    }
+
+    /** Provide a resource with a database connection to allow db operations and proper resource release later.
+      *
+      * @param cfg
+      *   The application configuration.
+      * @return
+      *   A cats resource encapsulation a database connection as defined within the given configuration.
+      */
+    protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] =
+        Resource.make(
+            IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass))
+        )(c => IO.delay(c.close()))
+
+    /** Create a project for ticket tracking in the database.
+      *
+      * @param project
+      *   The project 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 createTicketsProject(project: Project): IO[Int] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay {
+                    con.prepareStatement(
+                        """INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())"""
+                    )
+                }
+                _ <- IO.delay(statement.setString(1, project.name.toString))
+                _ <- IO.delay(statement.setObject(2, project.owner.uid))
+                _ <- IO.delay(statement.setBoolean(3, project.isPrivate))
+                _ <- IO.delay(
+                    project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr =>
+                        statement.setString(4, descr.toString)
+                    )
+                )
+                r <- IO.delay(statement.executeUpdate())
+                _ <- IO.delay(statement.close())
+            } 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
+      *   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 createTicketsUser(user: TicketsUser): 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, user.uid))
+                _ <- IO.delay(statement.setString(2, user.name.toString))
+                _ <- IO.delay(statement.setString(3, user.email.toString))
+                r <- IO.delay(statement.executeUpdate())
+                _ <- IO.delay(statement.close())
+            } yield r
+        }
+
+    /** Return the next ticket number for the given project.
+      *
+      * @param projectId
+      *   The internal database ID of the project.
+      * @return
+      *   The next ticket number.
+      */
+    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+    protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement(
+                        """SELECT next_ticket_number FROM tickets.projects WHERE id = ?"""
+                    )
+                )
+                _      <- IO.delay(statement.setLong(1, projectId.toLong))
+                result <- IO.delay(statement.executeQuery)
+                number <- IO.delay {
+                    result.next()
+                    result.getInt("next_ticket_number")
+                }
+                _ <- IO(statement.close())
+            } yield number
+        }
+
+    /** Find the project ID for the given owner and project name.
+      *
+      * @param owner
+      *   The unique ID of the user account that owns the project.
+      * @param name
+      *   The project name which must be unique in regard to the owner.
+      * @return
+      *   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[ProjectId]] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement(
+                        """SELECT id FROM tickets.projects WHERE owner = ? AND name = ? LIMIT 1"""
+                    )
+                )
+                _      <- IO.delay(statement.setObject(1, owner))
+                _      <- IO.delay(statement.setString(2, name.toString))
+                result <- IO.delay(statement.executeQuery)
+                projectId <- IO.delay {
+                    if (result.next()) {
+                        ProjectId.from(result.getLong("id"))
+                    } else {
+                        None
+                    }
+                }
+                _ <- IO(statement.close())
+            } yield projectId
+        }
+
+    /** 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)
+                ticketId <- IO.delay {
+                    if (result.next()) {
+                        TicketId.from(result.getLong("id"))
+                    } else {
+                        None
+                    }
+                }
+                _ <- IO(statement.close())
+            } yield ticketId
+        }
+
+    /** Find the ticket service user with the given user id.
+      *
+      * @param uid
+      *   The unique id of the user account.
+      * @return
+      *   An option to the loaded user.
+      */
+    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+    protected def loadTicketsUser(uid: UserId): IO[Option[TicketsUser]] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement("""SELECT uid, name, email, language FROM tickets.users WHERE uid = ?""")
+                )
+                _      <- IO.delay(statement.setObject(1, uid.toUUID))
+                result <- IO.delay(statement.executeQuery())
+                user <- IO.delay {
+                    if (result.next()) {
+                        val language = LanguageCode.from(result.getString("language"))
+                        (
+                            uid.some,
+                            Username.from(result.getString("name")),
+                            EmailAddress.from(result.getString("email"))
+                        ).mapN { case (uid, name, email) =>
+                            TicketsUser(uid, name, email, language)
+                        }
+                    } else {
+                        None
+                    }
+                }
+            } yield user
+        }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,42 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class ColourCodeTest extends ScalaCheckSuite {
+    given Arbitrary[ColourCode] = Arbitrary(genColourCode)
+
+    property("ColourCode.from must fail on invalid input") {
+        forAll { (input: String) =>
+            assertEquals(ColourCode.from(input), None)
+        }
+    }
+
+    property("ColourCode.from must succeed on valid input") {
+        forAll { (colourCode: ColourCode) =>
+            val input = colourCode.toString
+            assertEquals(ColourCode.from(input), Option(colourCode))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,65 @@
+/*
+ * 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.config
+
+import cats.effect.*
+import cats.syntax.all.*
+import de.smederee.TestTags.*
+import de.smederee.tickets.BaseSpec
+import org.flywaydb.core.Flyway
+
+final class DatabaseMigratorTest extends BaseSpec {
+    override def beforeEach(context: BeforeEach): Unit = {
+        val dbConfig = configuration.database
+        val flyway: Flyway =
+            DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+        val _ = flyway.migrate()
+        val _ = flyway.clean()
+    }
+
+    override def afterEach(context: AfterEach): Unit = {
+        val dbConfig = configuration.database
+        val flyway: Flyway =
+            DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+        val _ = flyway.migrate()
+        val _ = flyway.clean()
+    }
+
+    test("DatabaseMigrator must update available outdated database".tag(NeedsDatabase)) {
+        val dbConfig = configuration.database
+        val migrator = new DatabaseMigrator[IO]
+        val test     = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+        test.map(result => assert(result.migrationsExecuted > 0))
+    }
+
+    test("DatabaseMigrator must not update an up to date database".tag(NeedsDatabase)) {
+        val dbConfig = configuration.database
+        val migrator = new DatabaseMigrator[IO]
+        val test = for {
+            _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+            r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
+        } yield r
+        test.map(result => assert(result.migrationsExecuted === 0))
+    }
+
+    test("DatabaseMigrator must throw an exception if the database is not available".tag(NeedsDatabase)) {
+        val migrator = new DatabaseMigrator[IO]
+        val test     = migrator.migrate("jdbc:nodriver://", "", "")
+        test.attempt.map(r => assert(r.isLeft))
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,71 @@
+/*
+ * 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.config
+
+import com.comcast.ip4s.*
+import com.typesafe.config.*
+import org.http4s.Uri
+import org.http4s.implicits.*
+import pureconfig.*
+
+import munit.*
+
+final class SmedereeTicketsConfigurationTest extends FunSuite {
+    val rawDefaultConfig = new Fixture[Config]("defaultConfig") {
+        def apply() = ConfigFactory.load(getClass.getClassLoader)
+    }
+
+    override def munitFixtures = List(rawDefaultConfig)
+
+    test("must load from the default configuration successfully") {
+        ConfigSource
+            .fromConfig(rawDefaultConfig())
+            .at(s"${SmedereeTicketsConfiguration.location.toString}")
+            .load[SmedereeTicketsConfiguration] match {
+                case Left(errors) => fail(errors.toList.mkString(", "))
+                case Right(_)     => assert(true)
+            }
+    }
+
+    test("default values for external linking must be setup for local development") {
+        ConfigSource
+            .fromConfig(rawDefaultConfig())
+            .at(s"${SmedereeTicketsConfiguration.location.toString}")
+            .load[SmedereeTicketsConfiguration] match {
+                case Left(errors) => fail(errors.toList.mkString(", "))
+                case Right(cfg) =>
+                    val externalCfg = cfg.external
+                    assertEquals(externalCfg.host, host"localhost")
+                    assertEquals(externalCfg.port, Option(port"8080"))
+                    assert(externalCfg.path.isEmpty)
+                    assertEquals(externalCfg.scheme, Uri.Scheme.http)
+            }
+    }
+
+    test("default values for hub service integration must be setup for local development") {
+        ConfigSource
+            .fromConfig(rawDefaultConfig())
+            .at(s"${SmedereeTicketsConfiguration.location.toString}")
+            .load[SmedereeTicketsConfiguration] match {
+                case Left(errors) => fail(errors.toList.mkString(", "))
+                case Right(cfg) =>
+                    val expectedUri = uri"http://localhost:8080"
+                    assertEquals(cfg.hub.baseUri, expectedUri)
+            }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,312 @@
+/*
+ * 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 cats.effect.*
+import cats.syntax.all.*
+import de.smederee.TestTags.*
+import de.smederee.tickets.Generators.*
+import doobie.*
+
+final class DoobieLabelRepositoryTest extends BaseSpec {
+
+    /** Find the label ID for the given project and label name.
+      *
+      * @param owner
+      *   The unique ID of the user account that owns the project.
+      * @param vcsRepoName
+      *   The project name which must be unique in regard to the owner.
+      * @param labelName
+      *   The label name which must be unique in the project context.
+      * @return
+      *   An option to the internal database ID.
+      */
+    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+    protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement(
+                        """SELECT "labels".id
+              |FROM "tickets"."labels" AS "labels"
+              |JOIN "tickets"."projects" AS "projects"
+              |ON "labels".project = "projects".id
+              |WHERE "projects".owner = ?
+              |AND "projects".name = ?
+              |AND "labels".name = ?""".stripMargin
+                    )
+                )
+                _      <- IO.delay(statement.setObject(1, owner))
+                _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
+                _      <- IO.delay(statement.setString(3, labelName.toString))
+                result <- IO.delay(statement.executeQuery)
+                account <- IO.delay {
+                    if (result.next()) {
+                        Option(result.getLong("id"))
+                    } else {
+                        None
+                    }
+                }
+                _ <- IO(statement.close())
+            } yield account
+        }
+
+    test("allLabels must return all labels".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _         <- createProjectOwner(owner)
+                    _         <- createTicketsProject(project)
+                    projectId <- loadProjectId(owner.uid, project.name)
+                    createdLabels <- projectId match {
+                        case None            => IO.pure(List.empty)
+                        case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
+                    }
+                    foundLabels <- projectId match {
+                        case None            => IO.pure(List.empty)
+                        case Some(projectId) => labelRepo.allLabels(projectId).compile.toList
+                    }
+                } yield foundLabels
+                test.map { foundLabels =>
+                    assert(foundLabels.size === labels.size, "Different number of labels!")
+                    foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) =>
+                        assertEquals(found.copy(id = expected.id), expected)
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createLabel must create the label".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
+                } yield (createdProjects, projectId, createdLabels, foundLabel)
+                test.map { tuple =>
+                    val (createdProjects, projectId, createdLabels, foundLabel) = tuple
+                    assert(createdProjects === 1, "Test project was not created!")
+                    assert(projectId.nonEmpty, "No project id found!")
+                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
+                    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)
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createLabel must fail if the label name already exists".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                    _               <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                } yield (createdProjects, projectId, createdLabels)
+                test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!"))
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("deleteLabel must delete an existing label".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                    labelId         <- findLabelId(owner.uid, project.name, label.name)
+                    deletedLabels   <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from)))
+                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
+                } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel)
+                test.map { tuple =>
+                    val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple
+                    assert(createdProjects === 1, "Test vcs project was not created!")
+                    assert(projectId.nonEmpty, "No vcs project id found!")
+                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
+                    assert(deletedLabels === 1, "Test label was not deleted!")
+                    assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findLabel must find existing labels".tag(NeedsDatabase)) {
+        (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 expectedLabel = labels(scala.util.Random.nextInt(labels.size))
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels <- projectId match {
+                        case None            => IO.pure(List.empty)
+                        case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
+                    }
+                    foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name))
+                } yield foundLabel.flatten
+                test.map { foundLabel =>
+                    assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("updateLabel must update an existing label".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genLabel.sample) match {
+            case (Some(owner), Some(generatedProject), Some(label)) =>
+                val updatedLabel = label.copy(
+                    name = LabelName("updated label"),
+                    description = Option(LabelDescription("I am an updated label description...")),
+                    colour = ColourCode("#abcdef")
+                )
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                    labelId         <- findLabelId(owner.uid, project.name, label.name)
+                    updatedLabels   <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply)))
+                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name))
+                } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten)
+                test.map { tuple =>
+                    val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple
+                    assert(createdProjects === 1, "Test vcs project was not created!")
+                    assert(projectId.nonEmpty, "No vcs project id found!")
+                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
+                    assert(updatedLabels === 1, "Test label was not updated!")
+                    assert(foundLabel.nonEmpty, "Updated label not found!")
+                    foundLabel.map { label =>
+                        assertEquals(label, updatedLabel.copy(id = label.id))
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("updateLabel must do nothing if id attribute is empty".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genLabel.sample) match {
+            case (Some(owner), Some(generatedProject), Some(label)) =>
+                val updatedLabel = label.copy(
+                    id = None,
+                    name = LabelName("updated label"),
+                    description = Option(LabelDescription("I am an updated label description...")),
+                    colour = ColourCode("#abcdef")
+                )
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val labelRepo = new DoobieLabelRepository[IO](tx)
+                val test = for {
+                    _               <- createProjectOwner(owner)
+                    createdProjects <- createTicketsProject(project)
+                    projectId       <- loadProjectId(owner.uid, project.name)
+                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
+                    labelId         <- findLabelId(owner.uid, project.name, label.name)
+                    updatedLabels   <- labelRepo.updateLabel(updatedLabel)
+                } yield (createdProjects, projectId, createdLabels, updatedLabels)
+                test.map { tuple =>
+                    val (createdProjects, projectId, createdLabels, updatedLabels) = tuple
+                    assert(createdProjects === 1, "Test vcs project was not created!")
+                    assert(projectId.nonEmpty, "No vcs project id found!")
+                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
+                    assert(updatedLabels === 0, "Label with empty id must not be updated!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,446 @@
+/*
+ * 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.*
+
+import cats.effect.*
+import cats.syntax.all.*
+import de.smederee.TestTags.*
+import de.smederee.tickets.Generators.*
+import doobie.*
+
+final class DoobieMilestoneRepositoryTest extends BaseSpec {
+
+    /** Find the milestone ID for the given repository and milestone title.
+      *
+      * @param owner
+      *   The unique ID of the user owner that owns the repository.
+      * @param projectName
+      *   The project name which must be unique in regard to the owner.
+      * @param title
+      *   The milestone title which must be unique in the repository context.
+      * @return
+      *   An option to the internal database ID.
+      */
+    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+    protected def findMilestoneId(
+        owner: ProjectOwnerId,
+        projectName: ProjectName,
+        title: MilestoneTitle
+    ): IO[Option[Long]] =
+        connectToDb(configuration).use { con =>
+            for {
+                statement <- IO.delay(
+                    con.prepareStatement(
+                        """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?"""
+                    )
+                )
+                _      <- IO.delay(statement.setObject(1, owner))
+                _      <- IO.delay(statement.setString(2, projectName.toString))
+                _      <- IO.delay(statement.setString(3, title.toString))
+                result <- IO.delay(statement.executeQuery)
+                owner <- IO.delay {
+                    if (result.next()) {
+                        Option(result.getLong("id"))
+                    } else {
+                        None
+                    }
+                }
+                _ <- IO(statement.close())
+            } yield owner
+        }
+
+    test("allMilestones must return all milestones".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    createdRepos <- createTicketsProject(project)
+                    repoId       <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId match {
+                        case None => IO.pure(List.empty)
+                        case Some(repoId) =>
+                            milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+                    }
+                    foundMilestones <- repoId match {
+                        case None         => IO.pure(List.empty)
+                        case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList
+                    }
+                } yield foundMilestones
+                test.map { foundMilestones =>
+                    assert(foundMilestones.size === milestones.size, "Different number of milestones!")
+                    foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) =>
+                        assertEquals(found.copy(id = expected.id), expected)
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allTickets must return all tickets associated with the milestone".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genMilestone.sample, genTickets.sample) match {
+            case (Some(owner), Some(generatedProject), Some(milestone), Some(rawTickets)) =>
+                val project  = generatedProject.copy(owner = owner)
+                val tickets  = rawTickets.map(_.copy(submitter = None))
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val ticketRepo    = new DoobieTicketRepository[IO](tx)
+                val test = for {
+                    _      <- createProjectOwner(owner)
+                    _      <- createTicketsProject(project)
+                    repoId <- loadProjectId(owner.uid, project.name)
+                    foundTickets <- repoId match {
+                        case None => IO.pure(List.empty)
+                        case Some(projectId) =>
+                            for {
+                                _ <- milestoneRepo.createMilestone(projectId)(milestone)
+                                _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket))
+                                createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                                _ <- createdMilestone match {
+                                    case None => IO.pure(List.empty)
+                                    case Some(milestone) =>
+                                        tickets.traverse(ticket =>
+                                            ticketRepo.addMilestone(projectId)(ticket.number)(milestone)
+                                        )
+                                }
+                                foundTickets <- createdMilestone.map(_.id).getOrElse(None) match {
+                                    case None              => IO.pure(List.empty)
+                                    case Some(milestoneId) => milestoneRepo.allTickets(None)(milestoneId).compile.toList
+                                }
+                            } yield foundTickets
+                    }
+                } yield foundTickets
+                test.map { foundTickets =>
+                    assertEquals(foundTickets.size, tickets.size, "Different number of tickets!")
+                    foundTickets.sortBy(_.number).zip(tickets.sortBy(_.number)).map { (found, expected) =>
+                        assertEquals(
+                            found.copy(createdAt = expected.createdAt, updatedAt = expected.updatedAt),
+                            expected
+                        )
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("closeMilestone must set the closed flag correctly".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+            case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
+                val milestone = generatedMilestone.copy(closed = false)
+                val project   = generatedProject.copy(owner = owner)
+                val dbConfig  = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    createdRepos <- createTicketsProject(project)
+                    repoId       <- loadProjectId(owner.uid, project.name)
+                    milestones <- repoId match {
+                        case None => IO.pure((None, None))
+                        case Some(projectId) =>
+                            for {
+                                _      <- milestoneRepo.createMilestone(projectId)(milestone)
+                                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                                _      <- before.flatMap(_.id).traverse(milestoneRepo.closeMilestone)
+                                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                            } yield (before, after)
+                    }
+                } yield milestones
+                test.map { result =>
+                    val (before, after) = result
+                    val expected        = before.map(m => milestone.copy(id = m.id))
+                    assertEquals(before, expected, "Test milestone not properly initialised!")
+                    assertEquals(after, before.map(_.copy(closed = true)), "Test milestone not properly closed!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createMilestone must create the milestone".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _                 <- createProjectOwner(owner)
+                    createdRepos      <- createTicketsProject(project)
+                    repoId            <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                    foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+                } yield (createdRepos, repoId, createdMilestones, foundMilestone)
+                test.map { tuple =>
+                    val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple
+                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+                    foundMilestone.getOrElse(None) match {
+                        case None => fail("Created milestone not found!")
+                        case Some(foundMilestone) =>
+                            assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id))
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createMilestone must fail if the milestone name already exists".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _                 <- createProjectOwner(owner)
+                    createdRepos      <- createTicketsProject(project)
+                    repoId            <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                    _                 <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                } yield (createdRepos, repoId, createdMilestones)
+                test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!"))
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("deleteMilestone must delete an existing milestone".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _                 <- createProjectOwner(owner)
+                    createdRepos      <- createTicketsProject(project)
+                    repoId            <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+                    deletedMilestones <- milestoneRepo.deleteMilestone(
+                        milestone.copy(id = milestoneId.flatMap(MilestoneId.from))
+                    )
+                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+                } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone)
+                test.map { tuple =>
+                    val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple
+                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+                    assert(deletedMilestones === 1, "Test milestone was not deleted!")
+                    assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findMilestone must find existing milestones".tag(NeedsDatabase)) {
+        (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 expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    createdRepos <- createTicketsProject(project)
+                    repoId       <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId match {
+                        case None => IO.pure(List.empty)
+                        case Some(repoId) =>
+                            milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+                    }
+                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title))
+                } yield foundMilestone.flatten
+                test.map { foundMilestone =>
+                    assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("openMilestone must reset the closed flag correctly".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+            case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
+                val milestone = generatedMilestone.copy(closed = true)
+                val project   = generatedProject.copy(owner = owner)
+                val dbConfig  = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    createdRepos <- createTicketsProject(project)
+                    repoId       <- loadProjectId(owner.uid, project.name)
+                    milestones <- repoId match {
+                        case None => IO.pure((None, None))
+                        case Some(projectId) =>
+                            for {
+                                _      <- milestoneRepo.createMilestone(projectId)(milestone)
+                                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                                _      <- before.flatMap(_.id).traverse(milestoneRepo.openMilestone)
+                                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                            } yield (before, after)
+                    }
+                } yield milestones
+                test.map { result =>
+                    val (before, after) = result
+                    val expected        = before.map(m => milestone.copy(id = m.id))
+                    assertEquals(before, expected, "Test milestone not properly initialised!")
+                    assertEquals(after, before.map(_.copy(closed = false)), "Test milestone not properly opened!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("updateMilestone must update an existing milestone".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+            case (Some(owner), Some(generatedProject), Some(milestone)) =>
+                val updatedMilestone = milestone.copy(
+                    title = MilestoneTitle("updated milestone"),
+                    description = Option(MilestoneDescription("I am an updated milestone description...")),
+                    dueDate = Option(LocalDate.of(1879, 3, 14))
+                )
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _                 <- createProjectOwner(owner)
+                    createdRepos      <- createTicketsProject(project)
+                    repoId            <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+                    updatedMilestones <- milestoneRepo.updateMilestone(
+                        updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply))
+                    )
+                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title))
+                } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten)
+                test.map { tuple =>
+                    val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple
+                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+                    assert(updatedMilestones === 1, "Test milestone was not updated!")
+                    assert(foundMilestone.nonEmpty, "Updated milestone not found!")
+                    foundMilestone.map { milestone =>
+                        assertEquals(milestone, updatedMilestone.copy(id = milestone.id))
+                    }
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("updateMilestone must do nothing if id attribute is empty".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+            case (Some(owner), Some(generatedProject), Some(milestone)) =>
+                val updatedMilestone = milestone.copy(
+                    id = None,
+                    title = MilestoneTitle("updated milestone"),
+                    description = Option(MilestoneDescription("I am an updated milestone description...")),
+                    dueDate = Option(LocalDate.of(1879, 3, 14))
+                )
+                val project  = generatedProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+                val test = for {
+                    _                 <- createProjectOwner(owner)
+                    createdRepos      <- createTicketsProject(project)
+                    repoId            <- loadProjectId(owner.uid, project.name)
+                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
+                    updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone)
+                } yield (createdRepos, repoId, createdMilestones, updatedMilestones)
+                test.map { tuple =>
+                    val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple
+                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
+                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
+                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+                    assert(updatedMilestones === 0, "Milestone with empty id must not be updated!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,227 @@
+/*
+ * 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 cats.effect.*
+import cats.syntax.all.*
+import de.smederee.TestTags.*
+import de.smederee.tickets.Generators.*
+import doobie.*
+
+final class DoobieProjectRepositoryTest extends BaseSpec {
+    test("createProject must create a project".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    _            <- projectRepo.createProject(project)
+                    foundProject <- projectRepo.findProject(owner, project.name)
+                } yield foundProject
+                test.map { foundProject =>
+                    assertEquals(foundProject, Some(project))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("deleteProject must delete a project".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    _            <- createTicketsProject(project)
+                    deleted      <- projectRepo.deleteProject(project)
+                    foundProject <- projectRepo.findProject(owner, project.name)
+                } yield (deleted, foundProject)
+                test.map { result =>
+                    val (deleted, foundProject) = result
+                    assert(deleted > 0, "Rows not deleted from database!")
+                    assert(foundProject.isEmpty, "Project not deleted from database!")
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findProject must return the matching project".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _ <- createProjectOwner(owner)
+                    _ <- createTicketsProject(project)
+                    _ <- projects
+                        .filterNot(_.name === project.name)
+                        .traverse(p => createTicketsProject(p.copy(owner = owner)))
+                    foundProject <- projectRepo.findProject(owner, project.name)
+                } yield foundProject
+                test.map { foundProject =>
+                    assertEquals(foundProject, Some(project))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findProjectId must return the matching id".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _ <- createProjectOwner(owner)
+                    _ <- createTicketsProject(project)
+                    _ <- projects
+                        .filterNot(_.name === project.name)
+                        .traverse(p => createTicketsProject(p.copy(owner = owner)))
+                    foundProjectId <- projectRepo.findProjectId(owner, project.name)
+                    projectId      <- loadProjectId(owner.uid, project.name)
+                } yield (foundProjectId, projectId)
+                test.map { result =>
+                    val (foundProjectId, projectId) = result
+                    assertEquals(foundProjectId, projectId)
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("findProjectOwner must return the matching project owner".tag(NeedsDatabase)) {
+        genProjectOwners.sample match {
+            case Some(owner :: owners) =>
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _          <- createProjectOwner(owner)
+                    _          <- owners.filterNot(_.name === owner.name).traverse(createProjectOwner)
+                    foundOwner <- projectRepo.findProjectOwner(owner.name)
+                } yield foundOwner
+                test.map { foundOwner =>
+                    assert(foundOwner.exists(_ === owner))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("incrementNextTicketNumber must return and increment the old value".tag(NeedsDatabase)) {
+        (genProjectOwner.sample, genProject.sample) match {
+            case (Some(owner), Some(firstProject)) =>
+                val project  = firstProject.copy(owner = owner)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _         <- createProjectOwner(owner)
+                    _         <- projectRepo.createProject(project)
+                    projectId <- loadProjectId(owner.uid, project.name)
+                    result <- projectId match {
+                        case None => fail("Project was not created!")
+                        case Some(projectId) =>
+                            for {
+                                before <- loadNextTicketNumber(projectId)
+                                number <- projectRepo.incrementNextTicketNumber(projectId)
+                                after  <- loadNextTicketNumber(projectId)
+                            } yield (TicketNumber(before), number, TicketNumber(after))
+                    }
+                } yield result
+                test.map { result =>
+                    val (before, number, after) = result
+                    assertEquals(before, number)
+                    assertEquals(after, TicketNumber(number.toInt + 1))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("updateProject must update a project".tag(NeedsDatabase)) {
+        (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)
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val projectRepo = new DoobieProjectRepository[IO](tx)
+                val test = for {
+                    _            <- createProjectOwner(owner)
+                    _            <- projectRepo.createProject(project)
+                    written      <- projectRepo.updateProject(updatedProject)
+                    foundProject <- projectRepo.findProject(owner, project.name)
+                } yield (written, foundProject)
+                test.map { result =>
+                    val (written, foundProject) = result
+                    assert(written > 0, "Rows not updated in database!")
+                    assertEquals(foundProject, Some(updatedProject))
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,899 @@
+/*
+ * 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.TestTags.*
+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!")
+    @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while"))
+    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!")
+    @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while"))
+    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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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(filter = None)(projectId).compile.toList
+                            } yield (writtenTickets.sum, foundTickets)
+                    }
+                } yield result
+                test.map { result =>
+                    val (writtenTickets, foundTickets) = result
+                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
+                    assertEquals(
+                        foundTickets.size,
+                        writtenTickets,
+                        "Number of returned tickets differs from number of created tickets!"
+                    )
+                    assertEquals(
+                        foundTickets
+                            .sortBy(_.number)
+                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
+                        tickets.sortBy(_.number)
+                    )
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allTickets must respect given filters for numbers".tag(NeedsDatabase)) {
+        (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 expectedTickets = tickets.take(tickets.size / 2)
+                val filter          = TicketFilter(number = expectedTickets.map(_.number), Nil, Nil, Nil)
+                val submitters      = tickets.map(_.submitter).flatten
+                val project         = generatedProject.copy(owner = owner)
+                val dbConfig        = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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(filter.some)(projectId).compile.toList
+                            } yield (writtenTickets.sum, foundTickets)
+                    }
+                } yield result
+                test.map { result =>
+                    val (writtenTickets, foundTickets) = result
+                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
+                    assertEquals(
+                        foundTickets
+                            .sortBy(_.number)
+                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
+                        expectedTickets.sortBy(_.number)
+                    )
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allTickets must respect given filters for status".tag(NeedsDatabase)) {
+        (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 statusFlags     = tickets.map(_.status).distinct.take(2)
+                val expectedTickets = tickets.filter(t => statusFlags.exists(_ === t.status))
+                val filter          = TicketFilter(Nil, status = statusFlags, Nil, Nil)
+                val submitters      = tickets.map(_.submitter).flatten
+                val project         = generatedProject.copy(owner = owner)
+                val dbConfig        = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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(filter.some)(projectId).compile.toList
+                            } yield (writtenTickets.sum, foundTickets)
+                    }
+                } yield result
+                test.map { result =>
+                    val (writtenTickets, foundTickets) = result
+                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
+                    assertEquals(
+                        foundTickets
+                            .sortBy(_.number)
+                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
+                        expectedTickets.sortBy(_.number)
+                    )
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allTickets must respect given filters for resolution".tag(NeedsDatabase)) {
+        (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 resolutions     = tickets.map(_.resolution).flatten.distinct.take(2)
+                val expectedTickets = tickets.filter(t => t.resolution.exists(r => resolutions.exists(_ === r)))
+                val filter          = TicketFilter(Nil, Nil, resolution = resolutions, Nil)
+                val submitters      = tickets.map(_.submitter).flatten
+                val project         = generatedProject.copy(owner = owner)
+                val dbConfig        = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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(filter.some)(projectId).compile.toList
+                            } yield (writtenTickets.sum, foundTickets)
+                    }
+                } yield result
+                test.map { result =>
+                    val (writtenTickets, foundTickets) = result
+                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
+                    assertEquals(
+                        foundTickets
+                            .sortBy(_.number)
+                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
+                        expectedTickets.sortBy(_.number)
+                    )
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("allTickets must respect given filters for submitter".tag(NeedsDatabase)) {
+        (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 wantedSubmitters = submitters.take(submitters.size / 2)
+                val expectedTickets  = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s)))
+                val filter           = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters.map(_.name))
+                val project          = generatedProject.copy(owner = owner)
+                val dbConfig         = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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(filter.some)(projectId).compile.toList
+                            } yield (writtenTickets.sum, foundTickets)
+                    }
+                } yield result
+                test.map { result =>
+                    val (writtenTickets, foundTickets) = result
+                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
+                    assertEquals(
+                        foundTickets
+                            .sortBy(_.number)
+                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
+                        expectedTickets.sortBy(_.number)
+                    )
+                }
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createTicket must save the ticket to the database".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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".tag(NeedsDatabase)) {
+        (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](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                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/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,107 @@
+/*
+ * 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 cats.effect.*
+import de.smederee.TestTags.*
+import de.smederee.tickets.Generators.*
+import doobie.*
+
+final class DoobieTicketServiceApiTest extends BaseSpec {
+    test("createOrUpdateUser must create new users".tag(NeedsDatabase)) {
+        genTicketsUser.sample match {
+            case Some(user) =>
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val api = new DoobieTicketServiceApi[IO](tx)
+                val test = for {
+                    written   <- api.createOrUpdateUser(user)
+                    foundUser <- loadTicketsUser(user.uid)
+                } yield (written, foundUser)
+                test.map { result =>
+                    val (written, foundUser) = result
+                    assert(written > 0, "No rows written to database!")
+                    assertEquals(foundUser, Some(user))
+                }
+
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("createOrUpdateUser must update existing users".tag(NeedsDatabase)) {
+        (genTicketsUser.sample, genTicketsUser.sample) match {
+            case (Some(user), Some(anotherUser)) =>
+                val updatedUser = anotherUser.copy(uid = user.uid)
+                val dbConfig    = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val api = new DoobieTicketServiceApi[IO](tx)
+                val test = for {
+                    created   <- api.createOrUpdateUser(user)
+                    updated   <- api.createOrUpdateUser(updatedUser)
+                    foundUser <- loadTicketsUser(user.uid)
+                } yield (created, updated, foundUser)
+                test.map { result =>
+                    val (created, updated, foundUser) = result
+                    assert(created > 0, "No rows written to database!")
+                    assert(updated > 0, "No rows updated in database!")
+                    assertEquals(foundUser, Some(updatedUser))
+                }
+
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+
+    test("deleteUser must delete existing users".tag(NeedsDatabase)) {
+        genTicketsUser.sample match {
+            case Some(user) =>
+                val dbConfig = configuration.database
+                val tx = Transactor.fromDriverManager[IO](
+                    driver = dbConfig.driver,
+                    url = dbConfig.url,
+                    user = dbConfig.user,
+                    password = dbConfig.pass,
+                    logHandler = None
+                )
+                val api = new DoobieTicketServiceApi[IO](tx)
+                val test = for {
+                    _         <- api.createOrUpdateUser(user)
+                    deleted   <- api.deleteUser(user.uid)
+                    foundUser <- loadTicketsUser(user.uid)
+                } yield (deleted, foundUser)
+                test.map { result =>
+                    val (deleted, foundUser) = result
+                    assert(deleted > 0, "No rows deleted from database!")
+                    assert(foundUser.isEmpty, "User not deleted from database!")
+                }
+
+            case _ => fail("Could not generate data samples!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,290 @@
+/*
+ * 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.*
+import java.util.Locale
+import java.util.UUID
+
+import cats.*
+import cats.syntax.all.*
+import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.*
+
+import org.scalacheck.Arbitrary
+import org.scalacheck.Gen
+
+import scala.jdk.CollectionConverters.*
+
+object Generators {
+    val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
+    val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
+
+    /** Prepend a zero to a single character hexadecimal code.
+      *
+      * @param hexCode
+      *   A string supposed to contain a hexadecimal code between 0 and ff.
+      * @return
+      *   Either the given code prepended with a leading zero if it had only a single character or the originally given
+      *   code otherwise.
+      */
+    private def hexPadding(hexCode: String): String =
+        if (hexCode.length < 2)
+            "0" + hexCode
+        else
+            hexCode
+
+    val genLocalDate: Gen[LocalDate] =
+        for {
+            year  <- Gen.choose(MinimumYear, MaximumYear)
+            month <- Gen.choose(1, 12)
+            day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+        } yield LocalDate.of(year, month, day)
+
+    given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
+
+    val genOffsetDateTime: Gen[OffsetDateTime] =
+        for {
+            year       <- Gen.choose(MinimumYear, MaximumYear)
+            month      <- Gen.choose(1, 12)
+            day        <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+            hour       <- Gen.choose(0, 23)
+            minute     <- Gen.choose(0, 59)
+            second     <- Gen.choose(0, 59)
+            nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading.
+            offset <- Gen.oneOf(
+                ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset)
+            )
+        } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset)
+
+    given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime)
+
+    val genLocale: Gen[Locale]             = Gen.oneOf(Locale.getAvailableLocales.toList)
+    val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply)
+
+    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 genUsername: Gen[Username] = for {
+        length <- Gen.choose(2, 30)
+        prefix <- Gen.alphaChar
+        chars <- Gen
+            .nonEmptyListOf(Gen.alphaNumChar)
+            .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
+    } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+
+    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 genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from)
+
+    val genTicketStatus: Gen[TicketStatus]           = Gen.oneOf(TicketStatus.values.toList)
+    val genTicketStatusList: Gen[List[TicketStatus]] = Gen.nonEmptyListOf(genTicketStatus).map(_.distinct)
+
+    val genTicketResolution: Gen[TicketResolution]        = Gen.oneOf(TicketResolution.values.toList)
+    val genTicketResolutions: Gen[List[TicketResolution]] = Gen.nonEmptyListOf(genTicketResolution).map(_.distinct)
+
+    val genTicketNumber: Gen[TicketNumber]        = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply)
+    val genTicketNumbers: Gen[List[TicketNumber]] = Gen.nonEmptyListOf(genTicketNumber).map(_.distinct)
+
+    val genTicketTitle: Gen[TicketTitle] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply)
+
+    val genTicketsUser: Gen[TicketsUser] = for {
+        uid      <- genUserId
+        name     <- genUsername
+        email    <- genEmailAddress
+        language <- Gen.option(genLanguageCode)
+    } yield TicketsUser(uid, name, email, language)
+
+    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 genTicketFilter: Gen[TicketFilter] =
+        for {
+            number     <- Gen.listOf(genTicketNumber)
+            status     <- Gen.listOf(genTicketStatus)
+            resolution <- Gen.listOf(genTicketResolution)
+            submitter  <- Gen.listOf(genSubmitter)
+        } yield TicketFilter(number, status, resolution, submitter.map(_.name).distinct)
+
+    val genProjectOwnerName: Gen[ProjectOwnerName] = for {
+        length <- Gen.choose(2, 30)
+        prefix <- Gen.alphaChar
+        chars <- Gen
+            .nonEmptyListOf(Gen.alphaNumChar)
+            .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
+    } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+
+    val genProjectOwner: Gen[ProjectOwner] = for {
+        id    <- genProjectOwnerId
+        name  <- genProjectOwnerName
+        email <- genEmailAddress
+    } yield ProjectOwner(uid = id, name = name, email = email)
+
+    given Arbitrary[ProjectOwner] = Arbitrary(genProjectOwner)
+
+    val genProjectOwners: Gen[List[ProjectOwner]] = Gen
+        .nonEmptyListOf(genProjectOwner)
+        .map(_.foldLeft(List.empty[ProjectOwner]) { (acc, a) =>
+            if (acc.exists(_.name === a.name))
+                acc
+            else
+                a :: acc
+        }) // Ensure distinct user names.
+
+    val genLabelName: Gen[LabelName] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
+
+    val genLabelDescription: Gen[LabelDescription] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar)
+            .map(_.take(LabelDescription.MaxLength).mkString)
+            .map(LabelDescription.apply)
+
+    val genColourCode: Gen[ColourCode] = for {
+        red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+        green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+        blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
+        hexString = s"#$red$green$blue"
+    } yield ColourCode(hexString)
+
+    val genLabel: Gen[Label] = for {
+        id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
+        name        <- genLabelName
+        description <- Gen.option(genLabelDescription)
+        colour      <- genColourCode
+    } yield Label(id, name, description, colour)
+
+    val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
+
+    val genMilestoneTitle: Gen[MilestoneTitle] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
+
+    val genMilestoneDescription: Gen[MilestoneDescription] =
+        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
+
+    val genMilestone: Gen[Milestone] =
+        for {
+            id     <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
+            title  <- genMilestoneTitle
+            due    <- Gen.option(genLocalDate)
+            descr  <- Gen.option(genMilestoneDescription)
+            closed <- Gen.oneOf(List(false, true))
+        } yield Milestone(id, title, descr, due, closed)
+
+    val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
+
+    val genProjectName: Gen[ProjectName] = Gen
+        .nonEmptyListOf(
+            Gen.oneOf(
+                List(
+                    "a",
+                    "b",
+                    "c",
+                    "d",
+                    "e",
+                    "f",
+                    "g",
+                    "h",
+                    "i",
+                    "j",
+                    "k",
+                    "l",
+                    "m",
+                    "n",
+                    "o",
+                    "p",
+                    "q",
+                    "r",
+                    "s",
+                    "t",
+                    "u",
+                    "v",
+                    "w",
+                    "x",
+                    "y",
+                    "z",
+                    "0",
+                    "1",
+                    "2",
+                    "3",
+                    "4",
+                    "5",
+                    "6",
+                    "7",
+                    "8",
+                    "9",
+                    "-",
+                    "_"
+                )
+            )
+        )
+        .map(cs => ProjectName(cs.take(64).mkString))
+
+    val genProjectDescription: Gen[Option[ProjectDescription]] =
+        Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from)
+
+    val genProject: Gen[Project] =
+        for {
+            name        <- genProjectName
+            description <- genProjectDescription
+            owner       <- genProjectOwner
+            isPrivate   <- Gen.oneOf(List(false, true))
+        } yield Project(owner, name, description, isPrivate)
+
+    val genProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genProject)
+
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,47 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class LabelDescriptionTest extends ScalaCheckSuite {
+    given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription)
+
+    test("LabelDescription.from must fail on empty input") {
+        assertEquals(LabelDescription.from(""), None)
+    }
+
+    property("LabelDescription.from must fail on too long input") {
+        forAll { (input: String) =>
+            if (input.length > LabelDescription.MaxLength)
+                assertEquals(LabelDescription.from(input), None)
+        }
+    }
+
+    property("LabelDescription.from must succeed on valid input") {
+        forAll { (label: LabelDescription) =>
+            val input = label.toString
+            assertEquals(LabelDescription.from(input), Option(label))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,47 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class LabelNameTest extends ScalaCheckSuite {
+    given Arbitrary[LabelName] = Arbitrary(genLabelName)
+
+    test("LabelName.from must fail on empty input") {
+        assertEquals(LabelName.from(""), None)
+    }
+
+    property("LabelName.from must fail on too long input") {
+        forAll { (input: String) =>
+            if (input.length > LabelName.MaxLength)
+                assertEquals(LabelName.from(input), None)
+        }
+    }
+
+    property("LabelName.from must succeed on valid input") {
+        forAll { (label: LabelName) =>
+            val input = label.toString
+            assertEquals(LabelName.from(input), Option(label))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,36 @@
+/*
+ * 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 cats.syntax.all.*
+import de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class LabelTest extends ScalaCheckSuite {
+    given Arbitrary[Label] = Arbitrary(genLabel)
+
+    property("Eq must hold") {
+        forAll { (label: Label) =>
+            assert(label === label, "Identical labels must be considered equal!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,40 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class MilestoneDescriptionTest extends ScalaCheckSuite {
+    given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription)
+
+    test("MilestoneDescription.from must fail on empty input") {
+        assertEquals(MilestoneDescription.from(""), None)
+    }
+
+    property("MilestoneDescription.from must succeed on valid input") {
+        forAll { (label: MilestoneDescription) =>
+            val input = label.toString
+            assertEquals(MilestoneDescription.from(input), Option(label))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,36 @@
+/*
+ * 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 cats.syntax.all.*
+import de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class MilestoneTest extends ScalaCheckSuite {
+    given Arbitrary[Milestone] = Arbitrary(genMilestone)
+
+    property("Eq must hold") {
+        forAll { (label: Milestone) =>
+            assert(label === label, "Identical milestones must be considered equal!")
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,47 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class MilestoneTitleTest extends ScalaCheckSuite {
+    given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle)
+
+    test("MilestoneTitle.from must fail on empty input") {
+        assertEquals(MilestoneTitle.from(""), None)
+    }
+
+    property("MilestoneTitle.from must fail on too long input") {
+        forAll { (input: String) =>
+            if (input.length > MilestoneTitle.MaxLength)
+                assertEquals(MilestoneTitle.from(input), None)
+        }
+    }
+
+    property("MilestoneTitle.from must succeed on valid input") {
+        forAll { (label: MilestoneTitle) =>
+            val input = label.toString
+            assertEquals(MilestoneTitle.from(input), Option(label))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala	2024-05-18 22:05:18.513443528 +0000
@@ -0,0 +1,36 @@
+/*
+ * 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 munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketContentTest extends ScalaCheckSuite {
+
+    property("TicketContent.from must only accept valid input") {
+        forAll { (input: String) =>
+            if (input.nonEmpty)
+                assertEquals(TicketContent.from(input), Some(TicketContent(input)))
+            else
+                assertEquals(TicketContent.from(input), None)
+        }
+    }
+
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketFilterTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,136 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketFilterTest extends ScalaCheckSuite {
+    given Arbitrary[Submitter]        = Arbitrary(genSubmitter)
+    given Arbitrary[TicketFilter]     = Arbitrary(genTicketFilter)
+    given Arbitrary[TicketNumber]     = Arbitrary(genTicketNumber)
+    given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution)
+    given Arbitrary[TicketStatus]     = Arbitrary(genTicketStatus)
+
+    property("fromQueryParameter must produce empty filters for invalid input") {
+        forAll { (randomInput: String) =>
+            assertEquals(
+                TicketFilter.fromQueryParameter(randomInput),
+                TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = Nil)
+            )
+        }
+    }
+
+    property("fromQueryParameter must work for numbers only") {
+        forAll { (numbers: List[TicketNumber]) =>
+            val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil)
+            assertEquals(TicketFilter.fromQueryParameter(s"numbers: ${numbers.map(_.toString).mkString(",")}"), filter)
+        }
+    }
+
+    property("fromQueryParameter must work for status only") {
+        forAll { (status: List[TicketStatus]) =>
+            val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil)
+            assertEquals(TicketFilter.fromQueryParameter(s"status: ${status.map(_.toString).mkString(",")}"), filter)
+        }
+    }
+
+    property("fromQueryParameter must work for resolution only") {
+        forAll { (resolution: List[TicketResolution]) =>
+            val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil)
+            assertEquals(
+                TicketFilter.fromQueryParameter(s"resolution: ${resolution.map(_.toString).mkString(",")}"),
+                filter
+            )
+        }
+    }
+
+    property("fromQueryParameter must work for submitter only") {
+        forAll { (submitters: List[Submitter]) =>
+            if (submitters.nonEmpty) {
+                val filter =
+                    TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name))
+                assertEquals(
+                    TicketFilter.fromQueryParameter(s"by: ${submitters.map(_.name.toString).mkString(",")}"),
+                    filter
+                )
+            }
+        }
+    }
+
+    property("toQueryParameter must include numbers") {
+        forAll { (numbers: List[TicketNumber]) =>
+            if (numbers.nonEmpty) {
+                val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil)
+                assert(
+                    TicketFilter.toQueryParameter(filter).contains(s"numbers: ${numbers.map(_.toString).mkString(",")}")
+                )
+            }
+        }
+    }
+
+    property("toQueryParameter must include status") {
+        forAll { (status: List[TicketStatus]) =>
+            if (status.nonEmpty) {
+                val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil)
+                assert(
+                    TicketFilter.toQueryParameter(filter).contains(s"status: ${status.map(_.toString).mkString(",")}")
+                )
+            }
+        }
+    }
+
+    property("toQueryParameter must include resolution") {
+        forAll { (resolution: List[TicketResolution]) =>
+            if (resolution.nonEmpty) {
+                val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil)
+                assert(
+                    TicketFilter
+                        .toQueryParameter(filter)
+                        .contains(s"resolution: ${resolution.map(_.toString).mkString(",")}"),
+                    TicketFilter.toQueryParameter(filter)
+                )
+            }
+        }
+    }
+
+    property("toQueryParameter must include submitter") {
+        forAll { (submitters: List[Submitter]) =>
+            if (submitters.nonEmpty) {
+                val filter =
+                    TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name))
+                assert(
+                    TicketFilter
+                        .toQueryParameter(filter)
+                        .contains(s"by: ${submitters.map(_.name.toString).mkString(",")}"),
+                    TicketFilter.toQueryParameter(filter)
+                )
+            }
+        }
+    }
+
+    property("toQueryParameter must be the dual of fromQueryParameter") {
+        forAll { (filter: TicketFilter) =>
+            assertEquals(TicketFilter.fromQueryParameter(filter.toQueryParameter), filter)
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,36 @@
+/*
+ * 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 munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketNumberTest extends ScalaCheckSuite {
+
+    property("TicketNumber.from must only accept valid input") {
+        forAll { (integer: Int) =>
+            if (integer > 0)
+                assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer)))
+            else
+                assertEquals(TicketNumber.from(integer), None)
+        }
+    }
+
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,41 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketResolutionTest extends ScalaCheckSuite {
+    given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution)
+
+    property("valueOf must work for all known instances") {
+        forAll { (status: TicketResolution) =>
+            assertEquals(TicketResolution.valueOf(status.toString), status)
+        }
+    }
+
+    property("fromString must work for all known instances") {
+        forAll { (status: TicketResolution) =>
+            assertEquals(TicketResolution.fromString(status.toString), Option(status))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketStatusTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,41 @@
+/*
+ * 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 de.smederee.tickets.Generators.*
+
+import munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketStatusTest extends ScalaCheckSuite {
+    given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus)
+
+    property("valueOf must work for all known instances") {
+        forAll { (status: TicketStatus) =>
+            assertEquals(TicketStatus.valueOf(status.toString), status)
+        }
+    }
+
+    property("fromString must work for all known instances") {
+        forAll { (status: TicketStatus) =>
+            assertEquals(TicketStatus.fromString(status.toString), Option(status))
+        }
+    }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	2024-05-18 22:05:18.517443486 +0000
@@ -0,0 +1,36 @@
+/*
+ * 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 munit.*
+
+import org.scalacheck.*
+import org.scalacheck.Prop.*
+
+final class TicketTitleTest extends ScalaCheckSuite {
+
+    property("TicketTitle.from must only accept valid input") {
+        forAll { (input: String) =>
+            if (input.nonEmpty && input.length <= TicketTitle.MaxLength)
+                assertEquals(TicketTitle.from(input), Some(TicketTitle(input)))
+            else
+                assertEquals(TicketTitle.from(input), None)
+        }
+    }
+
+}
diff -rN -u old-smederee/modules/hub/src/universal/conf/application.conf.sample new-smederee/modules/hub/src/universal/conf/application.conf.sample
--- old-smederee/modules/hub/src/universal/conf/application.conf.sample	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/hub/src/universal/conf/application.conf.sample	2024-05-18 22:05:18.517443486 +0000
@@ -1,5 +1,5 @@
 ###############################################################################
-### Example configuration for running hub and ticket service on localhost.  ###
+## Example configuration for running the Smederee hub service on localhost.  ##
 ###############################################################################
 
 hub {
@@ -171,57 +171,6 @@
 }
 
 tickets {
-  # Authentication / login settings
-  authentication {
-    enabled = true
-
-    # The name used for the authentication cookie.
-    cookie-name = "sloetel"
-
-    # The secret used for the cookie encryption and validation.
-    # Using the default should produce a warning message on startup.
-    cookie-secret = "CHANGEME"
-
-    # Determines after how many failed login attempts an account gets locked.
-    lock-after = 10
-
-    # Timeouts for the authentication session.
-    timeouts {
-      # The maximum allowed age an authentication session. This setting will
-      # affect the invalidation of a session on the server side.
-      # This timeout MUST be triggered regardless of session activity.
-      absolute-timeout = 3 days
-
-      # This timeout defines how long after the last activity a session will
-      # remain valid.
-      idle-timeout = 30 minutes
-
-      # The time after which a session will be renewed (a new session ID will be
-      # generated).
-      renewal-timeout = 20 minutes
-    }
-  }
-
-  # Configuration of the CSRF protection middleware.
-  csrf-protection {
-    # The official hostname of the service which will be used for the CSRF
-    # protection.
-    host = ${tickets.service.host}
-
-    # The port number which defaults to the port the service is listening on.
-    # If the service is running behind a reverse proxy on a standard port e.g.
-    # 80 or 443 (http or https) then you MUST set this either to `port = null`
-    # or comment it out!
-    port = ${tickets.service.port}
-
-    # The URL scheme which is used for links and will also determine if cookies
-    # will have the secure flag enabled.
-    # Valid options are:
-    # - http
-    # - https
-    scheme = "http"
-  }
-
   # Configuration of the database.
   # Defaults are given except for password and can also be overridden via
   # environment variables.
@@ -242,29 +191,7 @@
 
   # Settings affecting how the service will communicate several information to
   # the "outside world" e.g. if it runs behind a reverse proxy.
-  external-url {
-    # The official hostname of the service which will be used for the generation
-    # of links.
-    host = ${tickets.service.host}
-
-    # A possible path prefix that will be prepended to any paths used in link
-    # generation. If no path prefix is used then you MUST either comment it out
-    # or set it to `path = null`!
-    #path = null
-    
-    # The port number which defaults to the port the service is listening on.
-    # If the service is running behind a reverse proxy on a standard port e.g.
-    # 80 or 443 (http or https) then you MUST set this either to `port = null`
-    # or comment it out!
-    port = ${tickets.service.port}
-
-    # The URL scheme which is used for links and will also determine if cookies
-    # will have the secure flag enabled.
-    # Valid options are:
-    # - http
-    # - https
-    scheme = "http"
-  }
+  external = ${hub.service.external}
 
   # Configuration regarding the integration with the hub service.
   hub-integration {
@@ -272,12 +199,4 @@
     base-uri = "http://localhost:8080"
     base-uri = ${?SMEDEREE_HUB_BASE_URI}
   }
-
-  # Generic service configuration.
-  service {
-    # The hostname on which the service shall listen for requests.
-    host = "localhost"
-    # The TCP port number on which the service shall listen for requests.
-    port = 8080
-  }
 }
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,3 +0,0 @@
-CREATE SCHEMA IF NOT EXISTS tickets;
-
-COMMENT ON SCHEMA tickets IS 'Data related to ticket tracking.';
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,200 +0,0 @@
-CREATE TABLE tickets.users
-(
-  uid              UUID                     NOT NULL,
-  name             CHARACTER VARYING(32)    NOT NULL,
-  email            CHARACTER VARYING(128)   NOT NULL,
-  created_at       TIMESTAMP WITH TIME ZONE NOT NULL,
-  updated_at       TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT users_pk           PRIMARY KEY (uid),
-  CONSTRAINT users_unique_name  UNIQUE (name),
-  CONSTRAINT users_unique_email UNIQUE (email)
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.users IS 'All users for the ticket system live within this table.';
-COMMENT ON COLUMN tickets.users.uid IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.';
-COMMENT ON COLUMN tickets.users.name IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
-COMMENT ON COLUMN tickets.users.email IS 'A globally unique email address associated with the account.';
-COMMENT ON COLUMN tickets.users.created_at IS 'The timestamp of when the account was created.';
-COMMENT ON COLUMN tickets.users.updated_at IS 'A timestamp when the account was last changed.';
-
-CREATE TABLE tickets.sessions
-(
-  id         VARCHAR(32)              NOT NULL,
-  uid        UUID                     NOT NULL,
-  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
-  updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT sessions_pk     PRIMARY KEY (id),
-  CONSTRAINT sessions_fk_uid FOREIGN KEY (uid)
-    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.sessions IS 'Keeps the sessions of users.';
-COMMENT ON COLUMN tickets.sessions.id IS 'A globally unique session ID.';
-COMMENT ON COLUMN tickets.sessions.uid IS 'The unique ID of the user account to whom the session belongs.';
-COMMENT ON COLUMN tickets.sessions.created_at IS 'The timestamp of when the session was created.';
-COMMENT ON COLUMN tickets.sessions.updated_at IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
-
-CREATE TABLE tickets.projects
-(
-  id                 BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  name               CHARACTER VARYING(64)    NOT NULL,
-  owner              UUID                     NOT NULL,
-  is_private         BOOLEAN                  NOT NULL DEFAULT FALSE,
-  description        CHARACTER VARYING(254),
-  created_at         TIMESTAMP WITH TIME ZONE NOT NULL,
-  updated_at         TIMESTAMP WITH TIME ZONE NOT NULL,
-  next_ticket_number INTEGER                  NOT NULL DEFAULT 1,
-  CONSTRAINT projects_unique_owner_name UNIQUE (owner, name),
-  CONSTRAINT projects_fk_uid FOREIGN KEY (owner)
-    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.projects IS 'All projects which are basically mirrored repositories from the hub are stored within this table.';
-COMMENT ON COLUMN tickets.projects.id IS 'An auto generated primary key.';
-COMMENT ON COLUMN tickets.projects.name IS '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.';
-COMMENT ON COLUMN tickets.projects.owner IS 'The unique ID of the user account that owns the project.';
-COMMENT ON COLUMN tickets.projects.is_private IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.';
-COMMENT ON COLUMN tickets.projects.description IS 'An optional short text description of the project.';
-COMMENT ON COLUMN tickets.projects.created_at IS 'The timestamp of when the project was created.';
-COMMENT ON COLUMN tickets.projects.updated_at IS 'A timestamp when the project was last changed.';
-COMMENT ON COLUMN tickets.projects.next_ticket_number IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.';
-
-CREATE TABLE tickets.labels
-(
-  id          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  project     BIGINT                 NOT NULL,
-  name        CHARACTER VARYING(40)  NOT NULL,
-  description CHARACTER VARYING(254) DEFAULT NULL,
-  colour      CHARACTER VARYING(7)   NOT NULL,
-  CONSTRAINT labels_unique_project_label UNIQUE (project, name),
-  CONSTRAINT labels_fk_project FOREIGN KEY (project)
-    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.labels IS 'Labels used to add information to tickets.';
-COMMENT ON COLUMN tickets.labels.id IS 'An auto generated primary key.';
-COMMENT ON COLUMN tickets.labels.project IS 'The project to which this label belongs.'; 
-COMMENT ON COLUMN tickets.labels.name IS 'A short descriptive name for the label which is supposed to be unique in a project context.';
-COMMENT ON COLUMN tickets.labels.description IS 'An optional description if needed.';
-COMMENT ON COLUMN tickets.labels.colour IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.';
-
-CREATE TABLE tickets.milestones
-(
-  id          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  project     BIGINT                 NOT NULL,
-  title       CHARACTER VARYING(64)  NOT NULL,
-  due_date    DATE                   DEFAULT NULL,
-  description TEXT                   DEFAULT NULL,
-  CONSTRAINT milestones_unique_project_title UNIQUE (project, title),
-  CONSTRAINT milestones_fk_project FOREIGN KEY (project)
-    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.milestones IS 'Milestones used to organise tickets';
-COMMENT ON COLUMN tickets.milestones.project IS 'The project to which this milestone belongs.';
-COMMENT ON COLUMN tickets.milestones.title IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.';
-COMMENT ON COLUMN tickets.milestones.due_date IS 'An optional date on which the milestone is supposed to be reached.';
-COMMENT ON COLUMN tickets.milestones.description IS 'An optional longer description of the milestone.';
-
-CREATE TABLE tickets.tickets
-(
-  id         BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  project    BIGINT                   NOT NULL,
-  number     INT                      NOT NULL,
-  title      CHARACTER VARYING(72)    NOT NULL,
-  content    TEXT                     DEFAULT NULL,
-  status     CHARACTER VARYING(16)    NOT NULL,
-  submitter  UUID                     DEFAULT NULL,
-  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
-  updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT tickets_unique_project_ticket UNIQUE (project, number),
-  CONSTRAINT tickets_fk_project FOREIGN KEY (project)
-    REFERENCES tickets.projects (id) ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT tickets_fk_submitter FOREIGN KEY (submitter)
-    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE SET NULL
-)
-WITH (
-  OIDS=FALSE
-);
-
-CREATE INDEX tickets_status ON tickets.tickets (status);
-
-COMMENT ON TABLE tickets.tickets IS 'Information about tickets for projects.';
-COMMENT ON COLUMN tickets.tickets.id IS 'An auto generated primary key.';
-COMMENT ON COLUMN tickets.tickets.project IS 'The unique ID of the project which is associated with the ticket.';
-COMMENT ON COLUMN tickets.tickets.number IS 'The number of the ticket which must be unique within the scope of the project.';
-COMMENT ON COLUMN tickets.tickets.title IS 'A concise and short description of the ticket which should not exceed 72 characters.';
-COMMENT ON COLUMN tickets.tickets.content IS 'An optional field to describe the ticket in great detail if needed.';
-COMMENT ON COLUMN tickets.tickets.status IS 'The current status of the ticket describing its life cycle.';
-COMMENT ON COLUMN tickets.tickets.submitter IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.';
-COMMENT ON COLUMN tickets.tickets.created_at IS 'The timestamp when the ticket was created / submitted.';
-COMMENT ON COLUMN tickets.tickets.updated_at IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.';
-
-CREATE TABLE tickets.milestone_tickets
-(
-  milestone BIGINT NOT NULL,
-  ticket    BIGINT NOT NULL,
-  CONSTRAINT milestone_tickets_pk PRIMARY KEY (milestone, ticket),
-  CONSTRAINT milestone_tickets_fk_milestone FOREIGN KEY (milestone)
-    REFERENCES tickets.milestones (id) ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT milestone_tickets_fk_ticket FOREIGN KEY (ticket)
-    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.milestone_tickets IS 'This table stores the relation between milestones and their tickets.';
-COMMENT ON COLUMN tickets.milestone_tickets.milestone IS 'The unique ID of the milestone.';
-COMMENT ON COLUMN tickets.milestone_tickets.ticket IS 'The unique ID of the ticket that is attached to the milestone.';
-
-CREATE TABLE tickets.ticket_assignees
-(
-  ticket     BIGINT NOT NULL,
-  assignee   UUID NOT NULL,
-  CONSTRAINT ticket_assignees_pk PRIMARY KEY (ticket, assignee),
-  CONSTRAINT ticket_assignees_fk_ticket FOREIGN KEY (ticket)
-    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT ticket_assignees_fk_assignee FOREIGN KEY (assignee)
-    REFERENCES tickets.users (uid) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.ticket_assignees IS 'This table stores the relation between tickets and their assignees.';
-COMMENT ON COLUMN tickets.ticket_assignees.ticket IS 'The unqiue ID of the ticket.';
-COMMENT ON COLUMN tickets.ticket_assignees.assignee IS 'The unique ID of the user account that is assigned to the ticket.';
-
-CREATE TABLE tickets.ticket_labels
-(
-  ticket BIGINT NOT NULL,
-  label  BIGINT NOT NULL,
-  CONSTRAINT ticket_labels_pk PRIMARY KEY (ticket, label),
-  CONSTRAINT ticket_labels_fk_ticket FOREIGN KEY (ticket)
-    REFERENCES tickets.tickets (id) ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT ticket_labels_fk_label FOREIGN KEY (label)
-    REFERENCES tickets.labels (id) ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE tickets.ticket_labels IS 'This table stores the relation between tickets and their labels.';
-COMMENT ON COLUMN tickets.ticket_labels.ticket IS 'The unqiue ID of the ticket.';
-COMMENT ON COLUMN tickets.ticket_labels.label IS 'The unique ID of the label that is attached to the ticket.';
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,4 +0,0 @@
-ALTER TABLE tickets.users
-  ADD COLUMN language CHARACTER VARYING(3) DEFAULT NULL;
-
-COMMENT ON COLUMN tickets.users.language IS 'The ISO-639 language code of the preferred language of the user.';
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V4__add_resolution.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,4 +0,0 @@
-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/resources/db/migration/tickets/V5__add_closeable_milestones.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,4 +0,0 @@
-ALTER TABLE tickets.milestones
-  ADD COLUMN closed BOOLEAN DEFAULT FALSE;
-
-COMMENT ON COLUMN tickets.milestones.closed IS 'This flag indicates if the milestone is closed e.g. considered done or obsolete.';
diff -rN -u old-smederee/modules/tickets/src/main/resources/logback.xml new-smederee/modules/tickets/src/main/resources/logback.xml
--- old-smederee/modules/tickets/src/main/resources/logback.xml	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/logback.xml	1970-01-01 00:00:00.000000000 +0000
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<configuration debug="false">
-  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
-    <encoder>
-      <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern>
-    </encoder>
-  </appender>
-
-  <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender">
-    <appender-ref ref="console"/>
-    <queueSize>5000</queueSize>
-    <discardingThreshold>0</discardingThreshold>
-  </appender>
-
-  <!-- Suppress logback status output! -->
-  <statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
-
-  <logger name="de.smederee.tickets" level="${smederee.tickets.loglevel:-INFO}" additivity="false">
-    <appender-ref ref="async-console"/>
-  </logger>
-
-  <root level="INFO">
-    <appender-ref ref="async-console"/>
-  </root>
-</configuration>
diff -rN -u old-smederee/modules/tickets/src/main/resources/reference.conf new-smederee/modules/tickets/src/main/resources/reference.conf
--- old-smederee/modules/tickets/src/main/resources/reference.conf	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/resources/reference.conf	1970-01-01 00:00:00.000000000 +0000
@@ -1,115 +0,0 @@
-###############################################################################
-###     Reference configuration file for the Smederee tickets service.      ###
-###############################################################################
-
-tickets {
-  # Authentication / login settings
-  authentication {
-    enabled = true
-
-    # The name used for the authentication cookie.
-    cookie-name = "sloetel"
-
-    # The secret used for the cookie encryption and validation.
-    # Using the default should produce a warning message on startup.
-    cookie-secret = "CHANGEME"
-
-    # Determines after how many failed login attempts an account gets locked.
-    lock-after = 10
-
-    # Timeouts for the authentication session.
-    timeouts {
-      # The maximum allowed age an authentication session. This setting will
-      # affect the invalidation of a session on the server side.
-      # This timeout MUST be triggered regardless of session activity.
-      absolute-timeout = 3 days
-
-      # This timeout defines how long after the last activity a session will
-      # remain valid.
-      idle-timeout = 30 minutes
-
-      # The time after which a session will be renewed (a new session ID will be
-      # generated).
-      renewal-timeout = 20 minutes
-    }
-  }
-
-  # Configuration of the CSRF protection middleware.
-  csrf-protection {
-    # The official hostname of the service which will be used for the CSRF
-    # protection.
-    host = ${tickets.service.host}
-
-    # The port number which defaults to the port the service is listening on.
-    # If the service is running behind a reverse proxy on a standard port e.g.
-    # 80 or 443 (http or https) then you MUST set this either to `port = null`
-    # or comment it out!
-    port = ${tickets.service.port}
-
-    # The URL scheme which is used for links and will also determine if cookies
-    # will have the secure flag enabled.
-    # Valid options are:
-    # - http
-    # - https
-    scheme = "http"
-  }
-
-  # Configuration of the database.
-  # Defaults are given except for password and can also be overridden via
-  # environment variables.
-  database {
-    # The class name of the JDBC driver to be used.
-    driver = "org.postgresql.Driver"
-    driver = ${?SMEDEREE_TICKETS_DB_DRIVER}
-    # The JDBC connection URL **without** username and password.
-    url = "jdbc:postgresql://localhost:5432/smederee"
-    url = ${?SMEDEREE_TICKETS_DB_URL}
-    # The username (login) needed to authenticate against the database.
-    user = "smederee_tickets"
-    user = ${?SMEDEREE_TICKETS_DB_USER}
-    # The password needed to authenticate against the database.
-    pass = "secret"
-    pass = ${?SMEDEREE_TICKETS_DB_PASS}
-  }
-
-  # Settings affecting how the service will communicate several information to
-  # the "outside world" e.g. if it runs behind a reverse proxy.
-  external-url {
-    # The official hostname of the service which will be used for the generation
-    # of links.
-    host = ${tickets.service.host}
-
-    # A possible path prefix that will be prepended to any paths used in link
-    # generation. If no path prefix is used then you MUST either comment it out
-    # or set it to `path = null`!
-    #path = null
-    
-    # The port number which defaults to the port the service is listening on.
-    # If the service is running behind a reverse proxy on a standard port e.g.
-    # 80 or 443 (http or https) then you MUST set this either to `port = null`
-    # or comment it out!
-    port = ${tickets.service.port}
-
-    # The URL scheme which is used for links and will also determine if cookies
-    # will have the secure flag enabled.
-    # Valid options are:
-    # - http
-    # - https
-    scheme = "http"
-  }
-
-  # Configuration regarding the integration with the hub service.
-  hub-integration {
-    # The base URI used to build links to the hub service.
-    base-uri = "http://localhost:8080"
-    base-uri = ${?SMEDEREE_HUB_BASE_URI}
-  }
-
-  # Generic service configuration.
-  service {
-    # The hostname on which the service shall listen for requests.
-    host = "localhost"
-    # The TCP port number on which the service shall listen for requests.
-    port = 8081
-  }
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,165 +0,0 @@
-/*
- * 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.*
-import cats.data.*
-import cats.syntax.all.*
-
-/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
-  */
-opaque type AssigneeId = UUID
-object AssigneeId {
-    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
-
-    given Eq[AssigneeId] = Eq.fromUniversalEquals
-
-    /** Create an instance of AssigneeId from the given UUID type.
-      *
-      * @param source
-      *   An instance of type UUID which will be returned as a AssigneeId.
-      * @return
-      *   The appropriate instance of AssigneeId.
-      */
-    def apply(source: UUID): AssigneeId = source
-
-    /** Try to create an instance of AssigneeId from the given UUID.
-      *
-      * @param source
-      *   A UUID that should fulfil the requirements to be converted into a AssigneeId.
-      * @return
-      *   An option to the successfully converted AssigneeId.
-      */
-    def from(source: UUID): Option[AssigneeId] = Option(source)
-
-    /** Try to create an instance of AssigneeId from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a AssigneeId.
-      * @return
-      *   An option to the successfully converted AssigneeId.
-      */
-    def fromString(source: String): Either[String, AssigneeId] =
-        Option(source)
-            .filter(s => Format.matches(s))
-            .flatMap { uuidString =>
-                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
-            }
-            .toRight("Illegal value for AssigneeId!")
-
-    /** Generate a new random user id.
-      *
-      * @return
-      *   A user id which is pseudo randomly generated.
-      */
-    def randomAssigneeId: AssigneeId = UUID.randomUUID
-
-    extension (uid: AssigneeId) {
-        def toUUID: UUID = uid
-    }
-}
-
-/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
-  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
-  * lowercase.
-  */
-opaque type AssigneeName = String
-object AssigneeName {
-    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
-
-    /** Create an instance of AssigneeName from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a AssigneeName.
-      * @return
-      *   The appropriate instance of AssigneeName.
-      */
-    def apply(source: String): AssigneeName = source
-
-    /** Try to create an instance of AssigneeName from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a AssigneeName.
-      * @return
-      *   An option to the successfully converted AssigneeName.
-      */
-    def from(s: String): Option[AssigneeName] = validate(s).toOption
-
-    /** Validate the given string and return either the validated username or a list of errors.
-      *
-      * @param s
-      *   An arbitrary string which should be a username.
-      * @return
-      *   Either a list of errors or the validated username.
-      */
-    def validate(s: String): ValidatedNec[String, AssigneeName] =
-        Option(s).map(_.trim.nonEmpty) match {
-            case Some(true) =>
-                val input = s.trim
-                val miniumLength =
-                    if (input.length >= 2)
-                        input.validNec
-                    else
-                        "AssigneeName too short (min. 2 characters)!".invalidNec
-                val maximumLength =
-                    if (input.length < 32)
-                        input.validNec
-                    else
-                        "AssigneeName too long (max. 31 characters)!".invalidNec
-                val alphanumeric =
-                    if (isAlphanumeric.matches(input))
-                        input.validNec
-                    else
-                        "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec
-                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
-                    name
-                }
-            case _ => "AssigneeName must not be empty!".invalidNec
-        }
-}
-
-/** Extractor to retrieve an AssigneeName from a path parameter.
-  */
-object AssigneeNamePathParameter {
-    def unapply(str: String): Option[AssigneeName] =
-        Option(str).flatMap { string =>
-            if (string.startsWith("~"))
-                AssigneeName.from(string.drop(1))
-            else
-                None
-        }
-}
-
-/** The assignee for a ticket i.e. the person supposed to be working on it.
-  *
-  * @param id
-  *   A globally unique ID identifying the assignee.
-  * @param name
-  *   The name associated with the assignee which is supposed to be unique.
-  */
-final case class Assignee(id: AssigneeId, name: AssigneeName)
-
-object Assignee {
-    given Eq[Assignee] = Eq.fromUniversalEquals
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,45 +0,0 @@
-/*
- * 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.config
-
-/** A configuration path describes a path within a configuration file and is used to determine locations of certain
-  * configurations within a combined configuration file.
-  */
-opaque type ConfigurationPath = String
-object ConfigurationPath {
-
-    given Conversion[ConfigurationPath, String] = _.toString
-
-    /** Create an instance of ConfigurationPath from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a ConfigurationPath.
-      * @return
-      *   The appropriate instance of ConfigurationPath.
-      */
-    def apply(source: String): ConfigurationPath = source
-
-    /** Try to create an instance of ConfigurationPath from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ConfigurationPath.
-      * @return
-      *   An option to the successfully converted ConfigurationPath.
-      */
-    def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty)
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,37 +0,0 @@
-/*
- * 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.config
-
-import pureconfig.*
-
-/** Configuration specifying the database access.
-  *
-  * @param driver
-  *   The class name of the JDBC driver to be used.
-  * @param url
-  *   The JDBC connection URL **without** username and password.
-  * @param user
-  *   The username (login) needed to authenticate against the database.
-  * @param pass
-  *   The password needed to authenticate against the database.
-  */
-final case class DatabaseConfig(driver: String, url: String, user: String, pass: String)
-
-object DatabaseConfig {
-    given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply)
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,70 +0,0 @@
-/*
- * 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.config
-
-import cats.effect.*
-import cats.syntax.all.*
-import org.flywaydb.core.Flyway
-import org.flywaydb.core.api.configuration.FluentConfiguration
-import org.flywaydb.core.api.output.MigrateResult
-
-/** Provide functionality to migrate the database used by the service.
-  */
-final class DatabaseMigrator[F[_]: Sync] {
-
-    /** Apply pending migrations to the database if needed using the underlying Flyway library.
-      *
-      * @param url
-      *   The JDBC connection URL **without** username and password.
-      * @param user
-      *   The username (login) needed to authenticate against the database.
-      * @param pass
-      *   The password needed to authenticate against the database.
-      * @return
-      *   A migrate result object holding information about executed migrations and the schema. See the Java-Doc of
-      *   Flyway for details.
-      */
-    def migrate(url: String, user: String, pass: String): F[MigrateResult] =
-        for {
-            flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load())
-            result <- Sync[F].delay(flyway.migrate())
-        } yield result
-}
-
-object DatabaseMigrator {
-
-    /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The
-      * returned instance must be activated by calling the `.load()` method.
-      *
-      * @param url
-      *   The JDBC connection URL **without** username and password.
-      * @param user
-      *   The username (login) needed to authenticate against the database.
-      * @param pass
-      *   The password needed to authenticate against the database.
-      * @return
-      *   An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method.
-      */
-    def configureFlyway(url: String, user: String, pass: String): FluentConfiguration =
-        Flyway
-            .configure()
-            .defaultSchema("tickets")
-            .locations("classpath:db/migration/tickets")
-            .dataSource(url, user, pass)
-
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,113 +0,0 @@
-/*
- * 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.config
-
-import cats.*
-import com.comcast.ip4s.*
-import de.smederee.html.ExternalUrlConfiguration
-import org.http4s.Uri
-import pureconfig.*
-
-/** Configuration for a CSRF protection middleware.
-  *
-  * @param host
-  *   The hostname which will be expected and must be matched.
-  * @param port
-  *   An optional portnumber which will be expected if set.
-  * @param scheme
-  *   The URL scheme which is either HTTP or HTTPS.
-  */
-final case class CsrfProtectionConfiguration(host: Host, port: Option[Port], scheme: Uri.Scheme)
-
-object CsrfProtectionConfiguration {
-    given Eq[CsrfProtectionConfiguration] = Eq.fromUniversalEquals
-
-    given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
-    given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
-    given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
-
-    given ConfigReader[CsrfProtectionConfiguration] =
-        ConfigReader.forProduct3("host", "port", "scheme")(CsrfProtectionConfiguration.apply)
-}
-
-/** Configuration regarding the integration with the hub service.
-  *
-  * @param baseUri
-  *   The base URI used to build links to the hub service.
-  */
-final case class HubIntegrationConfiguration(baseUri: Uri)
-
-object HubIntegrationConfiguration {
-    given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
-    given ConfigReader[HubIntegrationConfiguration] =
-        ConfigReader.forProduct1("base-uri")(HubIntegrationConfiguration.apply)
-}
-
-/** Generic service configuration determining how the service will be run.
-  *
-  * @param host
-  *   The hostname on which the service shall listen for requests.
-  * @param port
-  *   The TCP port number on which the service shall listen for requests.
-  */
-final case class ServiceConfiguration(host: Host, port: Port)
-
-object ServiceConfiguration {
-    given ConfigReader[Host]                 = ConfigReader.fromStringOpt[Host](Host.fromString)
-    given ConfigReader[Port]                 = ConfigReader.fromStringOpt[Port](Port.fromString)
-    given ConfigReader[ServiceConfiguration] = ConfigReader.forProduct2("host", "port")(ServiceConfiguration.apply)
-}
-
-/** Wrapper class for the confiuration of the Smederee tickets module.
-  *
-  * @param csrfProtection
-  *   The CSRF protection configuration.
-  * @param database
-  *   The configuration needed to access the database.
-  * @param externalUrl
-  *   Configuration regarding support for generating "external urls" which is usually needed if the service runs behind
-  *   a reverse proxy.
-  * @param hub
-  *   Configuration regarding the integration with the hub service.
-  * @param service
-  *   Generic service configuration determining how the service will be run.
-  */
-final case class SmedereeTicketsConfiguration(
-    csrfProtection: CsrfProtectionConfiguration,
-    database: DatabaseConfig,
-    externalUrl: ExternalUrlConfiguration,
-    hub: HubIntegrationConfiguration,
-    service: ServiceConfiguration
-)
-
-object SmedereeTicketsConfiguration {
-    val location: ConfigurationPath = ConfigurationPath("tickets")
-
-    given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
-    given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
-    given ConfigReader[Uri]        = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
-    given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
-
-    given ConfigReader[ExternalUrlConfiguration] =
-        ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
-
-    given ConfigReader[SmedereeTicketsConfiguration] =
-        ConfigReader.forProduct5("csrf-protection", "database", "external-url", "hub-integration", "service")(
-            SmedereeTicketsConfiguration.apply
-        )
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,81 +0,0 @@
-/*
- * 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 cats.effect.*
-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[F] = Slf4jLogHandler.createLogHandler[F](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(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(projectId: ProjectId)(label: Label): F[Int] =
-        sql"""INSERT INTO tickets.labels
-          (
-            project,
-            name,
-            description,
-            colour
-          )
-          VALUES (
-            $projectId,
-            ${label.name},
-            ${label.description},
-            ${label.colour}
-          )""".update.run.transact(tx)
-
-    override def deleteLabel(label: Label): F[Int] =
-        label.id match {
-            case None => Sync[F].pure(0)
-            case Some(id) =>
-                sql"""DELETE FROM tickets.labels WHERE id = $id""".update.run.transact(tx)
-        }
-
-    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)
-
-    override def updateLabel(label: Label): F[Int] =
-        label.id match {
-            case None => Sync[F].pure(0)
-            case Some(id) =>
-                sql"""UPDATE tickets.labels 
-              SET name = ${label.name},
-              description = ${label.description},
-              colour = ${label.colour}
-              WHERE id = $id""".update.run.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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,145 +0,0 @@
-/*
- * 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 cats.syntax.all.*
-import doobie.*
-import doobie.Fragments.*
-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[F] = Slf4jLogHandler.createLogHandler[F](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"
-          LEFT OUTER JOIN "tickets"."users" AS "submitters"
-          ON "tickets".submitter = "submitters".uid"""
-
-    override def allMilestones(projectId: ProjectId): Stream[F, Milestone] =
-        sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC"""
-            .query[Milestone]
-            .stream
-            .transact(tx)
-
-    override def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket] = {
-        val milestoneFilter =
-            fr""""tickets".id IN (SELECT ticket FROM "tickets".milestone_tickets AS "milestone_tickets" WHERE milestone = $milestoneId)"""
-        val tickets = filter match {
-            case None => selectTicketColumns ++ whereAnd(milestoneFilter)
-            case Some(filter) =>
-                val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr""""tickets".number""", numbers))
-                val statusFilter = filter.status.toNel.map(status => Fragments.in(fr""""tickets".status""", status))
-                val resolutionFilter =
-                    filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions))
-                val submitterFilter =
-                    filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters))
-                selectTicketColumns ++ whereAndOpt(
-                    milestoneFilter.some,
-                    numberFilter,
-                    statusFilter,
-                    resolutionFilter,
-                    submitterFilter
-                )
-        }
-        tickets.query[Ticket].stream.transact(tx)
-    }
-
-    override def closeMilestone(milestoneId: MilestoneId): F[Int] =
-        sql"""UPDATE "tickets"."milestones" SET closed = TRUE WHERE id = $milestoneId""".update.run.transact(tx)
-
-    override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] =
-        sql"""INSERT INTO "tickets"."milestones"
-          (
-            project,
-            title,
-            due_date,
-            description,
-            closed
-          )
-          VALUES (
-            $projectId,
-            ${milestone.title},
-            ${milestone.dueDate},
-            ${milestone.description},
-            ${milestone.closed}
-          )""".update.run.transact(tx)
-
-    override def deleteMilestone(milestone: Milestone): F[Int] =
-        milestone.id match {
-            case None     => Sync[F].pure(0)
-            case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx)
-        }
-
-    override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] =
-        sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1"""
-            .query[Milestone]
-            .option
-            .transact(tx)
-
-    override def openMilestone(milestoneId: MilestoneId): F[Int] =
-        sql"""UPDATE "tickets"."milestones" SET closed = FALSE WHERE id = $milestoneId""".update.run.transact(tx)
-
-    override def updateMilestone(milestone: Milestone): F[Int] =
-        milestone.id match {
-            case None => Sync[F].pure(0)
-            case Some(id) =>
-                sql"""UPDATE "tickets"."milestones"
-              SET title = ${milestone.title},
-                due_date = ${milestone.dueDate},
-                description = ${milestone.description}
-              WHERE id = $id""".update.run.transact(tx)
-        }
-
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,127 +0,0 @@
-/*
- * 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 de.smederee.email.EmailAddress
-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[F] = Slf4jLogHandler.createLogHandler[F](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)
-    given Meta[TicketNumber]       = Meta[Int].timap(TicketNumber.apply)(_.toInt)
-
-    override def createProject(project: Project): F[Int] =
-        sql"""INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at)
-          VALUES (
-            ${project.name},
-            ${project.owner.uid},
-            ${project.isPrivate},
-            ${project.description},
-            NOW(),
-            NOW()
-          )""".update.run.transact(tx)
-
-    override def deleteProject(project: Project): F[Int] =
-        sql"""DELETE FROM tickets.projects WHERE owner = ${project.owner.uid} AND name = ${project.name}""".update.run
-            .transact(tx)
-
-    override def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] =
-        sql"""SELECT
-            users.uid AS owner_id,
-            users.name AS owner_name,
-            users.email AS owner_email,
-            projects.name,
-            projects.description,
-            projects.is_private
-          FROM tickets.projects AS projects
-          JOIN tickets.users AS users
-            ON projects.owner = users.uid
-          WHERE
-            projects.owner = ${owner.uid}
-          AND
-            projects.name = $name""".query[Project].option.transact(tx)
-
-    override def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]] =
-        sql"""SELECT
-            projects.id
-          FROM tickets.projects AS projects
-          JOIN tickets.users AS users
-            ON projects.owner = users.uid
-          WHERE
-            projects.owner = ${owner.uid}
-          AND
-            projects.name = $name""".query[ProjectId].option.transact(tx)
-
-    override def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] =
-        sql"""SELECT
-            users.uid,
-            users.name,
-            users.email
-          FROM tickets.users
-          WHERE name = $name""".query[ProjectOwner].option.transact(tx)
-
-    override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = {
-        // TODO: Find out which of the queries is more reliable and more performant.
-        /*
-        val sqlQuery1 = sql"""UPDATE tickets.projects AS alias1
-                            SET next_ticket_number = alias2.next_ticket_number + 1
-                          FROM (
-                            SELECT
-                              id,
-                              next_ticket_number
-                            FROM tickets.projects
-                            WHERE id = $projectId
-                          ) AS alias2
-                          WHERE alias1.id = alias2.id
-                          RETURNING alias2.next_ticket_number AS next_ticket_number"""
-         */
-        val sqlQuery2 = sql"""WITH old_number AS (
-                            SELECT next_ticket_number FROM tickets.projects WHERE id = $projectId
-                          )
-                          UPDATE tickets.projects
-                            SET next_ticket_number = next_ticket_number + 1
-                          WHERE id = $projectId
-                          RETURNING (
-                            SELECT next_ticket_number FROM old_number
-                          )"""
-        sqlQuery2.query[TicketNumber].unique.transact(tx)
-    }
-
-    override def updateProject(project: Project): F[Int] =
-        sql"""UPDATE tickets.projects SET
-            is_private = ${project.isPrivate},
-            description = ${project.description},
-            updated_at = NOW()
-          WHERE
-            owner = ${project.owner.uid}
-          AND name = ${project.name}""".update.run.transact(tx)
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,275 +0,0 @@
-/*
- * 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.*
-import cats.data.*
-import cats.effect.*
-import cats.syntax.all.*
-import doobie.*
-import doobie.Fragments.*
-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[F] = Slf4jLogHandler.createLogHandler[F](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
-          LEFT OUTER 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(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] = {
-        val projectFilter = fr"""tickets.project = $projectId"""
-        val tickets = filter match {
-            case None => selectTicketColumns ++ whereAnd(projectFilter)
-            case Some(filter) =>
-                val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr"""tickets.number""", numbers))
-                val statusFilter = filter.status.toNel.map(status => Fragments.in(fr"""tickets.status""", status))
-                val resolutionFilter =
-                    filter.resolution.toNel.map(resolutions => Fragments.in(fr"""tickets.resolution""", resolutions))
-                val submitterFilter =
-                    filter.submitter.toNel.map(submitters => Fragments.in(fr"""submitters.name""", submitters))
-                selectTicketColumns ++ whereAndOpt(
-                    projectFilter.some,
-                    numberFilter,
-                    statusFilter,
-                    resolutionFilter,
-                    submitterFilter
-                )
-        }
-        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,
-             milestones.closed AS closed
-           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)},
-            updated_at = NOW()
-          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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,59 +0,0 @@
-/*
- * 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 de.smederee.email.EmailAddress
-import de.smederee.i18n.LanguageCode
-import de.smederee.security.UserId
-import de.smederee.security.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[F] = Slf4jLogHandler.createLogHandler[F](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)
-    given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
-
-    override def createOrUpdateUser(user: TicketsUser): F[Int] =
-        sql"""INSERT INTO tickets.users (uid, name, email, language, created_at, updated_at)
-          VALUES (
-            ${user.uid},
-            ${user.name},
-            ${user.email},
-            ${user.language},
-            NOW(),
-            NOW()
-          ) ON CONFLICT (uid) DO UPDATE SET
-            name = EXCLUDED.name,
-            email = EXCLUDED.email,
-            language = EXCLUDED.language,
-            updated_at = EXCLUDED.updated_at""".update.run.transact(tx)
-
-    override def deleteUser(uid: UserId): F[Int] =
-        sql"""DELETE FROM tickets.users WHERE uid = $uid""".update.run.transact(tx)
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,54 +0,0 @@
-/*
- * 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.forms
-
-import cats.data.*
-import de.smederee.tickets.forms.types.*
-
-/** A base class for form validators.
-  *
-  * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets
-  * submitted as raw stringified map.</p>
-  *
-  * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir
-  * endpoints.</p>
-  *
-  * <p>However, sometimes you want to have more fine grained control...</p>
-  *
-  * @tparam T
-  *   The concrete type of the validated form output.
-  */
-abstract class FormValidator[T] {
-    final val fieldGlobal: FormField = FormValidator.fieldGlobal
-
-    /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a
-      * list of [[de.smederee.tickets.forms.types.FormErrors]].
-      *
-      * @param data
-      *   The stringified map which was submitted.
-      * @return
-      *   Either the validated form as concrete type T or a list of form errors.
-      */
-    def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T]
-
-}
-
-object FormValidator {
-    // A constant for the field name used for global errors.
-    val fieldGlobal: FormField = FormField("global")
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,106 +0,0 @@
-/*
- * 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.forms
-
-import cats.data.*
-import cats.syntax.all.*
-
-object types {
-
-    type FormErrors = Map[FormField, List[FormFieldError]]
-    object FormErrors {
-        val empty: FormErrors = Map.empty[FormField, List[FormFieldError]]
-
-        /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned
-          * from validators.
-          *
-          * @param errors
-          *   A non empty chain of FormErrors.
-          * @return
-          *   A single FormErrors instance containing all the errors.
-          */
-        def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
-
-        /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from
-          * validators.
-          *
-          * @param errors
-          *   A non empty list of FormErrors.
-          * @return
-          *   A single FormErrors instance containing all the errors.
-          */
-        def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _)
-    }
-
-    opaque type FormField = String
-    object FormField {
-
-        /** Create an instance of FormField from the given String type.
-          *
-          * @param source
-          *   An instance of type String which will be returned as a FormField.
-          * @return
-          *   The appropriate instance of FormField.
-          */
-        def apply(source: String): FormField = source
-
-        /** Try to create an instance of FormField from the given String.
-          *
-          * @param source
-          *   A String that should fulfil the requirements to be converted into a FormField.
-          * @return
-          *   An option to the successfully converted FormField.
-          */
-        def from(source: String): Option[FormField] =
-            Option(source).map(_.trim.nonEmpty) match {
-                case Some(true) => Option(source.trim)
-                case _          => None
-            }
-    }
-
-    given Conversion[FormField, String] = _.toString
-
-    opaque type FormFieldError = String
-    object FormFieldError {
-
-        /** Create an instance of FormFieldError from the given String type.
-          *
-          * @param source
-          *   An instance of type String which will be returned as a FormFieldError.
-          * @return
-          *   The appropriate instance of FormFieldError.
-          */
-        def apply(source: String): FormFieldError = source
-
-        /** Try to create an instance of FormFieldError from the given String.
-          *
-          * @param source
-          *   A String that should fulfil the requirements to be converted into a FormFieldError.
-          * @return
-          *   An option to the successfully converted FormFieldError.
-          */
-        def from(source: String): Option[FormFieldError] =
-            Option(source).map(_.trim.nonEmpty) match {
-                case Some(true) => Option(source.trim)
-                case _          => None
-            }
-    }
-
-    given Conversion[FormFieldError, String] = _.toString
-
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,78 +0,0 @@
-/*
- * 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 fs2.Stream
-
-/** The base class that defines the needed functionality to handle labels within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class LabelRepository[F[_]] {
-
-    /** Return all labels associated with the given repository.
-      *
-      * @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(projectId: ProjectId): Stream[F, Label]
-
-    /** Create a database entry for the given label definition.
-      *
-      * @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(projectId: ProjectId)(label: Label): F[Int]
-
-    /** Delete the label from the database.
-      *
-      * @param label
-      *   The label definition that shall be deleted from the database.
-      * @return
-      *   The number of affected database rows.
-      */
-    def deleteLabel(label: Label): F[Int]
-
-    /** Find the label with the given name for the given vcs repository.
-      *
-      * @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(projectId: ProjectId)(name: LabelName): F[Option[Label]]
-
-    /** Update the database entry for the given label.
-      *
-      * @param label
-      *   The label definition that shall be updated within the database.
-      * @return
-      *   The number of affected database rows.
-      */
-    def updateLabel(label: Label): F[Int]
-
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,187 +0,0 @@
-/*
- * 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 cats.*
-import cats.syntax.all.*
-
-import scala.util.matching.Regex
-
-opaque type LabelId = Long
-object LabelId {
-    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
-
-    /** Create an instance of LabelId from the given Long type.
-      *
-      * @param source
-      *   An instance of type Long which will be returned as a LabelId.
-      * @return
-      *   The appropriate instance of LabelId.
-      */
-    def apply(source: Long): LabelId = source
-
-    /** Try to create an instance of LabelId from the given Long.
-      *
-      * @param source
-      *   A Long that should fulfil the requirements to be converted into a LabelId.
-      * @return
-      *   An option to the successfully converted LabelId.
-      */
-    def from(source: Long): Option[LabelId] = Option(source)
-
-    /** Try to create an instance of LabelId from the given String.
-      *
-      * @param source
-      *   A string that should fulfil the requirements to be converted into a LabelId.
-      * @return
-      *   An option to the successfully converted LabelId.
-      */
-    def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
-
-    extension (id: LabelId) {
-        def toLong: Long = id
-    }
-}
-
-/** Extractor to retrieve an LabelId from a path parameter.
-  */
-object LabelIdPathParameter {
-    def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString)
-}
-
-/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and
-  * not exceed 40 characters in length.
-  */
-opaque type LabelName = String
-object LabelName {
-    given Eq[LabelName]       = Eq.fromUniversalEquals
-    given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y)
-    given Order[LabelName]    = Order.fromOrdering[LabelName]
-
-    val MaxLength: Int = 40
-
-    /** Create an instance of LabelName from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a LabelName.
-      * @return
-      *   The appropriate instance of LabelName.
-      */
-    def apply(source: String): LabelName = source
-
-    /** Try to create an instance of LabelName from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a LabelName.
-      * @return
-      *   An option to the successfully converted LabelName.
-      */
-    def from(source: String): Option[LabelName] =
-        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-
-}
-
-/** Extractor to retrieve an LabelName from a path parameter.
-  */
-object LabelNamePathParameter {
-    def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from)
-}
-
-/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length.
-  */
-opaque type LabelDescription = String
-object LabelDescription {
-    given Eq[LabelDescription] = Eq.fromUniversalEquals
-
-    val MaxLength: Int = 254
-
-    /** Create an instance of LabelDescription from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a LabelDescription.
-      * @return
-      *   The appropriate instance of LabelDescription.
-      */
-    def apply(source: String): LabelDescription = source
-
-    /** Try to create an instance of LabelDescription from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a LabelDescription.
-      * @return
-      *   An option to the successfully converted LabelDescription.
-      */
-    def from(source: String): Option[LabelDescription] =
-        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-}
-
-/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag
-  * followed by three 2-digit hexadecimal codes (`00-ff`).
-  */
-opaque type ColourCode = String
-object ColourCode {
-    given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b))
-
-    val Format: Regex = "^#[0-9a-fA-F]{6}$".r
-
-    /** Create an instance of ColourCode from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a ColourCode.
-      * @return
-      *   The appropriate instance of ColourCode.
-      */
-    def apply(source: String): ColourCode = source
-
-    /** Try to create an instance of ColourCode from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ColourCode.
-      * @return
-      *   An option to the successfully converted ColourCode.
-      */
-    def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string))
-
-}
-
-/** A label is intended to mark tickets with keywords and colours to allow filtering on them.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the label.
-  * @param name
-  *   A short descriptive name for the label which is supposed to be unique in a project context.
-  * @param description
-  *   An optional description if needed.
-  * @param colour
-  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
-  */
-final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode)
-
-object Label {
-    given Eq[Label] =
-        Eq.instance((thisLabel, thatLabel) =>
-            thisLabel.id === thatLabel.id &&
-                thisLabel.name === thatLabel.name &&
-                thisLabel.description === thatLabel.description &&
-                thisLabel.colour === thatLabel.colour
-        )
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,107 +0,0 @@
-/*
- * 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 fs2.Stream
-
-/** The base class that defines the needed functionality to handle milestones within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class MilestoneRepository[F[_]] {
-
-    /** Return all milestones associated with the given repository.
-      *
-      * @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(projectId: ProjectId): Stream[F, Milestone]
-
-    /** Return all tickets associated with the given milestone.
-      *
-      * @param filter
-      *   A ticket filter containing possible values which will be used to filter the list of tickets.
-      * @param milestoneId
-      *   The unique internal ID of a milestone for which all tickets shall be returned.
-      * @return
-      *   A stream of tickets associated with the vcs repository which may be empty.
-      */
-    def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket]
-
-    /** Change the milestone status with the given id to closed.
-      *
-      * @param milestoneId
-      *   The unique internal ID of a milestone for which all tickets shall be returned.
-      * @return
-      *   The number of affected database rows.
-      */
-    def closeMilestone(milestoneId: MilestoneId): F[Int]
-
-    /** Create a database entry for the given milestone definition.
-      *
-      * @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(projectId: ProjectId)(milestone: Milestone): F[Int]
-
-    /** Delete the milestone from the database.
-      *
-      * @param milestone
-      *   The milestone definition that shall be deleted from the database.
-      * @return
-      *   The number of affected database rows.
-      */
-    def deleteMilestone(milestone: Milestone): F[Int]
-
-    /** Find the milestone with the given title for the given vcs repository.
-      *
-      * @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(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]]
-
-    /** Change the milestone status with the given id to open.
-      *
-      * @param milestoneId
-      *   The unique internal ID of a milestone for which all tickets shall be returned.
-      * @return
-      *   The number of affected database rows.
-      */
-    def openMilestone(milestoneId: MilestoneId): F[Int]
-
-    /** Update the database entry for the given milestone.
-      *
-      * @param milestone
-      *   The milestone definition that shall be updated within the database.
-      * @return
-      *   The number of affected database rows.
-      */
-    def updateMilestone(milestone: Milestone): F[Int]
-
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,167 +0,0 @@
-/*
- * 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.LocalDate
-
-import cats.*
-import cats.syntax.all.*
-
-import scala.util.matching.Regex
-
-opaque type MilestoneId = Long
-object MilestoneId {
-    given Eq[MilestoneId] = Eq.fromUniversalEquals
-
-    val Format: Regex = "^-?\\d+$".r
-
-    /** Create an instance of MilestoneId from the given Long type.
-      *
-      * @param source
-      *   An instance of type Long which will be returned as a MilestoneId.
-      * @return
-      *   The appropriate instance of MilestoneId.
-      */
-    def apply(source: Long): MilestoneId = source
-
-    /** Try to create an instance of MilestoneId from the given Long.
-      *
-      * @param source
-      *   A Long that should fulfil the requirements to be converted into a MilestoneId.
-      * @return
-      *   An option to the successfully converted MilestoneId.
-      */
-    def from(source: Long): Option[MilestoneId] = Option(source)
-
-    /** Try to create an instance of MilestoneId from the given String.
-      *
-      * @param source
-      *   A string that should fulfil the requirements to be converted into a MilestoneId.
-      * @return
-      *   An option to the successfully converted MilestoneId.
-      */
-    def fromString(source: String): Option[MilestoneId] =
-        Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
-
-    extension (id: MilestoneId) {
-        def toLong: Long = id
-    }
-}
-
-/** Extractor to retrieve an MilestoneId from a path parameter.
-  */
-object MilestoneIdPathParameter {
-    def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString)
-}
-
-/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a
-  * project context. It must not be empty and not exceed 64 characters in length.
-  */
-opaque type MilestoneTitle = String
-object MilestoneTitle {
-    given Eq[MilestoneTitle]       = Eq.fromUniversalEquals
-    given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y)
-    given Order[MilestoneTitle]    = Order.fromOrdering[MilestoneTitle]
-
-    val MaxLength: Int = 64
-
-    /** Create an instance of MilestoneTitle from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a MilestoneTitle.
-      * @return
-      *   The appropriate instance of MilestoneTitle.
-      */
-    def apply(source: String): MilestoneTitle = source
-
-    /** Try to create an instance of MilestoneTitle from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a MilestoneTitle.
-      * @return
-      *   An option to the successfully converted MilestoneTitle.
-      */
-    def from(source: String): Option[MilestoneTitle] =
-        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-
-}
-
-/** Extractor to retrieve an MilestoneTitle from a path parameter.
-  */
-object MilestoneTitlePathParameter {
-    def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from)
-}
-
-/** A longer detailed description of a project milestone which must not be empty.
-  */
-opaque type MilestoneDescription = String
-object MilestoneDescription {
-    given Eq[MilestoneDescription] = Eq.fromUniversalEquals
-
-    /** Create an instance of MilestoneDescription from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a MilestoneDescription.
-      * @return
-      *   The appropriate instance of MilestoneDescription.
-      */
-    def apply(source: String): MilestoneDescription = source
-
-    /** Try to create an instance of MilestoneDescription from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a MilestoneDescription.
-      * @return
-      *   An option to the successfully converted MilestoneDescription.
-      */
-    def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty)
-
-}
-
-/** A milestone can be used to organise tickets and progress inside a project.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the milestone.
-  * @param title
-  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
-  *   a project context.
-  * @param description
-  *   An optional longer description of the milestone.
-  * @param dueDate
-  *   An optional date on which the milestone is supposed to be reached.
-  * @param closed
-  *   This flag indicates if the milestone is closed e.g. considered done or obsolete.
-  */
-final case class Milestone(
-    id: Option[MilestoneId],
-    title: MilestoneTitle,
-    description: Option[MilestoneDescription],
-    dueDate: Option[LocalDate],
-    closed: Boolean
-)
-
-object Milestone {
-
-    given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
-
-    given Eq[Milestone] =
-        Eq.instance((a, b) =>
-            a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description
-        )
-
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,96 +0,0 @@
-/*
- * 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
-
-/** A base class for a database repository that should handle all functionality regarding projects in the database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class ProjectRepository[F[_]] {
-
-    /** Create the given project within the database.
-      *
-      * @param project
-      *   The project that shall be created.
-      * @return
-      *   The number of affected database rows.
-      */
-    def createProject(project: Project): F[Int]
-
-    /** Delete the given project from the database.
-      *
-      * @param project
-      *   The project that shall be deleted.
-      * @return
-      *   The number of affected database rows.
-      */
-    def deleteProject(project: Project): F[Int]
-
-    /** Search for the project entry with the given owner and name.
-      *
-      * @param owner
-      *   Data about the owner of the project containing information needed to query the database.
-      * @param name
-      *   The project name which must be unique in regard to the owner.
-      * @return
-      *   An option to the successfully found project entry.
-      */
-    def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]]
-
-    /** Search for the internal database specific (auto generated) ID of the given owner / project combination which
-      * serves as a primary key for the database table.
-      *
-      * @param owner
-      *   Data about the owner of the project containing information needed to query the database.
-      * @param name
-      *   The project name which must be unique in regard to the owner.
-      * @return
-      *   An option to the internal database ID.
-      */
-    def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[ProjectId]]
-
-    /** Search for a project owner of whom we only know the name.
-      *
-      * @param name
-      *   The name of the project owner which is the username of the actual owners account.
-      * @return
-      *   An option to successfully found project owner.
-      */
-    def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]]
-
-    /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was
-      * incremented).
-      *
-      * @param projectId
-      *   The internal database id of the project.
-      * @return
-      *   The ticket number _before_ it was incremented.
-      */
-    def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber]
-
-    /** Update the database entry for the given project.
-      *
-      * @param project
-      *   The project that shall be updated within the database.
-      * @return
-      *   The number of affected database rows.
-      */
-    def updateProject(project: Project): F[Int]
-
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,355 +0,0 @@
-/*
- * 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.*
-import cats.data.*
-import cats.syntax.all.*
-import de.smederee.email.EmailAddress
-import de.smederee.security.UserId
-import de.smederee.security.Username
-
-import scala.util.Try
-import scala.util.matching.Regex
-
-opaque type ProjectDescription = String
-object ProjectDescription {
-    val MaximumLength: Int = 8192
-
-    /** Create an instance of ProjectDescription from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a ProjectDescription.
-      * @return
-      *   The appropriate instance of ProjectDescription.
-      */
-    def apply(source: String): ProjectDescription = source
-
-    /** Try to create an instance of ProjectDescription from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ProjectDescription.
-      * @return
-      *   An option to the successfully converted ProjectDescription.
-      */
-    def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength))
-
-}
-
-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 {
-
-    given Eq[ProjectName] = Eq.fromUniversalEquals
-
-    given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString))
-
-    // TODO: Can we rewrite this in a Scala-3 way (i.e. without implicitly)?
-    given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering
-
-    val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r
-
-    /** Create an instance of ProjectName from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a ProjectName.
-      * @return
-      *   The appropriate instance of ProjectName.
-      */
-    def apply(source: String): ProjectName = source
-
-    /** Try to create an instance of ProjectName from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ProjectName.
-      * @return
-      *   An option to the successfully converted ProjectName.
-      */
-    def from(source: String): Option[ProjectName] = validate(source).toOption
-
-    /** Validate the given string and return either the validated repository name or a list of errors.
-      *
-      * @param s
-      *   An arbitrary string which should be a repository name.
-      * @return
-      *   Either a list of errors or the validated repository name.
-      */
-    def validate(s: String): ValidatedNec[String, ProjectName] =
-        Option(s).map(_.trim.nonEmpty) match {
-            case Some(true) =>
-                val input = s.trim
-                val miniumLength =
-                    if (input.length > 1)
-                        input.validNec
-                    else
-                        "Repository name too short (min. 2 characters)!".invalidNec
-                val maximumLength =
-                    if (input.length < 65)
-                        input.validNec
-                    else
-                        "Repository name too long (max. 64 characters)!".invalidNec
-                val validFormat =
-                    if (Format.matches(input))
-                        input.validNec
-                    else
-                        "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec
-                (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) =>
-                    name
-                }
-            case _ => "Repository name must not be empty!".invalidNec
-        }
-}
-
-/** Extractor to retrieve a ProjectName from a path parameter.
-  */
-object ProjectNamePathParameter {
-    def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from)
-}
-
-/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
-  */
-opaque type ProjectOwnerId = UUID
-object ProjectOwnerId {
-    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
-
-    given Eq[ProjectOwnerId] = Eq.fromUniversalEquals
-
-    // given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId
-
-    /** Create an instance of ProjectOwnerId from the given UUID type.
-      *
-      * @param source
-      *   An instance of type UUID which will be returned as a ProjectOwnerId.
-      * @return
-      *   The appropriate instance of ProjectOwnerId.
-      */
-    def apply(source: UUID): ProjectOwnerId = source
-
-    /** Try to create an instance of ProjectOwnerId from the given UUID.
-      *
-      * @param source
-      *   A UUID that should fulfil the requirements to be converted into a ProjectOwnerId.
-      * @return
-      *   An option to the successfully converted ProjectOwnerId.
-      */
-    def from(source: UUID): Option[ProjectOwnerId] = Option(source)
-
-    /** Try to create an instance of ProjectOwnerId from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ProjectOwnerId.
-      * @return
-      *   An option to the successfully converted ProjectOwnerId.
-      */
-    def fromString(source: String): Either[String, ProjectOwnerId] =
-        Option(source)
-            .filter(s => Format.matches(s))
-            .flatMap { uuidString =>
-                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
-            }
-            .toRight("Illegal value for ProjectOwnerId!")
-
-    /** Create an instance of ProjectOwnerId from the given UserId type.
-      *
-      * @param uid
-      *   An instance of type UserId which will be returned as a ProjectOwnerId.
-      * @return
-      *   The appropriate instance of ProjectOwnerId.
-      */
-    def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID
-
-    /** Generate a new random project owner id.
-      *
-      * @return
-      *   A project owner id which is pseudo randomly generated.
-      */
-    def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID
-
-    extension (uid: ProjectOwnerId) {
-        def toUUID: UUID = uid
-    }
-}
-
-/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix
-  * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31
-  * characters long and be all lowercase.
-  */
-opaque type ProjectOwnerName = String
-object ProjectOwnerName {
-    given Eq[ProjectOwnerName] = Eq.fromUniversalEquals
-
-    given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername
-
-    val isAlphanumeric = "^[a-z][a-z0-9]+$".r
-
-    /** Create an instance of ProjectOwnerName from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a ProjectOwnerName.
-      * @return
-      *   The appropriate instance of ProjectOwnerName.
-      */
-    def apply(source: String): ProjectOwnerName = source
-
-    /** Try to create an instance of ProjectOwnerName from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a ProjectOwnerName.
-      * @return
-      *   An option to the successfully converted ProjectOwnerName.
-      */
-    def from(s: String): Option[ProjectOwnerName] = validate(s).toOption
-
-    /** Create an instance of ProjectOwnerName from the given Username type.
-      *
-      * @param username
-      *   An instance of the type Username which will be returned as a ProjectOwnerName.
-      * @return
-      *   The appropriate instance of ProjectOwnerName.
-      */
-    def fromUsername(username: Username): ProjectOwnerName = username.toString
-
-    /** Validate the given string and return either the validated project owner name or a list of errors.
-      *
-      * @param s
-      *   An arbitrary string which should be a project owner name.
-      * @return
-      *   Either a list of errors or the validated project owner name.
-      */
-    def validate(s: String): ValidatedNec[String, ProjectOwnerName] =
-        Option(s).map(_.trim.nonEmpty) match {
-            case Some(true) =>
-                val input = s.trim
-                val miniumLength =
-                    if (input.length >= 2)
-                        input.validNec
-                    else
-                        "ProjectOwnerName too short (min. 2 characters)!".invalidNec
-                val maximumLength =
-                    if (input.length < 32)
-                        input.validNec
-                    else
-                        "ProjectOwnerName too long (max. 31 characters)!".invalidNec
-                val alphanumeric =
-                    if (isAlphanumeric.matches(input))
-                        input.validNec
-                    else
-                        "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec
-                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
-                    name
-                }
-            case _ => "ProjectOwnerName must not be empty!".invalidNec
-        }
-
-    extension (ownername: ProjectOwnerName) {
-
-        /** Convert this project owner name into a username.
-          *
-          * @return
-          *   A syntactically valid username.
-          */
-        def toUsername: Username = Username(ownername.toString)
-    }
-}
-
-/** Extractor to retrieve an ProjectOwnerName from a path parameter.
-  */
-object ProjectOwnerNamePathParameter {
-    def unapply(str: String): Option[ProjectOwnerName] =
-        Option(str).flatMap { string =>
-            if (string.startsWith("~"))
-                ProjectOwnerName.from(string.drop(1))
-            else
-                None
-        }
-}
-
-/** Descriptive information about the owner of a project.
-  *
-  * @param owner
-  *   The unique ID of the project owner.
-  * @param name
-  *   The name of the project owner which is supposed to be unique.
-  * @param email
-  *   The email address of the project owner.
-  */
-final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress)
-
-object ProjectOwner {
-    given Eq[ProjectOwner] = Eq.fromUniversalEquals
-}
-
-/** A project is the base entity for tracking tickets.
-  *
-  * @param owner
-  *   The owner of the project.
-  * @param name
-  *   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.
-  * @param description
-  *   An optional short text description of the project.
-  * @param isPrivate
-  *   A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate
-  *   permissions.
-  */
-final case class Project(
-    owner: ProjectOwner,
-    name: ProjectName,
-    description: Option[ProjectDescription],
-    isPrivate: Boolean
-)
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Slf4jLogHandler.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,91 +0,0 @@
-/*
- * 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 cats.effect.*
-import doobie.util.log.*
-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[F[_]: Sync](log: Logger): LogHandler[F] =
-        new LogHandler[F] {
-            def run(logEvent: LogEvent): F[Unit] =
-                Sync[F].delay {
-                    logEvent match {
-                        case Success(sqlQuery, arguments, label, executionTime, processingTime) =>
-                            log.debug(s"""SQL command successful:
-                           |
-                           | ${sqlQueryToLogString(sqlQuery)}
-                           |
-                           | arguments: [${sqlArgumentsToLogString(arguments)}]
-                           | label: $label
-                           |
-                           | execution time : ${executionTime.toMillis} ms
-                           | processing time: ${processingTime.toMillis} ms
-                           | total time     : ${(executionTime + processingTime).toMillis} ms
-                           |""".stripMargin)
-                        case ProcessingFailure(sqlQuery, arguments, label, executionTime, processingTime, failure) =>
-                            log.error(
-                                s"""SQL PROCESSING FAILURE:
-                   |
-                   | ${sqlQueryToLogString(sqlQuery)}
-                   |
-                   | arguments: [${sqlArgumentsToLogString(arguments)}]
-                   | label: $label
-                   |
-                   | execution time : ${executionTime.toMillis} ms
-                   | processing time: ${processingTime.toMillis} ms
-                   | total time     : ${(executionTime + processingTime).toMillis} ms
-                   |""".stripMargin,
-                                failure
-                            )
-                        case ExecFailure(sqlQuery, arguments, label, executionTime, failure) =>
-                            log.error(
-                                s"""SQL EXECUTION FAILURE:
-                   |
-                   | ${sqlQueryToLogString(sqlQuery)}
-                   |
-                   | arguments: [${sqlArgumentsToLogString(arguments)}]
-                   | label: $label
-                   |
-                   | execution time : ${executionTime.toMillis} ms
-                   |""".stripMargin,
-                                failure
-                            )
-                    }
-                }
-        }
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,163 +0,0 @@
-/*
- * 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.*
-import cats.data.*
-import cats.syntax.all.*
-
-/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
-  */
-opaque type SubmitterId = UUID
-object SubmitterId {
-    val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
-
-    given Eq[SubmitterId] = Eq.fromUniversalEquals
-
-    /** Create an instance of SubmitterId from the given UUID type.
-      *
-      * @param source
-      *   An instance of type UUID which will be returned as a SubmitterId.
-      * @return
-      *   The appropriate instance of SubmitterId.
-      */
-    def apply(source: UUID): SubmitterId = source
-
-    /** Try to create an instance of SubmitterId from the given UUID.
-      *
-      * @param source
-      *   A UUID that should fulfil the requirements to be converted into a SubmitterId.
-      * @return
-      *   An option to the successfully converted SubmitterId.
-      */
-    def from(source: UUID): Option[SubmitterId] = Option(source)
-
-    /** Try to create an instance of SubmitterId from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a SubmitterId.
-      * @return
-      *   An option to the successfully converted SubmitterId.
-      */
-    def fromString(source: String): Either[String, SubmitterId] =
-        Option(source)
-            .filter(s => Format.matches(s))
-            .flatMap { uuidString =>
-                Either.catchNonFatal(UUID.fromString(uuidString)).toOption
-            }
-            .toRight("Illegal value for SubmitterId!")
-
-    /** Generate a new random user id.
-      *
-      * @return
-      *   A user id which is pseudo randomly generated.
-      */
-    def randomSubmitterId: SubmitterId = UUID.randomUUID
-
-    extension (uid: SubmitterId) {
-        def toUUID: UUID = uid
-    }
-}
-
-/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must
-  * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all
-  * lowercase.
-  */
-opaque type SubmitterName = String
-object SubmitterName {
-    given Eq[SubmitterName] = Eq.fromUniversalEquals
-
-    val isAlphanumeric = "^[a-z][a-z0-9]+$".r
-
-    /** Create an instance of SubmitterName from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a SubmitterName.
-      * @return
-      *   The appropriate instance of SubmitterName.
-      */
-    def apply(source: String): SubmitterName = source
-
-    /** Try to create an instance of SubmitterName from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a SubmitterName.
-      * @return
-      *   An option to the successfully converted SubmitterName.
-      */
-    def from(s: String): Option[SubmitterName] = validate(s).toOption
-
-    /** Validate the given string and return either the validated username or a list of errors.
-      *
-      * @param s
-      *   An arbitrary string which should be a username.
-      * @return
-      *   Either a list of errors or the validated username.
-      */
-    def validate(s: String): ValidatedNec[String, SubmitterName] =
-        Option(s).map(_.trim.nonEmpty) match {
-            case Some(true) =>
-                val input = s.trim
-                val miniumLength =
-                    if (input.length >= 2)
-                        input.validNec
-                    else
-                        "SubmitterName too short (min. 2 characters)!".invalidNec
-                val maximumLength =
-                    if (input.length < 32)
-                        input.validNec
-                    else
-                        "SubmitterName too long (max. 31 characters)!".invalidNec
-                val alphanumeric =
-                    if (isAlphanumeric.matches(input))
-                        input.validNec
-                    else
-                        "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec
-                (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) =>
-                    name
-                }
-            case _ => "SubmitterName must not be empty!".invalidNec
-        }
-}
-
-/** Extractor to retrieve an SubmitterName from a path parameter.
-  */
-object SubmitterNamePathParameter {
-    def unapply(str: String): Option[SubmitterName] =
-        Option(str).flatMap { string =>
-            if (string.startsWith("~"))
-                SubmitterName.from(string.drop(1))
-            else
-                None
-        }
-}
-
-/** The submitter for a ticket i.e. the person supposed to be working on it.
-  *
-  * @param id
-  *   A globally unique ID identifying the submitter.
-  * @param name
-  *   The name associated with the submitter which is supposed to be unique.
-  */
-final case class Submitter(id: SubmitterId, name: SubmitterName)
-
-object Submitter {
-    given Eq[Submitter] = Eq.fromUniversalEquals
-}
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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,207 +0,0 @@
-/*
- * 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 fs2.Stream
-
-/** The base class that defines the needed functionality to handle tickets and related data types within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class TicketRepository[F[_]] {
-
-    /** Add the given assignee to the ticket of the given repository 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.
-      * @param assignee
-      *   The assignee to be added to the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    def addAssignee(projectId: ProjectId)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int]
-
-    /** Add the given label to the ticket of the given repository 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.
-      * @param label
-      *   The label to be added to the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    def addLabel(projectId: ProjectId)(ticketNumber: TicketNumber)(label: Label): F[Int]
-
-    /** Add the given milestone to the ticket of the given repository 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.
-      * @param milestone
-      *   The milestone to be added to the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    def addMilestone(projectId: ProjectId)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int]
-
-    /** Return all tickets associated with the given repository.
-      *
-      * @param filter
-      *   A ticket filter containing possible values which will be used to filter the list of tickets.
-      * @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(filter: Option[TicketFilter])(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 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(projectId: ProjectId)(ticket: Ticket): F[Int]
-
-    /** Delete the ticket of the repository with the given id from the database.
-      *
-      * @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(projectId: ProjectId)(ticket: Ticket): F[Int]
-
-    /** Find the ticket with the given number of the repository with the given 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
-      *   An option to the found 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 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(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Assignee]
-
-    /** Load all labels that are attached to the ticket with the given number and repository 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 stream of labels that may be empty.
-      */
-    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 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(projectId: ProjectId)(ticketNumber: TicketNumber): Stream[F, Milestone]
-
-    /** Remove the given assignee from the ticket of the given repository 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.
-      * @param assignee
-      *   The assignee to be removed from the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    def removeAssignee(projectId: ProjectId)(ticket: Ticket)(assignee: Assignee): F[Int]
-
-    /** Remove the given label from the ticket of the given repository 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.
-      * @param label
-      *   The label to be removed from the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    def removeLabel(projectId: ProjectId)(ticket: Ticket)(label: Label): F[Int]
-
-    /** Remove the given milestone from the ticket of the given repository 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.
-      * @param milestone
-      *   The milestone to be removed from the ticket.
-      * @return
-      *   The number of affected database rows.
-      */
-    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 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(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	2024-05-18 22:05:18.497443697 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,426 +0,0 @@
-/*
- * 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.*
-import cats.syntax.all.*
-import org.http4s.QueryParamDecoder
-import org.http4s.QueryParamEncoder
-import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher
-
-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
-object TicketContent {
-
-    /** Create an instance of TicketContent from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a TicketContent.
-      * @return
-      *   The appropriate instance of TicketContent.
-      */
-    def apply(source: String): TicketContent = source
-
-    /** Try to create an instance of TicketContent from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a TicketContent.
-      * @return
-      *   An option to the successfully converted TicketContent.
-      */
-    def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty)
-
-}
-
-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 Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y)
-    given Order[TicketNumber]    = Order.fromOrdering
-
-    val Format: Regex = "^-?\\d+$".r
-
-    /** Create an instance of TicketNumber from the given Int type.
-      *
-      * @param source
-      *   An instance of type Int which will be returned as a TicketNumber.
-      * @return
-      *   The appropriate instance of TicketNumber.
-      */
-    def apply(source: Int): TicketNumber = source
-
-    /** Try to create an instance of TicketNumber from the given Int.
-      *
-      * @param source
-      *   A Int that should fulfil the requirements to be converted into a TicketNumber.
-      * @return
-      *   An option to the successfully converted TicketNumber.
-      */
-    def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0)
-
-    /** Try to create an instance of TicketNumber from the given String.
-      *
-      * @param source
-      *   A string that should fulfil the requirements to be converted into a TicketNumber.
-      * @return
-      *   An option to the successfully converted TicketNumber.
-      */
-    def fromString(source: String): Option[TicketNumber] =
-        Option(source).filter(Format.matches).map(_.toInt).flatMap(from)
-
-    extension (number: TicketNumber) {
-        def toInt: Int = number.toInt
-    }
-}
-
-/** Extractor to retrieve a TicketNumber from a path parameter.
-  */
-object TicketNumberPathParameter {
-    def unapply(str: String): Option[TicketNumber] = Option(str).flatMap(TicketNumber.fromString)
-}
-
-/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things
-  * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket.
-  */
-enum TicketStatus {
-
-    /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be
-      * present.
-      */
-    case Confirmed
-
-    /** The ticket is being worked on i.e. it is in progress.
-      */
-    case InProgress
-
-    /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on
-      * the circumstances. This could be used to model the "blocked" state of Kanban.
-      */
-    case Pending
-
-    /** The ticket is resolved (i.e. closed) and considered done.
-      */
-    case Resolved
-
-    /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog"
-      * nowadays.
-      */
-    case Submitted
-}
-
-object TicketStatus {
-    given Eq[TicketStatus] = Eq.fromUniversalEquals
-
-    /** Try to parse a ticket status instance from the given string without throwin an exception like `valueOf`.
-      *
-      * @param source
-      *   A string that should contain the name of a ticket status.
-      * @return
-      *   An option to the successfully deserialised instance.
-      */
-    def fromString(source: String): Option[TicketStatus] =
-        TicketStatus.values.map(_.toString).find(_ === source).map(TicketStatus.valueOf)
-}
-
-/** Possible types of "resolved states" of a ticket.
-  */
-enum TicketResolution {
-
-    /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered
-      * to be a bug.
-      */
-    case ByDesign
-
-    /** The ticket is finally closed and considered done.
-      *
-      * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and
-      * tester can later move the ticket to `Closed`.
-      */
-    case Closed
-
-    /** The ticket is a duplicate of an already existing one.
-      */
-    case Duplicate
-
-    /** The bug described in the ticket was fixed.
-      */
-    case Fixed
-
-    /** The feature described in the ticket was implemented.
-      */
-    case Implemented
-
-    /** The ticket is considered to be invalid.
-      */
-    case Invalid
-
-    /** The issue described in the ticket will not be fixed.
-      */
-    case WontFix
-}
-
-object TicketResolution {
-    given Eq[TicketResolution] = Eq.fromUniversalEquals
-
-    /** Try to parse a ticket resolution instance from the given string without throwin an exception like `valueOf`.
-      *
-      * @param source
-      *   A string that should contain the name of a ticket resolution.
-      * @return
-      *   An option to the successfully deserialised instance.
-      */
-    def fromString(source: String): Option[TicketResolution] =
-        TicketResolution.values.map(_.toString).find(_ === source).map(TicketResolution.valueOf)
-}
-
-/** A concise and short description of the ticket which should not exceed 80 characters.
-  */
-opaque type TicketTitle = String
-object TicketTitle {
-
-    val MaxLength: Int = 72
-
-    /** Create an instance of TicketTitle from the given String type.
-      *
-      * @param source
-      *   An instance of type String which will be returned as a TicketTitle.
-      * @return
-      *   The appropriate instance of TicketTitle.
-      */
-    def apply(source: String): TicketTitle = source
-
-    /** Try to create an instance of TicketTitle from the given String.
-      *
-      * @param source
-      *   A String that should fulfil the requirements to be converted into a TicketTitle.
-      * @return
-      *   An option to the successfully converted TicketTitle.
-      */
-    def from(source: String): Option[TicketTitle] =
-        Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-}
-
-/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project.
-  *
-  * @param number
-  *   The unique identifier of a ticket within the project scope is its number.
-  * @param title
-  *   A concise and short description of the ticket which should not exceed 72 characters.
-  * @param content
-  *   An optional field to describe the ticket in great detail if needed.
-  * @param status
-  *   The current status of the ticket describing its life cycle.
-  * @param resolution
-  *   An optional resolution state of the ticket that should be set if it is closed.
-  * @param submitter
-  *   The person who submitted (created) this ticket which is optional because of possible account deletion or other
-  *   reasons.
-  * @param createdAt
-  *   The timestamp when the ticket was created / submitted.
-  * @param updatedAt
-  *   The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.
-  */
-final case class Ticket(
-    number: TicketNumber,
-    title: TicketTitle,
-    content: Option[TicketContent],
-    status: TicketStatus,
-    resolution: Option[TicketResolution],
-    submitter: Option[Submitter],
-    createdAt: OffsetDateTime,
-    updatedAt: OffsetDateTime
-)
-
-/** A data container for values that can be used to filter a list of tickets by.
-  *
-  * @param number
-  *   A list of ticket numbers that must be matched.
-  * @param status
-  *   A list of ticket status flags that must be matched.
-  * @param resolution
-  *   A list of ticket resolution kinds that must be matched.
-  * @param submitter
-  *   A list of usernames from whom the ticket must have been submitted.
-  */
-final case class TicketFilter(
-    number: List[TicketNumber],
-    status: List[TicketStatus],
-    resolution: List[TicketResolution],
-    submitter: List[SubmitterName]
-)
-
-object TicketFilter {
-    given QueryParamDecoder[TicketFilter] = QueryParamDecoder[String].map(TicketFilter.fromQueryParameter)
-    given QueryParamEncoder[TicketFilter] = QueryParamEncoder[String].contramap(_.toQueryParameter)
-
-    /** Decode an optional possibly existing query parameter into a `TicketFilter`.
-      *
-      * Usage: `case GET -> Root / "..." :? OptionalUrlParamter(maybeFilter) => ...`
-      */
-    object OptionalUrlParameter extends OptionalQueryParamDecoderMatcher[TicketFilter]("q")
-
-    // Only "open" tickets.
-    val OpenTicketsOnly = TicketFilter(
-        number = Nil,
-        status = TicketStatus.values.filterNot(_ === TicketStatus.Resolved).toList,
-        resolution = Nil,
-        submitter = Nil
-    )
-    // Only resolved (closed) tickets.
-    val ResolvedTicketsOnly =
-        TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil)
-
-    /** Parse the given query string which must contain a serialised ticket filter instance and return a ticket filter
-      * with the successfully parsed filters.
-      *
-      * @param queryString
-      *   A query string parameter passed via an URL.
-      * @return
-      *   A ticket filter instance which may be empty.
-      */
-    def fromQueryParameter(queryString: String): TicketFilter = {
-        val number =
-            if (queryString.contains("numbers: "))
-                queryString
-                    .drop(queryString.indexOf("numbers: ") + 9)
-                    .takeWhile(char => !char.isWhitespace)
-                    .split(",")
-                    .map(TicketNumber.fromString)
-                    .flatten
-                    .toList
-            else
-                Nil
-        val status =
-            if (queryString.contains("status: "))
-                queryString
-                    .drop(queryString.indexOf("status: ") + 8)
-                    .takeWhile(char => !char.isWhitespace)
-                    .split(",")
-                    .map(TicketStatus.fromString)
-                    .flatten
-                    .toList
-            else
-                Nil
-        val resolution =
-            if (queryString.contains("resolution: "))
-                queryString
-                    .drop(queryString.indexOf("resolution: ") + 12)
-                    .takeWhile(char => !char.isWhitespace)
-                    .split(",")
-                    .map(TicketResolution.fromString)
-                    .flatten
-                    .toList
-            else
-                Nil
-        val submitter =
-            if (queryString.contains("by: "))
-                queryString
-                    .drop(queryString.indexOf("by: ") + 4)
-                    .takeWhile(char => !char.isWhitespace)
-                    .split(",")
-                    .map(SubmitterName.from)
-                    .flatten
-                    .toList
-            else
-                Nil
-        TicketFilter(number, status, resolution, submitter)
-    }
-
-    extension (filter: TicketFilter) {
-
-        /** Convert this ticket filter instance into a query string representation that can be passed as query parameter
-          * in a URL and parsed back again.
-          *
-          * @return
-          *   A string containing a serialised form of the ticket filter that can be used as a URL query parameter.
-          */
-        def toQueryParameter: String = {
-            val numbers =
-                if (filter.number.isEmpty)
-                    None
-                else
-                    filter.number.map(_.toString).mkString(",").some
-            val status =
-                if (filter.status.isEmpty)
-                    None
-                else
-                    filter.status.map(_.toString).mkString(",").some
-            val resolution =
-                if (filter.resolution.isEmpty)
-                    None
-                else
-                    filter.resolution.map(_.toString).mkString(",").some
-            val submitter =
-                if (filter.submitter.isEmpty)
-                    None
-                else
-                    filter.submitter.map(_.toString).mkString(",").some
-            List(
-                numbers.map(string => s"numbers: $string"),
-                status.map(string => s"status: $string"),
-                resolution.map(string => s"resolution: $string"),
-                submitter.map(string => s"by: $string")
-            ).flatten.mkString(" ")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,48 +0,0 @@
-/*
- * 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 de.smederee.security.UserId
-
-/** Definition of a programmatic API for the ticket service which can be used to initialise and synchronise data with
-  * the hub service.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class TicketServiceApi[F[_]] {
-
-    /** Create a user in the ticket service or update an existing one if an account with the unique id already exists.
-      *
-      * @param user
-      *   The user account that shall be created.
-      * @return
-      *   The number of affected database rows.
-      */
-    def createOrUpdateUser(user: TicketsUser): F[Int]
-
-    /** Delete the given user from the ticket service.
-      *
-      * @param uid
-      *   The unique id of the user account.
-      * @return
-      *   The number of affected database rows.
-      */
-    def deleteUser(uid: UserId): F[Int]
-
-}
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-/*
- * 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 cats.*
-import de.smederee.email.EmailAddress
-import de.smederee.i18n.LanguageCode
-import de.smederee.security.UserId
-import de.smederee.security.Username
-
-/** A user of the tickets service.
-  *
-  * @param uid
-  *   The unique ID of the user.
-  * @param name
-  *   A unique name which can be used for login and to identify the user.
-  * @param email
-  *   The email address of the user which must also be unique.
-  * @param language
-  *   The language code of the users preferred language.
-  */
-final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress, language: Option[LanguageCode])
-
-object TicketsUser {
-    given Eq[TicketsUser] = Eq.fromUniversalEquals
-}
diff -rN -u old-smederee/modules/tickets/src/test/resources/application.conf new-smederee/modules/tickets/src/test/resources/application.conf
--- old-smederee/modules/tickets/src/test/resources/application.conf	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/resources/application.conf	1970-01-01 00:00:00.000000000 +0000
@@ -1,12 +0,0 @@
-tickets {
-  database {
-	host = localhost
-	host = ${?SMEDEREE_DB_HOST}
-	url  = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it"
-	url  = ${?SMEDEREE_TICKETS_TEST_DB_URL}
-	user = "smederee_tickets"
-	user = ${?SMEDEREE_TICKETS_TEST_DB_USER}
-	pass = "secret"
-	pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS}
-  }
-}
diff -rN -u old-smederee/modules/tickets/src/test/resources/logback-test.xml new-smederee/modules/tickets/src/test/resources/logback-test.xml
--- old-smederee/modules/tickets/src/test/resources/logback-test.xml	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/resources/logback-test.xml	1970-01-01 00:00:00.000000000 +0000
@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<configuration debug="false">
-  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
-    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
-      <level>WARN</level>
-    </filter>
-    <encoder>
-      <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern>
-    </encoder>
-  </appender>
-
-  <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender">
-    <appender-ref ref="console"/>
-    <queueSize>5000</queueSize>
-    <discardingThreshold>0</discardingThreshold>
-  </appender>
-
-  <logger name="de.smederee.tickets" level="DEBUG" additivity="false">
-    <appender-ref ref="async-console"/>
-  </logger>
-
-  <logger name="org.flywaydb.core" level="ERROR" additivity="false">
-	<appender-ref ref="async-console"/>
-  </logger>
-
-  <root>
-    <appender-ref ref="async-console"/>
-  </root>
-</configuration>
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala new-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/TestTags.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,25 +0,0 @@
-/*
- * 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
-
-/** A collection of tags that can be used to label tests which have certain requirements for example a database
-  * connection.
-  */
-object TestTags {
-    val NeedsDatabase = new munit.Tag("NeedsDatabase")
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/BaseSpec.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,369 +0,0 @@
-/*
- * 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.net.ServerSocket
-
-import cats.effect.*
-import cats.syntax.all.*
-import com.comcast.ip4s.*
-import com.typesafe.config.ConfigFactory
-import de.smederee.email.EmailAddress
-import de.smederee.i18n.LanguageCode
-import de.smederee.security.UserId
-import de.smederee.security.Username
-import de.smederee.tickets.config.*
-import org.flywaydb.core.Flyway
-import pureconfig.*
-
-import munit.*
-
-import scala.annotation.nowarn
-
-/** Base class for our integration test suites.
-  *
-  * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and
-  * does initialise the test database for each suite. The latter means a possibly existing database with the name
-  * configured **will be deleted**!
-  */
-abstract class BaseSpec extends CatsEffectSuite {
-    protected final val configuration: SmedereeTicketsConfiguration =
-        ConfigSource
-            .fromConfig(ConfigFactory.load(getClass.getClassLoader))
-            .at(SmedereeTicketsConfiguration.location)
-            .loadOrThrow[SmedereeTicketsConfiguration]
-
-    protected final val flyway: Flyway =
-        DatabaseMigrator
-            .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass)
-            .cleanDisabled(false)
-            .load()
-
-    /** Connect to the DBMS using the generic "template1" database which should always be present.
-      *
-      * @param dbConfig
-      *   The database configuration.
-      * @return
-      *   The connection to the database ("template1").
-      */
-    private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] =
-        for {
-            _        <- IO(Class.forName(dbConfig.driver))
-            database <- IO(dbConfig.url.split("/").reverse.take(1).mkString)
-            connection <- IO(
-                java.sql.DriverManager
-                    .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass)
-            )
-        } yield connection
-
-    @nowarn("msg=discarded non-Unit value.*")
-    override def beforeAll(): Unit = {
-        // Extract the database name from the URL.
-        val database = configuration.database.url.split("/").reverse.take(1).mkString
-        val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
-        // Create the test database if it does not already exist.
-        db.use { connection =>
-            for {
-                statement <- IO(connection.createStatement())
-                exists <- IO(
-                    statement.executeQuery(
-                        s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'"""
-                    )
-                )
-                _ <- IO {
-                    if (!exists.next())
-                        statement.execute(s"""CREATE DATABASE "$database"""")
-                }
-                _ <- IO(exists.close)
-                _ <- IO(statement.close)
-            } yield ()
-        }.unsafeRunSync()
-    }
-
-    override def afterAll(): Unit = {
-        // Extract the database name from the URL.
-        val database = configuration.database.url.split("/").reverse.take(1).mkString
-        val db       = Resource.make(connect(configuration.database))(con => IO(con.close()))
-        // Drop the test database after all tests have been run.
-        db.use { connection =>
-            for {
-                statement <- IO(connection.createStatement())
-                _         <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database"""))
-                _         <- IO(statement.close)
-            } yield ()
-        }.unsafeRunSync()
-    }
-
-    override def beforeEach(context: BeforeEach): Unit = {
-        val _ = flyway.migrate()
-    }
-
-    override def afterEach(context: AfterEach): Unit = {
-        val _ = flyway.clean()
-    }
-
-    /** Find and return a free port on the local machine by starting a server socket and closing it. The port number
-      * used by the socket is marked to allow reuse, considered free and returned.
-      *
-      * @return
-      *   An optional port number if a free one can be found.
-      */
-    protected def findFreePort(): Option[Port] = {
-        val socket = new ServerSocket(0)
-        val port   = socket.getLocalPort
-        socket.setReuseAddress(true) // Allow instant rebinding of the socket.
-        socket.close()               // Free the socket for further use by closing it.
-        Port.fromInt(port)
-    }
-
-    /** Provide a resource with a database connection to allow db operations and proper resource release later.
-      *
-      * @param cfg
-      *   The application configuration.
-      * @return
-      *   A cats resource encapsulation a database connection as defined within the given configuration.
-      */
-    protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] =
-        Resource.make(
-            IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass))
-        )(c => IO.delay(c.close()))
-
-    /** Create a project for ticket tracking in the database.
-      *
-      * @param project
-      *   The project 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 createTicketsProject(project: Project): IO[Int] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay {
-                    con.prepareStatement(
-                        """INSERT INTO tickets.projects (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())"""
-                    )
-                }
-                _ <- IO.delay(statement.setString(1, project.name.toString))
-                _ <- IO.delay(statement.setObject(2, project.owner.uid))
-                _ <- IO.delay(statement.setBoolean(3, project.isPrivate))
-                _ <- IO.delay(
-                    project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr =>
-                        statement.setString(4, descr.toString)
-                    )
-                )
-                r <- IO.delay(statement.executeUpdate())
-                _ <- IO.delay(statement.close())
-            } 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
-      *   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 createTicketsUser(user: TicketsUser): 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, user.uid))
-                _ <- IO.delay(statement.setString(2, user.name.toString))
-                _ <- IO.delay(statement.setString(3, user.email.toString))
-                r <- IO.delay(statement.executeUpdate())
-                _ <- IO.delay(statement.close())
-            } yield r
-        }
-
-    /** Return the next ticket number for the given project.
-      *
-      * @param projectId
-      *   The internal database ID of the project.
-      * @return
-      *   The next ticket number.
-      */
-    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-    protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay(
-                    con.prepareStatement(
-                        """SELECT next_ticket_number FROM tickets.projects WHERE id = ?"""
-                    )
-                )
-                _      <- IO.delay(statement.setLong(1, projectId.toLong))
-                result <- IO.delay(statement.executeQuery)
-                number <- IO.delay {
-                    result.next()
-                    result.getInt("next_ticket_number")
-                }
-                _ <- IO(statement.close())
-            } yield number
-        }
-
-    /** Find the project ID for the given owner and project name.
-      *
-      * @param owner
-      *   The unique ID of the user account that owns the project.
-      * @param name
-      *   The project name which must be unique in regard to the owner.
-      * @return
-      *   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[ProjectId]] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay(
-                    con.prepareStatement(
-                        """SELECT id FROM tickets.projects WHERE owner = ? AND name = ? LIMIT 1"""
-                    )
-                )
-                _      <- IO.delay(statement.setObject(1, owner))
-                _      <- IO.delay(statement.setString(2, name.toString))
-                result <- IO.delay(statement.executeQuery)
-                projectId <- IO.delay {
-                    if (result.next()) {
-                        ProjectId.from(result.getLong("id"))
-                    } else {
-                        None
-                    }
-                }
-                _ <- IO(statement.close())
-            } yield projectId
-        }
-
-    /** 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)
-                ticketId <- IO.delay {
-                    if (result.next()) {
-                        TicketId.from(result.getLong("id"))
-                    } else {
-                        None
-                    }
-                }
-                _ <- IO(statement.close())
-            } yield ticketId
-        }
-
-    /** Find the ticket service user with the given user id.
-      *
-      * @param uid
-      *   The unique id of the user account.
-      * @return
-      *   An option to the loaded user.
-      */
-    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-    protected def loadTicketsUser(uid: UserId): IO[Option[TicketsUser]] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay(
-                    con.prepareStatement("""SELECT uid, name, email, language FROM tickets.users WHERE uid = ?""")
-                )
-                _      <- IO.delay(statement.setObject(1, uid.toUUID))
-                result <- IO.delay(statement.executeQuery())
-                user <- IO.delay {
-                    if (result.next()) {
-                        val language = LanguageCode.from(result.getString("language"))
-                        (
-                            uid.some,
-                            Username.from(result.getString("name")),
-                            EmailAddress.from(result.getString("email"))
-                        ).mapN { case (uid, name, email) =>
-                            TicketsUser(uid, name, email, language)
-                        }
-                    } else {
-                        None
-                    }
-                }
-            } yield user
-        }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,42 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class ColourCodeTest extends ScalaCheckSuite {
-    given Arbitrary[ColourCode] = Arbitrary(genColourCode)
-
-    property("ColourCode.from must fail on invalid input") {
-        forAll { (input: String) =>
-            assertEquals(ColourCode.from(input), None)
-        }
-    }
-
-    property("ColourCode.from must succeed on valid input") {
-        forAll { (colourCode: ColourCode) =>
-            val input = colourCode.toString
-            assertEquals(ColourCode.from(input), Option(colourCode))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	2024-05-18 22:05:18.505443612 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,65 +0,0 @@
-/*
- * 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.config
-
-import cats.effect.*
-import cats.syntax.all.*
-import de.smederee.TestTags.*
-import de.smederee.tickets.BaseSpec
-import org.flywaydb.core.Flyway
-
-final class DatabaseMigratorTest extends BaseSpec {
-    override def beforeEach(context: BeforeEach): Unit = {
-        val dbConfig = configuration.database
-        val flyway: Flyway =
-            DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
-        val _ = flyway.migrate()
-        val _ = flyway.clean()
-    }
-
-    override def afterEach(context: AfterEach): Unit = {
-        val dbConfig = configuration.database
-        val flyway: Flyway =
-            DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
-        val _ = flyway.migrate()
-        val _ = flyway.clean()
-    }
-
-    test("DatabaseMigrator must update available outdated database".tag(NeedsDatabase)) {
-        val dbConfig = configuration.database
-        val migrator = new DatabaseMigrator[IO]
-        val test     = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
-        test.map(result => assert(result.migrationsExecuted > 0))
-    }
-
-    test("DatabaseMigrator must not update an up to date database".tag(NeedsDatabase)) {
-        val dbConfig = configuration.database
-        val migrator = new DatabaseMigrator[IO]
-        val test = for {
-            _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
-            r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
-        } yield r
-        test.map(result => assert(result.migrationsExecuted === 0))
-    }
-
-    test("DatabaseMigrator must throw an exception if the database is not available".tag(NeedsDatabase)) {
-        val migrator = new DatabaseMigrator[IO]
-        val test     = migrator.migrate("jdbc:nodriver://", "", "")
-        test.attempt.map(r => assert(r.isLeft))
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	2024-05-18 22:05:18.505443612 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/config/SmedereeTicketsConfigurationTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,70 +0,0 @@
-/*
- * 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.config
-
-import com.typesafe.config.*
-import org.http4s.Uri
-import org.http4s.implicits.*
-import pureconfig.*
-
-import munit.*
-
-final class SmedereeTicketsConfigurationTest extends FunSuite {
-    val rawDefaultConfig = new Fixture[Config]("defaultConfig") {
-        def apply() = ConfigFactory.load(getClass.getClassLoader)
-    }
-
-    override def munitFixtures = List(rawDefaultConfig)
-
-    test("must load from the default configuration successfully") {
-        ConfigSource
-            .fromConfig(rawDefaultConfig())
-            .at(s"${SmedereeTicketsConfiguration.location.toString}")
-            .load[SmedereeTicketsConfiguration] match {
-                case Left(errors) => fail(errors.toList.mkString(", "))
-                case Right(_)     => assert(true)
-            }
-    }
-
-    test("default values for external linking must be setup for local development") {
-        ConfigSource
-            .fromConfig(rawDefaultConfig())
-            .at(s"${SmedereeTicketsConfiguration.location.toString}")
-            .load[SmedereeTicketsConfiguration] match {
-                case Left(errors) => fail(errors.toList.mkString(", "))
-                case Right(cfg) =>
-                    val externalCfg = cfg.externalUrl
-                    assertEquals(externalCfg.host, cfg.service.host)
-                    assertEquals(externalCfg.port, Option(cfg.service.port))
-                    assert(externalCfg.path.isEmpty)
-                    assertEquals(externalCfg.scheme, Uri.Scheme.http)
-            }
-    }
-
-    test("default values for hub service integration must be setup for local development") {
-        ConfigSource
-            .fromConfig(rawDefaultConfig())
-            .at(s"${SmedereeTicketsConfiguration.location.toString}")
-            .load[SmedereeTicketsConfiguration] match {
-                case Left(errors) => fail(errors.toList.mkString(", "))
-                case Right(cfg) =>
-                    val expectedUri = uri"http://localhost:8080"
-                    assertEquals(cfg.hub.baseUri, expectedUri)
-            }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,312 +0,0 @@
-/*
- * 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 cats.effect.*
-import cats.syntax.all.*
-import de.smederee.TestTags.*
-import de.smederee.tickets.Generators.*
-import doobie.*
-
-final class DoobieLabelRepositoryTest extends BaseSpec {
-
-    /** Find the label ID for the given project and label name.
-      *
-      * @param owner
-      *   The unique ID of the user account that owns the project.
-      * @param vcsRepoName
-      *   The project name which must be unique in regard to the owner.
-      * @param labelName
-      *   The label name which must be unique in the project context.
-      * @return
-      *   An option to the internal database ID.
-      */
-    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-    protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay(
-                    con.prepareStatement(
-                        """SELECT "labels".id
-              |FROM "tickets"."labels" AS "labels"
-              |JOIN "tickets"."projects" AS "projects"
-              |ON "labels".project = "projects".id
-              |WHERE "projects".owner = ?
-              |AND "projects".name = ?
-              |AND "labels".name = ?""".stripMargin
-                    )
-                )
-                _      <- IO.delay(statement.setObject(1, owner))
-                _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
-                _      <- IO.delay(statement.setString(3, labelName.toString))
-                result <- IO.delay(statement.executeQuery)
-                account <- IO.delay {
-                    if (result.next()) {
-                        Option(result.getLong("id"))
-                    } else {
-                        None
-                    }
-                }
-                _ <- IO(statement.close())
-            } yield account
-        }
-
-    test("allLabels must return all labels".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _         <- createProjectOwner(owner)
-                    _         <- createTicketsProject(project)
-                    projectId <- loadProjectId(owner.uid, project.name)
-                    createdLabels <- projectId match {
-                        case None            => IO.pure(List.empty)
-                        case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
-                    }
-                    foundLabels <- projectId match {
-                        case None            => IO.pure(List.empty)
-                        case Some(projectId) => labelRepo.allLabels(projectId).compile.toList
-                    }
-                } yield foundLabels
-                test.map { foundLabels =>
-                    assert(foundLabels.size === labels.size, "Different number of labels!")
-                    foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) =>
-                        assertEquals(found.copy(id = expected.id), expected)
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createLabel must create the label".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
-                } yield (createdProjects, projectId, createdLabels, foundLabel)
-                test.map { tuple =>
-                    val (createdProjects, projectId, createdLabels, foundLabel) = tuple
-                    assert(createdProjects === 1, "Test project was not created!")
-                    assert(projectId.nonEmpty, "No project id found!")
-                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
-                    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)
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createLabel must fail if the label name already exists".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                    _               <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                } yield (createdProjects, projectId, createdLabels)
-                test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!"))
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("deleteLabel must delete an existing label".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                    labelId         <- findLabelId(owner.uid, project.name, label.name)
-                    deletedLabels   <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from)))
-                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(label.name))
-                } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel)
-                test.map { tuple =>
-                    val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple
-                    assert(createdProjects === 1, "Test vcs project was not created!")
-                    assert(projectId.nonEmpty, "No vcs project id found!")
-                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
-                    assert(deletedLabels === 1, "Test label was not deleted!")
-                    assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("findLabel must find existing labels".tag(NeedsDatabase)) {
-        (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 expectedLabel = labels(scala.util.Random.nextInt(labels.size))
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels <- projectId match {
-                        case None            => IO.pure(List.empty)
-                        case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label))
-                    }
-                    foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name))
-                } yield foundLabel.flatten
-                test.map { foundLabel =>
-                    assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("updateLabel must update an existing label".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genLabel.sample) match {
-            case (Some(owner), Some(generatedProject), Some(label)) =>
-                val updatedLabel = label.copy(
-                    name = LabelName("updated label"),
-                    description = Option(LabelDescription("I am an updated label description...")),
-                    colour = ColourCode("#abcdef")
-                )
-                val project  = generatedProject.copy(owner = owner)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                    labelId         <- findLabelId(owner.uid, project.name, label.name)
-                    updatedLabels   <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply)))
-                    foundLabel      <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name))
-                } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten)
-                test.map { tuple =>
-                    val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple
-                    assert(createdProjects === 1, "Test vcs project was not created!")
-                    assert(projectId.nonEmpty, "No vcs project id found!")
-                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
-                    assert(updatedLabels === 1, "Test label was not updated!")
-                    assert(foundLabel.nonEmpty, "Updated label not found!")
-                    foundLabel.map { label =>
-                        assertEquals(label, updatedLabel.copy(id = label.id))
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("updateLabel must do nothing if id attribute is empty".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genLabel.sample) match {
-            case (Some(owner), Some(generatedProject), Some(label)) =>
-                val updatedLabel = label.copy(
-                    id = None,
-                    name = LabelName("updated label"),
-                    description = Option(LabelDescription("I am an updated label description...")),
-                    colour = ColourCode("#abcdef")
-                )
-                val project  = generatedProject.copy(owner = owner)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val labelRepo = new DoobieLabelRepository[IO](tx)
-                val test = for {
-                    _               <- createProjectOwner(owner)
-                    createdProjects <- createTicketsProject(project)
-                    projectId       <- loadProjectId(owner.uid, project.name)
-                    createdLabels   <- projectId.traverse(id => labelRepo.createLabel(id)(label))
-                    labelId         <- findLabelId(owner.uid, project.name, label.name)
-                    updatedLabels   <- labelRepo.updateLabel(updatedLabel)
-                } yield (createdProjects, projectId, createdLabels, updatedLabels)
-                test.map { tuple =>
-                    val (createdProjects, projectId, createdLabels, updatedLabels) = tuple
-                    assert(createdProjects === 1, "Test vcs project was not created!")
-                    assert(projectId.nonEmpty, "No vcs project id found!")
-                    assert(createdLabels.exists(_ === 1), "Test label was not created!")
-                    assert(updatedLabels === 0, "Label with empty id must not be updated!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,446 +0,0 @@
-/*
- * 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.*
-
-import cats.effect.*
-import cats.syntax.all.*
-import de.smederee.TestTags.*
-import de.smederee.tickets.Generators.*
-import doobie.*
-
-final class DoobieMilestoneRepositoryTest extends BaseSpec {
-
-    /** Find the milestone ID for the given repository and milestone title.
-      *
-      * @param owner
-      *   The unique ID of the user owner that owns the repository.
-      * @param projectName
-      *   The project name which must be unique in regard to the owner.
-      * @param title
-      *   The milestone title which must be unique in the repository context.
-      * @return
-      *   An option to the internal database ID.
-      */
-    @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-    protected def findMilestoneId(
-        owner: ProjectOwnerId,
-        projectName: ProjectName,
-        title: MilestoneTitle
-    ): IO[Option[Long]] =
-        connectToDb(configuration).use { con =>
-            for {
-                statement <- IO.delay(
-                    con.prepareStatement(
-                        """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?"""
-                    )
-                )
-                _      <- IO.delay(statement.setObject(1, owner))
-                _      <- IO.delay(statement.setString(2, projectName.toString))
-                _      <- IO.delay(statement.setString(3, title.toString))
-                result <- IO.delay(statement.executeQuery)
-                owner <- IO.delay {
-                    if (result.next()) {
-                        Option(result.getLong("id"))
-                    } else {
-                        None
-                    }
-                }
-                _ <- IO(statement.close())
-            } yield owner
-        }
-
-    test("allMilestones must return all milestones".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    createdRepos <- createTicketsProject(project)
-                    repoId       <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId match {
-                        case None => IO.pure(List.empty)
-                        case Some(repoId) =>
-                            milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
-                    }
-                    foundMilestones <- repoId match {
-                        case None         => IO.pure(List.empty)
-                        case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList
-                    }
-                } yield foundMilestones
-                test.map { foundMilestones =>
-                    assert(foundMilestones.size === milestones.size, "Different number of milestones!")
-                    foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) =>
-                        assertEquals(found.copy(id = expected.id), expected)
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("allTickets must return all tickets associated with the milestone".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genMilestone.sample, genTickets.sample) match {
-            case (Some(owner), Some(generatedProject), Some(milestone), Some(rawTickets)) =>
-                val project  = generatedProject.copy(owner = owner)
-                val tickets  = rawTickets.map(_.copy(submitter = None))
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val ticketRepo    = new DoobieTicketRepository[IO](tx)
-                val test = for {
-                    _      <- createProjectOwner(owner)
-                    _      <- createTicketsProject(project)
-                    repoId <- loadProjectId(owner.uid, project.name)
-                    foundTickets <- repoId match {
-                        case None => IO.pure(List.empty)
-                        case Some(projectId) =>
-                            for {
-                                _ <- milestoneRepo.createMilestone(projectId)(milestone)
-                                _ <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket))
-                                createdMilestone <- milestoneRepo.findMilestone(projectId)(milestone.title)
-                                _ <- createdMilestone match {
-                                    case None => IO.pure(List.empty)
-                                    case Some(milestone) =>
-                                        tickets.traverse(ticket =>
-                                            ticketRepo.addMilestone(projectId)(ticket.number)(milestone)
-                                        )
-                                }
-                                foundTickets <- createdMilestone.map(_.id).getOrElse(None) match {
-                                    case None              => IO.pure(List.empty)
-                                    case Some(milestoneId) => milestoneRepo.allTickets(None)(milestoneId).compile.toList
-                                }
-                            } yield foundTickets
-                    }
-                } yield foundTickets
-                test.map { foundTickets =>
-                    assertEquals(foundTickets.size, tickets.size, "Different number of tickets!")
-                    foundTickets.sortBy(_.number).zip(tickets.sortBy(_.number)).map { (found, expected) =>
-                        assertEquals(
-                            found.copy(createdAt = expected.createdAt, updatedAt = expected.updatedAt),
-                            expected
-                        )
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("closeMilestone must set the closed flag correctly".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
-            case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
-                val milestone = generatedMilestone.copy(closed = false)
-                val project   = generatedProject.copy(owner = owner)
-                val dbConfig  = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    createdRepos <- createTicketsProject(project)
-                    repoId       <- loadProjectId(owner.uid, project.name)
-                    milestones <- repoId match {
-                        case None => IO.pure((None, None))
-                        case Some(projectId) =>
-                            for {
-                                _      <- milestoneRepo.createMilestone(projectId)(milestone)
-                                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
-                                _      <- before.flatMap(_.id).traverse(milestoneRepo.closeMilestone)
-                                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
-                            } yield (before, after)
-                    }
-                } yield milestones
-                test.map { result =>
-                    val (before, after) = result
-                    val expected        = before.map(m => milestone.copy(id = m.id))
-                    assertEquals(before, expected, "Test milestone not properly initialised!")
-                    assertEquals(after, before.map(_.copy(closed = true)), "Test milestone not properly closed!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createMilestone must create the milestone".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _                 <- createProjectOwner(owner)
-                    createdRepos      <- createTicketsProject(project)
-                    repoId            <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                    foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
-                } yield (createdRepos, repoId, createdMilestones, foundMilestone)
-                test.map { tuple =>
-                    val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple
-                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
-                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
-                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-                    foundMilestone.getOrElse(None) match {
-                        case None => fail("Created milestone not found!")
-                        case Some(foundMilestone) =>
-                            assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id))
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createMilestone must fail if the milestone name already exists".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _                 <- createProjectOwner(owner)
-                    createdRepos      <- createTicketsProject(project)
-                    repoId            <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                    _                 <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                } yield (createdRepos, repoId, createdMilestones)
-                test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!"))
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("deleteMilestone must delete an existing milestone".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _                 <- createProjectOwner(owner)
-                    createdRepos      <- createTicketsProject(project)
-                    repoId            <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
-                    deletedMilestones <- milestoneRepo.deleteMilestone(
-                        milestone.copy(id = milestoneId.flatMap(MilestoneId.from))
-                    )
-                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
-                } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone)
-                test.map { tuple =>
-                    val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple
-                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
-                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
-                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-                    assert(deletedMilestones === 1, "Test milestone was not deleted!")
-                    assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("findMilestone must find existing milestones".tag(NeedsDatabase)) {
-        (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 expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    createdRepos <- createTicketsProject(project)
-                    repoId       <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId match {
-                        case None => IO.pure(List.empty)
-                        case Some(repoId) =>
-                            milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
-                    }
-                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title))
-                } yield foundMilestone.flatten
-                test.map { foundMilestone =>
-                    assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("openMilestone must reset the closed flag correctly".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
-            case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
-                val milestone = generatedMilestone.copy(closed = true)
-                val project   = generatedProject.copy(owner = owner)
-                val dbConfig  = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    createdRepos <- createTicketsProject(project)
-                    repoId       <- loadProjectId(owner.uid, project.name)
-                    milestones <- repoId match {
-                        case None => IO.pure((None, None))
-                        case Some(projectId) =>
-                            for {
-                                _      <- milestoneRepo.createMilestone(projectId)(milestone)
-                                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
-                                _      <- before.flatMap(_.id).traverse(milestoneRepo.openMilestone)
-                                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
-                            } yield (before, after)
-                    }
-                } yield milestones
-                test.map { result =>
-                    val (before, after) = result
-                    val expected        = before.map(m => milestone.copy(id = m.id))
-                    assertEquals(before, expected, "Test milestone not properly initialised!")
-                    assertEquals(after, before.map(_.copy(closed = false)), "Test milestone not properly opened!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("updateMilestone must update an existing milestone".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
-            case (Some(owner), Some(generatedProject), Some(milestone)) =>
-                val updatedMilestone = milestone.copy(
-                    title = MilestoneTitle("updated milestone"),
-                    description = Option(MilestoneDescription("I am an updated milestone description...")),
-                    dueDate = Option(LocalDate.of(1879, 3, 14))
-                )
-                val project  = generatedProject.copy(owner = owner)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _                 <- createProjectOwner(owner)
-                    createdRepos      <- createTicketsProject(project)
-                    repoId            <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
-                    updatedMilestones <- milestoneRepo.updateMilestone(
-                        updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply))
-                    )
-                    foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title))
-                } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten)
-                test.map { tuple =>
-                    val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple
-                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
-                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
-                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-                    assert(updatedMilestones === 1, "Test milestone was not updated!")
-                    assert(foundMilestone.nonEmpty, "Updated milestone not found!")
-                    foundMilestone.map { milestone =>
-                        assertEquals(milestone, updatedMilestone.copy(id = milestone.id))
-                    }
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("updateMilestone must do nothing if id attribute is empty".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
-            case (Some(owner), Some(generatedProject), Some(milestone)) =>
-                val updatedMilestone = milestone.copy(
-                    id = None,
-                    title = MilestoneTitle("updated milestone"),
-                    description = Option(MilestoneDescription("I am an updated milestone description...")),
-                    dueDate = Option(LocalDate.of(1879, 3, 14))
-                )
-                val project  = generatedProject.copy(owner = owner)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-                val test = for {
-                    _                 <- createProjectOwner(owner)
-                    createdRepos      <- createTicketsProject(project)
-                    repoId            <- loadProjectId(owner.uid, project.name)
-                    createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-                    milestoneId       <- findMilestoneId(owner.uid, project.name, milestone.title)
-                    updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone)
-                } yield (createdRepos, repoId, createdMilestones, updatedMilestones)
-                test.map { tuple =>
-                    val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple
-                    assert(createdRepos === 1, "Test vcs generatedProject was not created!")
-                    assert(repoId.nonEmpty, "No vcs generatedProject id found!")
-                    assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-                    assert(updatedMilestones === 0, "Milestone with empty id must not be updated!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,227 +0,0 @@
-/*
- * 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 cats.effect.*
-import cats.syntax.all.*
-import de.smederee.TestTags.*
-import de.smederee.tickets.Generators.*
-import doobie.*
-
-final class DoobieProjectRepositoryTest extends BaseSpec {
-    test("createProject must create a project".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    _            <- projectRepo.createProject(project)
-                    foundProject <- projectRepo.findProject(owner, project.name)
-                } yield foundProject
-                test.map { foundProject =>
-                    assertEquals(foundProject, Some(project))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("deleteProject must delete a project".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    _            <- createTicketsProject(project)
-                    deleted      <- projectRepo.deleteProject(project)
-                    foundProject <- projectRepo.findProject(owner, project.name)
-                } yield (deleted, foundProject)
-                test.map { result =>
-                    val (deleted, foundProject) = result
-                    assert(deleted > 0, "Rows not deleted from database!")
-                    assert(foundProject.isEmpty, "Project not deleted from database!")
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("findProject must return the matching project".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _ <- createProjectOwner(owner)
-                    _ <- createTicketsProject(project)
-                    _ <- projects
-                        .filterNot(_.name === project.name)
-                        .traverse(p => createTicketsProject(p.copy(owner = owner)))
-                    foundProject <- projectRepo.findProject(owner, project.name)
-                } yield foundProject
-                test.map { foundProject =>
-                    assertEquals(foundProject, Some(project))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("findProjectId must return the matching id".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _ <- createProjectOwner(owner)
-                    _ <- createTicketsProject(project)
-                    _ <- projects
-                        .filterNot(_.name === project.name)
-                        .traverse(p => createTicketsProject(p.copy(owner = owner)))
-                    foundProjectId <- projectRepo.findProjectId(owner, project.name)
-                    projectId      <- loadProjectId(owner.uid, project.name)
-                } yield (foundProjectId, projectId)
-                test.map { result =>
-                    val (foundProjectId, projectId) = result
-                    assertEquals(foundProjectId, projectId)
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("findProjectOwner must return the matching project owner".tag(NeedsDatabase)) {
-        genProjectOwners.sample match {
-            case Some(owner :: owners) =>
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _          <- createProjectOwner(owner)
-                    _          <- owners.filterNot(_.name === owner.name).traverse(createProjectOwner)
-                    foundOwner <- projectRepo.findProjectOwner(owner.name)
-                } yield foundOwner
-                test.map { foundOwner =>
-                    assert(foundOwner.exists(_ === owner))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("incrementNextTicketNumber must return and increment the old value".tag(NeedsDatabase)) {
-        (genProjectOwner.sample, genProject.sample) match {
-            case (Some(owner), Some(firstProject)) =>
-                val project  = firstProject.copy(owner = owner)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _         <- createProjectOwner(owner)
-                    _         <- projectRepo.createProject(project)
-                    projectId <- loadProjectId(owner.uid, project.name)
-                    result <- projectId match {
-                        case None => fail("Project was not created!")
-                        case Some(projectId) =>
-                            for {
-                                before <- loadNextTicketNumber(projectId)
-                                number <- projectRepo.incrementNextTicketNumber(projectId)
-                                after  <- loadNextTicketNumber(projectId)
-                            } yield (TicketNumber(before), number, TicketNumber(after))
-                    }
-                } yield result
-                test.map { result =>
-                    val (before, number, after) = result
-                    assertEquals(before, number)
-                    assertEquals(after, TicketNumber(number.toInt + 1))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("updateProject must update a project".tag(NeedsDatabase)) {
-        (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)
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val projectRepo = new DoobieProjectRepository[IO](tx)
-                val test = for {
-                    _            <- createProjectOwner(owner)
-                    _            <- projectRepo.createProject(project)
-                    written      <- projectRepo.updateProject(updatedProject)
-                    foundProject <- projectRepo.findProject(owner, project.name)
-                } yield (written, foundProject)
-                test.map { result =>
-                    val (written, foundProject) = result
-                    assert(written > 0, "Rows not updated in database!")
-                    assertEquals(foundProject, Some(updatedProject))
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,899 +0,0 @@
-/*
- * 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.TestTags.*
-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!")
-    @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while"))
-    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!")
-    @SuppressWarnings(Array("DisableSyntax.var", "DisableSyntax.while"))
-    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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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(filter = None)(projectId).compile.toList
-                            } yield (writtenTickets.sum, foundTickets)
-                    }
-                } yield result
-                test.map { result =>
-                    val (writtenTickets, foundTickets) = result
-                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
-                    assertEquals(
-                        foundTickets.size,
-                        writtenTickets,
-                        "Number of returned tickets differs from number of created tickets!"
-                    )
-                    assertEquals(
-                        foundTickets
-                            .sortBy(_.number)
-                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
-                        tickets.sortBy(_.number)
-                    )
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("allTickets must respect given filters for numbers".tag(NeedsDatabase)) {
-        (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 expectedTickets = tickets.take(tickets.size / 2)
-                val filter          = TicketFilter(number = expectedTickets.map(_.number), Nil, Nil, Nil)
-                val submitters      = tickets.map(_.submitter).flatten
-                val project         = generatedProject.copy(owner = owner)
-                val dbConfig        = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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(filter.some)(projectId).compile.toList
-                            } yield (writtenTickets.sum, foundTickets)
-                    }
-                } yield result
-                test.map { result =>
-                    val (writtenTickets, foundTickets) = result
-                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
-                    assertEquals(
-                        foundTickets
-                            .sortBy(_.number)
-                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
-                        expectedTickets.sortBy(_.number)
-                    )
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("allTickets must respect given filters for status".tag(NeedsDatabase)) {
-        (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 statusFlags     = tickets.map(_.status).distinct.take(2)
-                val expectedTickets = tickets.filter(t => statusFlags.exists(_ === t.status))
-                val filter          = TicketFilter(Nil, status = statusFlags, Nil, Nil)
-                val submitters      = tickets.map(_.submitter).flatten
-                val project         = generatedProject.copy(owner = owner)
-                val dbConfig        = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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(filter.some)(projectId).compile.toList
-                            } yield (writtenTickets.sum, foundTickets)
-                    }
-                } yield result
-                test.map { result =>
-                    val (writtenTickets, foundTickets) = result
-                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
-                    assertEquals(
-                        foundTickets
-                            .sortBy(_.number)
-                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
-                        expectedTickets.sortBy(_.number)
-                    )
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("allTickets must respect given filters for resolution".tag(NeedsDatabase)) {
-        (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 resolutions     = tickets.map(_.resolution).flatten.distinct.take(2)
-                val expectedTickets = tickets.filter(t => t.resolution.exists(r => resolutions.exists(_ === r)))
-                val filter          = TicketFilter(Nil, Nil, resolution = resolutions, Nil)
-                val submitters      = tickets.map(_.submitter).flatten
-                val project         = generatedProject.copy(owner = owner)
-                val dbConfig        = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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(filter.some)(projectId).compile.toList
-                            } yield (writtenTickets.sum, foundTickets)
-                    }
-                } yield result
-                test.map { result =>
-                    val (writtenTickets, foundTickets) = result
-                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
-                    assertEquals(
-                        foundTickets
-                            .sortBy(_.number)
-                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
-                        expectedTickets.sortBy(_.number)
-                    )
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("allTickets must respect given filters for submitter".tag(NeedsDatabase)) {
-        (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 wantedSubmitters = submitters.take(submitters.size / 2)
-                val expectedTickets  = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s)))
-                val filter           = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters.map(_.name))
-                val project          = generatedProject.copy(owner = owner)
-                val dbConfig         = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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(filter.some)(projectId).compile.toList
-                            } yield (writtenTickets.sum, foundTickets)
-                    }
-                } yield result
-                test.map { result =>
-                    val (writtenTickets, foundTickets) = result
-                    assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!")
-                    assertEquals(
-                        foundTickets
-                            .sortBy(_.number)
-                            .map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)),
-                        expectedTickets.sortBy(_.number)
-                    )
-                }
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createTicket must save the ticket to the database".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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".tag(NeedsDatabase)) {
-        (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](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                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/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,107 +0,0 @@
-/*
- * 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 cats.effect.*
-import de.smederee.TestTags.*
-import de.smederee.tickets.Generators.*
-import doobie.*
-
-final class DoobieTicketServiceApiTest extends BaseSpec {
-    test("createOrUpdateUser must create new users".tag(NeedsDatabase)) {
-        genTicketsUser.sample match {
-            case Some(user) =>
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val api = new DoobieTicketServiceApi[IO](tx)
-                val test = for {
-                    written   <- api.createOrUpdateUser(user)
-                    foundUser <- loadTicketsUser(user.uid)
-                } yield (written, foundUser)
-                test.map { result =>
-                    val (written, foundUser) = result
-                    assert(written > 0, "No rows written to database!")
-                    assertEquals(foundUser, Some(user))
-                }
-
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("createOrUpdateUser must update existing users".tag(NeedsDatabase)) {
-        (genTicketsUser.sample, genTicketsUser.sample) match {
-            case (Some(user), Some(anotherUser)) =>
-                val updatedUser = anotherUser.copy(uid = user.uid)
-                val dbConfig    = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val api = new DoobieTicketServiceApi[IO](tx)
-                val test = for {
-                    created   <- api.createOrUpdateUser(user)
-                    updated   <- api.createOrUpdateUser(updatedUser)
-                    foundUser <- loadTicketsUser(user.uid)
-                } yield (created, updated, foundUser)
-                test.map { result =>
-                    val (created, updated, foundUser) = result
-                    assert(created > 0, "No rows written to database!")
-                    assert(updated > 0, "No rows updated in database!")
-                    assertEquals(foundUser, Some(updatedUser))
-                }
-
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-
-    test("deleteUser must delete existing users".tag(NeedsDatabase)) {
-        genTicketsUser.sample match {
-            case Some(user) =>
-                val dbConfig = configuration.database
-                val tx = Transactor.fromDriverManager[IO](
-                    driver = dbConfig.driver,
-                    url = dbConfig.url,
-                    user = dbConfig.user,
-                    password = dbConfig.pass,
-                    logHandler = None
-                )
-                val api = new DoobieTicketServiceApi[IO](tx)
-                val test = for {
-                    _         <- api.createOrUpdateUser(user)
-                    deleted   <- api.deleteUser(user.uid)
-                    foundUser <- loadTicketsUser(user.uid)
-                } yield (deleted, foundUser)
-                test.map { result =>
-                    val (deleted, foundUser) = result
-                    assert(deleted > 0, "No rows deleted from database!")
-                    assert(foundUser.isEmpty, "User not deleted from database!")
-                }
-
-            case _ => fail("Could not generate data samples!")
-        }
-    }
-}
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	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,290 +0,0 @@
-/*
- * 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.*
-import java.util.Locale
-import java.util.UUID
-
-import cats.*
-import cats.syntax.all.*
-import de.smederee.email.EmailAddress
-import de.smederee.i18n.LanguageCode
-import de.smederee.security.*
-
-import org.scalacheck.Arbitrary
-import org.scalacheck.Gen
-
-import scala.jdk.CollectionConverters.*
-
-object Generators {
-    val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
-    val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
-
-    /** Prepend a zero to a single character hexadecimal code.
-      *
-      * @param hexCode
-      *   A string supposed to contain a hexadecimal code between 0 and ff.
-      * @return
-      *   Either the given code prepended with a leading zero if it had only a single character or the originally given
-      *   code otherwise.
-      */
-    private def hexPadding(hexCode: String): String =
-        if (hexCode.length < 2)
-            "0" + hexCode
-        else
-            hexCode
-
-    val genLocalDate: Gen[LocalDate] =
-        for {
-            year  <- Gen.choose(MinimumYear, MaximumYear)
-            month <- Gen.choose(1, 12)
-            day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
-        } yield LocalDate.of(year, month, day)
-
-    given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
-
-    val genOffsetDateTime: Gen[OffsetDateTime] =
-        for {
-            year       <- Gen.choose(MinimumYear, MaximumYear)
-            month      <- Gen.choose(1, 12)
-            day        <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
-            hour       <- Gen.choose(0, 23)
-            minute     <- Gen.choose(0, 59)
-            second     <- Gen.choose(0, 59)
-            nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading.
-            offset <- Gen.oneOf(
-                ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset)
-            )
-        } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset)
-
-    given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime)
-
-    val genLocale: Gen[Locale]             = Gen.oneOf(Locale.getAvailableLocales.toList)
-    val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply)
-
-    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 genUsername: Gen[Username] = for {
-        length <- Gen.choose(2, 30)
-        prefix <- Gen.alphaChar
-        chars <- Gen
-            .nonEmptyListOf(Gen.alphaNumChar)
-            .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
-    } yield Username(prefix.toString.toLowerCase(Locale.ROOT) + chars)
-
-    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 genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from)
-
-    val genTicketStatus: Gen[TicketStatus]           = Gen.oneOf(TicketStatus.values.toList)
-    val genTicketStatusList: Gen[List[TicketStatus]] = Gen.nonEmptyListOf(genTicketStatus).map(_.distinct)
-
-    val genTicketResolution: Gen[TicketResolution]        = Gen.oneOf(TicketResolution.values.toList)
-    val genTicketResolutions: Gen[List[TicketResolution]] = Gen.nonEmptyListOf(genTicketResolution).map(_.distinct)
-
-    val genTicketNumber: Gen[TicketNumber]        = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply)
-    val genTicketNumbers: Gen[List[TicketNumber]] = Gen.nonEmptyListOf(genTicketNumber).map(_.distinct)
-
-    val genTicketTitle: Gen[TicketTitle] =
-        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply)
-
-    val genTicketsUser: Gen[TicketsUser] = for {
-        uid      <- genUserId
-        name     <- genUsername
-        email    <- genEmailAddress
-        language <- Gen.option(genLanguageCode)
-    } yield TicketsUser(uid, name, email, language)
-
-    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 genTicketFilter: Gen[TicketFilter] =
-        for {
-            number     <- Gen.listOf(genTicketNumber)
-            status     <- Gen.listOf(genTicketStatus)
-            resolution <- Gen.listOf(genTicketResolution)
-            submitter  <- Gen.listOf(genSubmitter)
-        } yield TicketFilter(number, status, resolution, submitter.map(_.name).distinct)
-
-    val genProjectOwnerName: Gen[ProjectOwnerName] = for {
-        length <- Gen.choose(2, 30)
-        prefix <- Gen.alphaChar
-        chars <- Gen
-            .nonEmptyListOf(Gen.alphaNumChar)
-            .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
-    } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
-
-    val genProjectOwner: Gen[ProjectOwner] = for {
-        id    <- genProjectOwnerId
-        name  <- genProjectOwnerName
-        email <- genEmailAddress
-    } yield ProjectOwner(uid = id, name = name, email = email)
-
-    given Arbitrary[ProjectOwner] = Arbitrary(genProjectOwner)
-
-    val genProjectOwners: Gen[List[ProjectOwner]] = Gen
-        .nonEmptyListOf(genProjectOwner)
-        .map(_.foldLeft(List.empty[ProjectOwner]) { (acc, a) =>
-            if (acc.exists(_.name === a.name))
-                acc
-            else
-                a :: acc
-        }) // Ensure distinct user names.
-
-    val genLabelName: Gen[LabelName] =
-        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
-
-    val genLabelDescription: Gen[LabelDescription] =
-        Gen.nonEmptyListOf(Gen.alphaNumChar)
-            .map(_.take(LabelDescription.MaxLength).mkString)
-            .map(LabelDescription.apply)
-
-    val genColourCode: Gen[ColourCode] = for {
-        red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-        green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-        blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-        hexString = s"#$red$green$blue"
-    } yield ColourCode(hexString)
-
-    val genLabel: Gen[Label] = for {
-        id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
-        name        <- genLabelName
-        description <- Gen.option(genLabelDescription)
-        colour      <- genColourCode
-    } yield Label(id, name, description, colour)
-
-    val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
-
-    val genMilestoneTitle: Gen[MilestoneTitle] =
-        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
-
-    val genMilestoneDescription: Gen[MilestoneDescription] =
-        Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
-
-    val genMilestone: Gen[Milestone] =
-        for {
-            id     <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
-            title  <- genMilestoneTitle
-            due    <- Gen.option(genLocalDate)
-            descr  <- Gen.option(genMilestoneDescription)
-            closed <- Gen.oneOf(List(false, true))
-        } yield Milestone(id, title, descr, due, closed)
-
-    val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
-
-    val genProjectName: Gen[ProjectName] = Gen
-        .nonEmptyListOf(
-            Gen.oneOf(
-                List(
-                    "a",
-                    "b",
-                    "c",
-                    "d",
-                    "e",
-                    "f",
-                    "g",
-                    "h",
-                    "i",
-                    "j",
-                    "k",
-                    "l",
-                    "m",
-                    "n",
-                    "o",
-                    "p",
-                    "q",
-                    "r",
-                    "s",
-                    "t",
-                    "u",
-                    "v",
-                    "w",
-                    "x",
-                    "y",
-                    "z",
-                    "0",
-                    "1",
-                    "2",
-                    "3",
-                    "4",
-                    "5",
-                    "6",
-                    "7",
-                    "8",
-                    "9",
-                    "-",
-                    "_"
-                )
-            )
-        )
-        .map(cs => ProjectName(cs.take(64).mkString))
-
-    val genProjectDescription: Gen[Option[ProjectDescription]] =
-        Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from)
-
-    val genProject: Gen[Project] =
-        for {
-            name        <- genProjectName
-            description <- genProjectDescription
-            owner       <- genProjectOwner
-            isPrivate   <- Gen.oneOf(List(false, true))
-        } yield Project(owner, name, description, isPrivate)
-
-    val genProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genProject)
-
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,47 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class LabelDescriptionTest extends ScalaCheckSuite {
-    given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription)
-
-    test("LabelDescription.from must fail on empty input") {
-        assertEquals(LabelDescription.from(""), None)
-    }
-
-    property("LabelDescription.from must fail on too long input") {
-        forAll { (input: String) =>
-            if (input.length > LabelDescription.MaxLength)
-                assertEquals(LabelDescription.from(input), None)
-        }
-    }
-
-    property("LabelDescription.from must succeed on valid input") {
-        forAll { (label: LabelDescription) =>
-            val input = label.toString
-            assertEquals(LabelDescription.from(input), Option(label))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,47 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class LabelNameTest extends ScalaCheckSuite {
-    given Arbitrary[LabelName] = Arbitrary(genLabelName)
-
-    test("LabelName.from must fail on empty input") {
-        assertEquals(LabelName.from(""), None)
-    }
-
-    property("LabelName.from must fail on too long input") {
-        forAll { (input: String) =>
-            if (input.length > LabelName.MaxLength)
-                assertEquals(LabelName.from(input), None)
-        }
-    }
-
-    property("LabelName.from must succeed on valid input") {
-        forAll { (label: LabelName) =>
-            val input = label.toString
-            assertEquals(LabelName.from(input), Option(label))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,36 +0,0 @@
-/*
- * 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 cats.syntax.all.*
-import de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class LabelTest extends ScalaCheckSuite {
-    given Arbitrary[Label] = Arbitrary(genLabel)
-
-    property("Eq must hold") {
-        forAll { (label: Label) =>
-            assert(label === label, "Identical labels must be considered equal!")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,40 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class MilestoneDescriptionTest extends ScalaCheckSuite {
-    given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription)
-
-    test("MilestoneDescription.from must fail on empty input") {
-        assertEquals(MilestoneDescription.from(""), None)
-    }
-
-    property("MilestoneDescription.from must succeed on valid input") {
-        forAll { (label: MilestoneDescription) =>
-            val input = label.toString
-            assertEquals(MilestoneDescription.from(input), Option(label))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,36 +0,0 @@
-/*
- * 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 cats.syntax.all.*
-import de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class MilestoneTest extends ScalaCheckSuite {
-    given Arbitrary[Milestone] = Arbitrary(genMilestone)
-
-    property("Eq must hold") {
-        forAll { (label: Milestone) =>
-            assert(label === label, "Identical milestones must be considered equal!")
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,47 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class MilestoneTitleTest extends ScalaCheckSuite {
-    given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle)
-
-    test("MilestoneTitle.from must fail on empty input") {
-        assertEquals(MilestoneTitle.from(""), None)
-    }
-
-    property("MilestoneTitle.from must fail on too long input") {
-        forAll { (input: String) =>
-            if (input.length > MilestoneTitle.MaxLength)
-                assertEquals(MilestoneTitle.from(input), None)
-        }
-    }
-
-    property("MilestoneTitle.from must succeed on valid input") {
-        forAll { (label: MilestoneTitle) =>
-            val input = label.toString
-            assertEquals(MilestoneTitle.from(input), Option(label))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,36 +0,0 @@
-/*
- * 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 munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketContentTest extends ScalaCheckSuite {
-
-    property("TicketContent.from must only accept valid input") {
-        forAll { (input: String) =>
-            if (input.nonEmpty)
-                assertEquals(TicketContent.from(input), Some(TicketContent(input)))
-            else
-                assertEquals(TicketContent.from(input), None)
-        }
-    }
-
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,136 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketFilterTest extends ScalaCheckSuite {
-    given Arbitrary[Submitter]        = Arbitrary(genSubmitter)
-    given Arbitrary[TicketFilter]     = Arbitrary(genTicketFilter)
-    given Arbitrary[TicketNumber]     = Arbitrary(genTicketNumber)
-    given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution)
-    given Arbitrary[TicketStatus]     = Arbitrary(genTicketStatus)
-
-    property("fromQueryParameter must produce empty filters for invalid input") {
-        forAll { (randomInput: String) =>
-            assertEquals(
-                TicketFilter.fromQueryParameter(randomInput),
-                TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = Nil)
-            )
-        }
-    }
-
-    property("fromQueryParameter must work for numbers only") {
-        forAll { (numbers: List[TicketNumber]) =>
-            val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil)
-            assertEquals(TicketFilter.fromQueryParameter(s"numbers: ${numbers.map(_.toString).mkString(",")}"), filter)
-        }
-    }
-
-    property("fromQueryParameter must work for status only") {
-        forAll { (status: List[TicketStatus]) =>
-            val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil)
-            assertEquals(TicketFilter.fromQueryParameter(s"status: ${status.map(_.toString).mkString(",")}"), filter)
-        }
-    }
-
-    property("fromQueryParameter must work for resolution only") {
-        forAll { (resolution: List[TicketResolution]) =>
-            val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil)
-            assertEquals(
-                TicketFilter.fromQueryParameter(s"resolution: ${resolution.map(_.toString).mkString(",")}"),
-                filter
-            )
-        }
-    }
-
-    property("fromQueryParameter must work for submitter only") {
-        forAll { (submitters: List[Submitter]) =>
-            if (submitters.nonEmpty) {
-                val filter =
-                    TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name))
-                assertEquals(
-                    TicketFilter.fromQueryParameter(s"by: ${submitters.map(_.name.toString).mkString(",")}"),
-                    filter
-                )
-            }
-        }
-    }
-
-    property("toQueryParameter must include numbers") {
-        forAll { (numbers: List[TicketNumber]) =>
-            if (numbers.nonEmpty) {
-                val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil)
-                assert(
-                    TicketFilter.toQueryParameter(filter).contains(s"numbers: ${numbers.map(_.toString).mkString(",")}")
-                )
-            }
-        }
-    }
-
-    property("toQueryParameter must include status") {
-        forAll { (status: List[TicketStatus]) =>
-            if (status.nonEmpty) {
-                val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil)
-                assert(
-                    TicketFilter.toQueryParameter(filter).contains(s"status: ${status.map(_.toString).mkString(",")}")
-                )
-            }
-        }
-    }
-
-    property("toQueryParameter must include resolution") {
-        forAll { (resolution: List[TicketResolution]) =>
-            if (resolution.nonEmpty) {
-                val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil)
-                assert(
-                    TicketFilter
-                        .toQueryParameter(filter)
-                        .contains(s"resolution: ${resolution.map(_.toString).mkString(",")}"),
-                    TicketFilter.toQueryParameter(filter)
-                )
-            }
-        }
-    }
-
-    property("toQueryParameter must include submitter") {
-        forAll { (submitters: List[Submitter]) =>
-            if (submitters.nonEmpty) {
-                val filter =
-                    TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name))
-                assert(
-                    TicketFilter
-                        .toQueryParameter(filter)
-                        .contains(s"by: ${submitters.map(_.name.toString).mkString(",")}"),
-                    TicketFilter.toQueryParameter(filter)
-                )
-            }
-        }
-    }
-
-    property("toQueryParameter must be the dual of fromQueryParameter") {
-        forAll { (filter: TicketFilter) =>
-            assertEquals(TicketFilter.fromQueryParameter(filter.toQueryParameter), filter)
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,36 +0,0 @@
-/*
- * 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 munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketNumberTest extends ScalaCheckSuite {
-
-    property("TicketNumber.from must only accept valid input") {
-        forAll { (integer: Int) =>
-            if (integer > 0)
-                assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer)))
-            else
-                assertEquals(TicketNumber.from(integer), None)
-        }
-    }
-
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketResolutionTest extends ScalaCheckSuite {
-    given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution)
-
-    property("valueOf must work for all known instances") {
-        forAll { (status: TicketResolution) =>
-            assertEquals(TicketResolution.valueOf(status.toString), status)
-        }
-    }
-
-    property("fromString must work for all known instances") {
-        forAll { (status: TicketResolution) =>
-            assertEquals(TicketResolution.fromString(status.toString), Option(status))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala	2024-05-18 22:05:18.501443655 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-/*
- * 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 de.smederee.tickets.Generators.*
-
-import munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketStatusTest extends ScalaCheckSuite {
-    given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus)
-
-    property("valueOf must work for all known instances") {
-        forAll { (status: TicketStatus) =>
-            assertEquals(TicketStatus.valueOf(status.toString), status)
-        }
-    }
-
-    property("fromString must work for all known instances") {
-        forAll { (status: TicketStatus) =>
-            assertEquals(TicketStatus.fromString(status.toString), Option(status))
-        }
-    }
-}
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	2024-05-18 22:05:18.505443612 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,36 +0,0 @@
-/*
- * 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 munit.*
-
-import org.scalacheck.*
-import org.scalacheck.Prop.*
-
-final class TicketTitleTest extends ScalaCheckSuite {
-
-    property("TicketTitle.from must only accept valid input") {
-        forAll { (input: String) =>
-            if (input.nonEmpty && input.length <= TicketTitle.MaxLength)
-                assertEquals(TicketTitle.from(input), Some(TicketTitle(input)))
-            else
-                assertEquals(TicketTitle.from(input), None)
-        }
-    }
-
-}
diff -rN -u old-smederee/README.md new-smederee/README.md
--- old-smederee/README.md	2024-05-18 22:05:18.493443739 +0000
+++ new-smederee/README.md	2024-05-18 22:05:18.505443612 +0000
@@ -91,12 +91,16 @@
 
 ### Creating the configuration file ###
 
-The easiest way is to simply concatenate the both default configurations for
-the hub and ticket module:
+The easiest way is to use the provided sample configuration file:
 
 ```
-% cat modules/hub/src/main/resources/reference.conf \
-    modules/tickets/src/main/resources/reference.conf > application.conf
+% cat application.conf.sample > application.conf
+```
+
+Another option is to copy the default configuration:
+
+```
+% cat modules/hub/src/main/resources/reference.conf > application.conf
 ```
 
 ### Important configuration settings ###
@@ -106,18 +110,20 @@
 values.
 
 Secondly take a look at the `external` configuration settings for _both_
-services and configure them accordingly to be able to reach them depending
-on your setup. We assume that you will be running them behind a reverse
-proxy which also does take care of encryption (https). Depending on your
-setup you can either decide to run both endpoints under one domain or on
+hub _and_ tickets and configure them accordingly to be able to reach them
+depending on your setup. We assume that you will be running them behind a
+reverse proxy which also does take care of encryption (https). Depending on
+your setup you can either decide to run both endpoints under one domain or on
 different subdomains e.g. hub on "example.com" and tickets on
 "tickets.example.com".
+The default case is that the tickets simply use the configuration from the hub
+section which means you only have to care for the setup in the hub section.
 
-The hub service has a `ticket-integration` section into which you have to
+The hub section has a `ticket-integration` section into which you have to
 enter the base uri under which the ticket endpoints are reachable. The
-ticket service has a `hub-integration` section with the analogue settings.
+ticket section has a `hub-integration` section with the analogue settings.
 
-Both services have a `cookie-secret` setting that MUST be set to a randomly
+The hub section has a `cookie-secret` setting that MUST be set to a randomly
 generated sequence of at least 64 alphanumeric characters.
 
 Next you might want to check the pre-configured paths and make adjustments