~jan0sch/smederee

Showing details for patch f2925545a468ee68878007aebeb472bb107eff4b.
2023-04-04 (Tue), 6:39 PM - Jens Grassel - f2925545a468ee68878007aebeb472bb107eff4b

Tickets: Re-integrate moved labels and milestones functionality.

- re-wire server code to re-enable labels and milestones
- url configuration for the ticket service has to match hub one for now
   - otherwise links won't be created correctly
- several adjustments and a bit of cleanup
Summary of changes
5 files added
  • modules/hub/src/main/scala/de/smederee/hub/RelatedTypesConverter.scala
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
  • modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala
20 files modified with 436 lines added and 106 lines removed
  • build.sbt with 1 added and 1 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 4 added and 3 removed lines
  • modules/hub/src/main/resources/messages.properties with 56 added and 24 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 1 added and 9 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 9 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 5 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 26 added and 8 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 4 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 10 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala with 13 added and 5 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 12 added and 4 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 4 added and 3 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala with 35 added and 3 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala with 64 added and 0 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala with 22 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala with 24 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Project.scala with 1 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala with 28 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala with 5 added and 2 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala with 112 added and 33 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/build.sbt	2025-01-31 02:34:56.704822746 +0000
@@ -318,7 +318,7 @@
 lazy val tickets =
   project
     .in(file("modules/tickets"))
-    .dependsOn(email, htmlUtils, security)
+    .dependsOn(email, htmlUtils, i18n, security)
     .enablePlugins(AutomateHeaderPlugin)
     .configs(IntegrationTest)
     .settings(commonSettings)
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-01-31 02:34:56.704822746 +0000
@@ -181,9 +181,10 @@
     .map(cs => VcsRepositoryName(cs.take(64).mkString))
 
   val genValidVcsRepositoryOwner = for {
-    uid  <- genUserId
-    name <- genValidUsername
-  } yield VcsRepositoryOwner(uid, name)
+    uid   <- genUserId
+    name  <- genValidUsername
+    email <- genValidEmail
+  } yield VcsRepositoryOwner(uid, name, email)
 
   val genValidVcsType = Gen.oneOf(VcsType.values.toList)
 
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-31 02:34:56.704822746 +0000
@@ -52,35 +52,11 @@
 form.fork.button.submit=Clone to your account.
 form.fork.button.submit.not-validated=Please validate your account first, only validated users can create repositories.
 form.fork.help=Please remember that darcs has a different nomenclature than for example git. Cloning this repository to your account will create a branch (equal to a git fork).
-form.label.create.button.submit=Create label
-form.label.colour=Colour
-form.label.colour.help=Pick a colour which will be used as background colour for the label.
-form.label.description=Description
-form.label.description.help=The description is optional and may contain up to 254 characters.
-form.label.description.placeholder=description
-form.label.name=Name
-form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project.
-form.label.name.placeholder=label name
-form.label.edit.button.submit=Save label
-form.label.delete.button.submit=Delete
-form.label.delete.i-am-sure=Yes, I'm sure!
 form.login.button.submit=Login
 form.login.password=Password
 form.login.password.placeholder=Please enter your password here.
 form.login.username=Username
 form.login.username.placeholder=Please enter your username.
-form.milestone.create.button.submit=Create milestone
-form.milestone.due-date=Due date
-form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached.
-form.milestone.description=Description
-form.milestone.description.help=An optional description of the milestone.
-form.milestone.description.placeholder=description
-form.milestone.title=Title
-form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
-form.milestone.title.placeholder=milestone title
-form.milestone.edit.button.submit=Save milestone
-form.milestone.delete.button.submit=Delete
-form.milestone.delete.i-am-sure=Yes, I'm sure!
 form.signup.button.submit=Sign up for an account
 form.signup.email=Email address
 form.signup.email.help=Please enter your email address.
@@ -269,3 +245,59 @@
 user.settings.ssh.list.empty=You haven''t uploaded any ssh keys yet.
 user.settings.ssh.title=SSH-Keys
 user.settings.title=Settings
+
+### Ticket service (to be moved to separate file)
+# Smederee - Tickets
+# Resource bundle for translations.
+#
+# *** FILE ENCODING MUST BE UTF-8! ***
+#
+# File structure
+# ==============
+# 1. Translation keys are supposed to be grouped by semantic topic (e.g. 
+#    error.foo.bar or ui.button.logout).
+# 2. Grouping continues downward if it makes sense, e.g.
+#    error.forbidden.title, error.forbidden.message.
+#
+form.label.colour.help=Pick a colour which will be used as background colour for the label.
+form.label.colour=Colour
+form.label.create.button.submit=Create label
+form.label.delete.button.submit=Delete
+form.label.delete.i-am-sure=Yes, I'm sure!
+form.label.description.help=The description is optional and may contain up to 254 characters.
+form.label.description.placeholder=description
+form.label.description=Description
+form.label.edit.button.submit=Save label
+form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project.
+form.label.name.placeholder=label name
+form.label.name=Name
+form.milestone.create.button.submit=Create milestone
+form.milestone.delete.button.submit=Delete
+form.milestone.delete.i-am-sure=Yes, I'm sure!
+form.milestone.description.help=An optional description of the milestone.
+form.milestone.description.placeholder=description
+form.milestone.description=Description
+form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached.
+form.milestone.due-date=Due date
+form.milestone.edit.button.submit=Save milestone
+form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
+form.milestone.title.placeholder=milestone title
+form.milestone.title=Title
+
+project.label.edit.link=Edit
+project.label.edit.title=Edit label ''{0}''.
+project.labels.add.title=Add a new label.
+project.labels.edit.title=Edit project labels.
+project.labels.list.empty=There are no labels defined for this project.
+project.labels.list.title={0} labels found.
+project.labels.view.title=Labels
+project.menu.labels=Labels
+project.menu.milestones=Milestones
+project.menu.overview=Overview
+project.milestone.edit.link=Edit
+project.milestone.edit.title=Edit milestone ''{0}''.
+project.milestones.add.title=Add a new milestone.
+project.milestones.edit.title=Edit project milestones.
+project.milestones.list.empty=There are no milestones defined for this project.
+project.milestones.list.title={0} milestones found.
+project.milestones.view.title=Milestones
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	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-01-31 02:34:56.708822751 +0000
@@ -34,6 +34,7 @@
 import de.smederee.i18n.LanguageCode
 import de.smederee.security._
 import de.smederee.ssh._
+import de.smederee.tickets.{ TicketServiceApi, TicketsUser }
 import org.http4s._
 import org.http4s.dsl._
 import org.http4s.headers.Location
@@ -54,6 +55,8 @@
   *   Middleware layer needed to send emails.
   * @param signAndValidate
   *   A class providing functions to handle session token signing and validation.
+  * @param ticketServiceApi
+  *   The ticket service API which is needed to synchronise changes for the account.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
@@ -61,7 +64,8 @@
     accountManagementRepo: AccountManagementRepository[F],
     configuration: ServiceConfig,
     emailMiddleware: EmailMiddleware[F],
-    signAndValidate: SignAndValidate
+    signAndValidate: SignAndValidate,
+    ticketServiceApi: TicketServiceApi[F]
 ) extends Http4sDsl[F] {
   private val log = LoggerFactory.getLogger(getClass)
 
@@ -254,9 +258,9 @@
                     .get(configuration.darcs.repositoriesDirectory.toPath.toString, user.name.toString)
                 )
                 _ <- deleteUserDirectory(userDir)
-                response <- accountManagementRepo.deleteAccount(user.uid) *> SeeOther(Location(rootUri)).map(
-                  _.removeCookie(Constants.authenticationCookieName.toString)
-                )
+                response <- ticketServiceApi.deleteUser(user.uid) *> accountManagementRepo.deleteAccount(
+                  user.uid
+                ) *> SeeOther(Location(rootUri)).map(_.removeCookie(Constants.authenticationCookieName.toString))
               } yield response
             } else
               BadRequest(
@@ -351,6 +355,7 @@
             configuration.external.createFullUri(uri"user/settings/email/validate")
           )
           _    <- accountManagementRepo.setLanguage(user.uid, languageCode)
+          _    <- ticketServiceApi.createOrUpdateUser(TicketsUser(user.uid, user.name, user.email, languageCode))
           resp <- SeeOther(Location(actionBaseUri))
         } yield resp
       }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 02:34:56.704822746 +0000
@@ -24,7 +24,6 @@
 import de.smederee.email.EmailAddress
 import de.smederee.i18n.LanguageCode
 import de.smederee.security._
-import de.smederee.tickets.ProjectOwner
 import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
 
 import scala.util.matching.Regex
@@ -225,18 +224,11 @@
 
   extension (account: Account) {
 
-    /** Create project owner metadata from the account.
-      *
-      * @return
-      *   Descriptive information about the owner of a project based on the account.
-      */
-    def toProjectOwner: ProjectOwner = ProjectOwner(account.uid, account.name, account.email)
-
     /** Create vcs repository owner metadata from the account.
       *
       * @return
       *   Descriptive information about the owner of a vcs repository based on the account.
       */
-    def toVcsRepositoryOwner: VcsRepositoryOwner = VcsRepositoryOwner(account.uid, account.name)
+    def toVcsRepositoryOwner: VcsRepositoryOwner = VcsRepositoryOwner(account.uid, account.name, account.email)
   }
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 02:34:56.708822751 +0000
@@ -21,10 +21,11 @@
 
 import cats.effect._
 import cats.syntax.all._
+import de.smederee.email.EmailAddress
 import de.smederee.hub.VcsMetadataRepositoriesOrdering._
 import de.smederee.security.{ UserId, Username }
-import doobie._
 import doobie.Fragments._
+import doobie._
 import doobie.implicits._
 import doobie.postgres.implicits._
 import fs2.Stream
@@ -32,6 +33,7 @@
 
 final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] {
 
+  given Meta[EmailAddress]             = Meta[String].timap(EmailAddress.apply)(_.toString)
   given Meta[VcsRepositoryName]        = Meta[String].timap(VcsRepositoryName.apply)(_.toString)
   given Meta[VcsRepositoryDescription] = Meta[String].timap(VcsRepositoryDescription.apply)(_.toString)
   given Meta[VcsType]                  = Meta[String].timap(VcsType.valueOf)(_.toString)
@@ -44,6 +46,7 @@
           "repos".name AS name,
           "accounts".uid AS owner_id,
           "accounts".name AS owner_name,
+          "accounts".email AS owner_email,
           "repos".is_private AS is_private,
           "repos".description AS description,
           "repos".vcs_type AS vcs_type,
@@ -94,7 +97,7 @@
   }
 
   override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] =
-    sql"""SELECT uid, name FROM "hub"."accounts" WHERE name = $name LIMIT 1"""
+    sql"""SELECT uid, name, email FROM "hub"."accounts" WHERE name = $name LIMIT 1"""
       .query[VcsRepositoryOwner]
       .option
       .transact(tx)
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	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 02:34:56.708822751 +0000
@@ -143,13 +143,25 @@
         ticketsConfiguration.database.user,
         ticketsConfiguration.database.pass
       )
-      transactor = Transactor.fromDriverManager[IO](
+      hubTransactor = Transactor.fromDriverManager[IO](
         hubConfiguration.database.driver,
         hubConfiguration.database.url,
         hubConfiguration.database.user,
         hubConfiguration.database.pass
       )
-      cryptoClock = java.time.Clock.systemUTC
+      ticketsTransactor = Transactor.fromDriverManager[IO](
+        ticketsConfiguration.database.driver,
+        ticketsConfiguration.database.url,
+        ticketsConfiguration.database.user,
+        ticketsConfiguration.database.pass
+      )
+      ticketServiceApi      = new DoobieTicketServiceApi[IO](ticketsTransactor)
+      ticketLabelsRepo      = new DoobieLabelRepository[IO](ticketsTransactor)
+      ticketMilestonesRepo  = new DoobieMilestoneRepository[IO](ticketsTransactor)
+      ticketProjectsRepo    = new DoobieProjectRepository[IO](ticketsTransactor)
+      ticketLabelRoutes     = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
+      ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo)
+      cryptoClock           = java.time.Clock.systemUTC
       csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
       csrfOriginCheck = createCsrfOriginCheck(hubConfiguration.service.external)
       csrfBuilder     = CSRF[IO, IO](csrfKey, csrfOriginCheck)
@@ -173,7 +185,7 @@
         .build
       signAndValidate    = SignAndValidate(hubConfiguration.service.authentication.cookieSecret)
       assetsRoutes       = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes
-      authenticationRepo = new DoobieAuthenticationRepository[IO](transactor)
+      authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor)
       authenticationWithFallThrough = AuthMiddleware.withFallThrough(
         authenticateUserWithFallThrough(
           authenticationRepo,
@@ -183,12 +195,13 @@
       )
       darcsWrapper          = new DarcsCommands[IO](hubConfiguration.service.darcs.executable)
       emailMiddleware       = new SimpleJavaMailMiddleware(hubConfiguration.service.email)
-      accountManagementRepo = new DoobieAccountManagementRepository[IO](transactor)
+      accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor)
       accountManagementRoutes = new AccountManagementRoutes[IO](
         accountManagementRepo,
         hubConfiguration.service,
         emailMiddleware,
-        signAndValidate
+        signAndValidate,
+        ticketServiceApi
       )
       authenticationRoutes = new AuthenticationRoutes[IO](
         cryptoClock,
@@ -196,19 +209,22 @@
         authenticationRepo,
         signAndValidate
       )
-      signUpRepo      = new DoobieSignupRepository[IO](transactor)
+      signUpRepo      = new DoobieSignupRepository[IO](hubTransactor)
       signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
       landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
-      vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor)
+      vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor)
       vcsRepoRoutes = new VcsRepositoryRoutes[IO](
         hubConfiguration.service,
         darcsWrapper,
-        vcsMetadataRepo
+        vcsMetadataRepo,
+        ticketProjectsRepo
       )
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
         authenticationRoutes.protectedRoutes <+>
           accountManagementRoutes.protectedRoutes <+>
           signUpRoutes.protectedRoutes <+>
+          ticketLabelRoutes.protectedRoutes <+>
+          ticketMilestoneRoutes.protectedRoutes <+>
           vcsRepoRoutes.protectedRoutes <+>
           landingPages.protectedRoutes
       )
@@ -218,6 +234,8 @@
           authenticationRoutes.routes <+>
           accountManagementRoutes.routes <+>
           signUpRoutes.routes <+>
+          ticketLabelRoutes.routes <+>
+          ticketMilestoneRoutes.routes <+>
           vcsRepoRoutes.routes <+>
           landingPages.routes)
       ).orNotFound
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/RelatedTypesConverter.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/RelatedTypesConverter.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/RelatedTypesConverter.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/RelatedTypesConverter.scala	2025-01-31 02:34:56.708822751 +0000
@@ -0,0 +1,64 @@
+/*
+ * 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.hub
+
+import de.smederee.tickets.{ Project, ProjectDescription, ProjectName, ProjectOwner, ProjectOwnerId, ProjectOwnerName }
+
+/** A type class for converting types into each other.
+  *
+  * @tparam FROM
+  *   The type from which a conversion shall be done.
+  * @tparam TO
+  *   The target type of the conversion.
+  */
+trait RelatedTypesConverter[FROM, TO] {
+  extension (from: FROM) {
+
+    /** Create an instance of the desired type.
+      *
+      * @return
+      *   An instance of the desired type based on the data of the source type.
+      */
+    def convert: TO
+  }
+}
+
+object RelatedTypesConverter {
+  given RelatedTypesConverter[Account, ProjectOwner] with {
+    extension (from: Account) {
+      override def convert: ProjectOwner =
+        ProjectOwner(
+          uid = ProjectOwnerId.fromUserId(from.uid),
+          name = ProjectOwnerName.fromUsername(from.name),
+          email = from.email
+        )
+    }
+  }
+
+  given RelatedTypesConverter[VcsRepository, Project] with {
+    extension (from: VcsRepository) {
+      override def convert: Project =
+        Project(
+          owner = ProjectOwner(ProjectOwnerId.fromUserId(from.owner.uid), from.owner.name, from.owner.email),
+          name = ProjectName(from.name.toString),
+          description = from.description.map(descr => ProjectDescription(descr.toString)),
+          isPrivate = from.isPrivate
+        )
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-31 02:34:56.708822751 +0000
@@ -26,12 +26,14 @@
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.darcs._
+import de.smederee.html.LinkTools._
 import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.hub.RelatedTypesConverter.given
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types.FormErrors
-import de.smederee.html.LinkTools._
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ CsrfToken, Username }
+import de.smederee.tickets.ProjectRepository
 import org.fusesource.jansi.utils.UtilsAnsiHtml
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
@@ -49,13 +51,16 @@
   *   A class providing darcs VCS operations.
   * @param vcsMetadataRepo
   *   A repository for handling database operations regarding our vcs repositories and their metadata.
+  * @param ticketsProjectRepo
+  *   A repository for handling the synchronisation with the ticket service.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
 final class VcsRepositoryRoutes[F[_]: Async](
     configuration: ServiceConfig,
     darcs: DarcsCommands[F],
-    vcsMetadataRepo: VcsMetadataRepository[F]
+    vcsMetadataRepo: VcsMetadataRepository[F],
+    ticketsProjectRepo: ProjectRepository[F]
 ) extends Http4sDsl[F] {
   private val log = LoggerFactory.getLogger(getClass)
 
@@ -992,7 +997,9 @@
                       output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty)
                       _ <-
                         if (output.exitValue === 0)
-                          vcsMetadataRepo.createVcsRepository(repoMetadata)
+                          vcsMetadataRepo.createVcsRepository(repoMetadata) *> ticketsProjectRepo.createProject(
+                            repoMetadata.convert
+                          )
                         else
                           Sync[F].pure(0) // Do not create DB entry if darcs init failed!
                     } yield output
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-31 02:34:56.700822741 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-01-31 02:34:56.708822751 +0000
@@ -23,6 +23,7 @@
 import cats._
 import cats.data._
 import cats.syntax.all._
+import de.smederee.email.EmailAddress
 import de.smederee.security.{ UserId, Username }
 import org.http4s.Uri
 import org.slf4j.LoggerFactory
@@ -138,8 +139,10 @@
   *   The user ID of the account that owns the repository.
   * @param name
   *   The name of the account that owns the repository.
+  * @param email
+  *   The email address of the project owner.
   */
-final case class VcsRepositoryOwner(uid: UserId, name: Username)
+final case class VcsRepositoryOwner(uid: UserId, name: Username, email: EmailAddress)
 
 object VcsRepositoryOwner {
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 02:34:56.708822751 +0000
@@ -102,7 +102,7 @@
               )()
             )
           } yield resp
-        case _ => NotFound()
+        case _ => NotFound("Ticket project not found!")
       }
     } yield resp
 
@@ -139,7 +139,9 @@
       projectAndId = currentUser match {
         case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
         case Some(user) =>
-          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner)
+          loadedRepo.filter(tuple =>
+            tuple._1.isPrivate === false || tuple._1.owner.uid === ProjectOwnerId.fromUserId(user.uid)
+          )
       }
     } yield projectAndId
 
@@ -155,7 +157,9 @@
           resp <- projectAndId match {
             case Some(repo, repoId) =>
               for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add labels!")
+                )
                 formData <- Sync[F].delay {
                   urlForm.values.map { t =>
                     val (key, values) = t
@@ -236,7 +240,9 @@
           resp <- projectAndId match {
             case Some(repo, repoId) =>
               for {
-                _     <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add labels!")
+                )
                 label <- labelRepo.findLabel(repoId)(labelName)
                 resp <- label match {
                   case Some(label) =>
@@ -305,7 +311,9 @@
           resp <- (projectAndId, label) match {
             case (Some(repo, repoId), Some(label)) =>
               for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add labels!")
+                )
                 projectBaseUri <- Sync[F].delay(
                   linkConfig.createFullUri(
                     Uri(path =
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 02:34:56.708822751 +0000
@@ -136,7 +136,9 @@
       repoAndId = currentUser match {
         case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
         case Some(user) =>
-          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner)
+          loadedRepo.filter(tuple =>
+            tuple._1.isPrivate === false || tuple._1.owner.uid === ProjectOwnerId.fromUserId(user.uid)
+          )
       }
     } yield repoAndId
 
@@ -152,7 +154,9 @@
           resp <- repoAndId match {
             case Some(repo, repoId) =>
               for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add milestones!")
+                )
                 formData <- Sync[F].delay {
                   urlForm.values.map { t =>
                     val (key, values) = t
@@ -236,7 +240,9 @@
           resp <- repoAndId match {
             case Some(repo, repoId) =>
               for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add milestones!")
+                )
                 milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle)
                 resp <- milestone match {
                   case Some(milestone) =>
@@ -305,7 +311,9 @@
           resp <- (repoAndId, milestone) match {
             case (Some(repo, repoId), Some(milestone)) =>
               for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                _ <- Sync[F].raiseUnless(repo.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add milestones!")
+                )
                 repositoryBaseUri <- Sync[F].delay(
                   linkConfig.createFullUri(
                     Uri(path =
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-31 02:34:56.708822751 +0000
@@ -181,9 +181,10 @@
     .map(cs => VcsRepositoryName(cs.take(64).mkString))
 
   val genValidVcsRepositoryOwner = for {
-    uid  <- genUserId
-    name <- genValidUsername
-  } yield VcsRepositoryOwner(uid, name)
+    uid   <- genUserId
+    name  <- genValidUsername
+    email <- genValidEmail
+  } yield VcsRepositoryOwner(uid, name, email)
 
   val genValidVcsType = Gen.oneOf(VcsType.values.toList)
 
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	2025-01-31 02:34:56.708822751 +0000
@@ -23,6 +23,9 @@
 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, Username }
 import de.smederee.tickets.config._
 import org.flywaydb.core.Flyway
 import pureconfig._
@@ -189,12 +192,12 @@
       } yield r
     }
 
-  /** Find the repository ID for the given owner and repository name.
+  /** Find the project ID for the given owner and project name.
     *
     * @param owner
-    *   The unique ID of the user account that owns the repository.
+    *   The unique ID of the user account that owns the project.
     * @param name
-    *   The repository name which must be unique in regard to the owner.
+    *   The project name which must be unique in regard to the owner.
     * @return
     *   An option to the internal database ID.
     */
@@ -220,4 +223,33 @@
         _ <- IO(statement.close())
       } yield account
     }
+
+  /** 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/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2025-01-31 02:34:56.708822751 +0000
@@ -23,6 +23,47 @@
 import doobie._
 
 final class DoobieProjectRepositoryTest extends BaseSpec {
+  test("createProject must create a project") {
+    (genValidProjectOwner.sample, genValidProject.sample) match {
+      case (Some(owner), Some(generatedProject)) =>
+        val project     = generatedProject.copy(owner = owner)
+        val dbConfig    = configuration.database
+        val tx          = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val projectRepo = new DoobieProjectRepository[IO](tx)
+        val test = for {
+          _            <- createTicketsUser(owner)
+          _            <- 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") {
+    (genValidProjectOwner.sample, genValidProject.sample) match {
+      case (Some(owner), Some(generatedProject)) =>
+        val project     = generatedProject.copy(owner = owner)
+        val dbConfig    = configuration.database
+        val tx          = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val projectRepo = new DoobieProjectRepository[IO](tx)
+        val test = for {
+          _            <- createTicketsUser(owner)
+          _            <- 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") {
     (genValidProjectOwner.sample, genValidProjects.sample) match {
       case (Some(owner), Some(generatedProject :: projects)) =>
@@ -81,5 +122,28 @@
         }
       case _ => fail("Could not generate data samples!")
     }
+  }
+
+  test("updateProject must update a project") {
+    (genValidProjectOwner.sample, genValidProject.sample, genValidProject.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](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val projectRepo = new DoobieProjectRepository[IO](tx)
+        val test = for {
+          _            <- createTicketsUser(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/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketServiceApiTest.scala	2025-01-31 02:34:56.708822751 +0000
@@ -0,0 +1,88 @@
+/*
+ * 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.tickets.Generators._
+import doobie._
+
+final class DoobieTicketServiceApiTest extends BaseSpec {
+  test("createOrUpdateUser must create new users") {
+    genValidTicketsUser.sample match {
+      case Some(user) =>
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        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") {
+    (genValidTicketsUser.sample, genValidTicketsUser.sample) match {
+      case (Some(user), Some(anotherUser)) =>
+        val updatedUser = anotherUser.copy(uid = user.uid)
+        val dbConfig    = configuration.database
+        val tx          = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        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") {
+    genValidTicketsUser.sample match {
+      case Some(user) =>
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        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/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 02:34:56.708822751 +0000
@@ -23,6 +23,8 @@
 import cats._
 import cats.syntax.all._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security._
 
 import org.scalacheck.{ Arbitrary, Gen }
 
@@ -71,10 +73,23 @@
 
   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 genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
 
+  val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId)
+
+  val genValidUsername: 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 genValidEmailAddress: Gen[EmailAddress] =
     for {
       length <- Gen.choose(4, 64)
@@ -82,6 +97,13 @@
       email = chars.take(length).mkString
     } yield EmailAddress(email + "@example.com")
 
+  val genValidTicketsUser: Gen[TicketsUser] = for {
+    uid      <- genUserId
+    name     <- genValidUsername
+    email    <- genValidEmailAddress
+    language <- Gen.option(genLanguageCode)
+  } yield TicketsUser(uid, name, email, language)
+
   val genValidProjectOwnerName: Gen[ProjectOwnerName] = for {
     length <- Gen.choose(2, 30)
     prefix <- Gen.alphaChar
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V3__add_language.sql	2025-01-31 02:34:56.708822751 +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/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	2025-01-31 02:34:56.708822751 +0000
@@ -33,6 +33,21 @@
   given Meta[ProjectOwnerId]     = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID)
   given Meta[ProjectOwnerName]   = Meta[String].timap(ProjectOwnerName.apply)(_.toString)
 
+  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,
@@ -68,4 +83,13 @@
           FROM "tickets"."users"
           WHERE name = $name""".query[ProjectOwner].option.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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketServiceApi.scala	2025-01-31 02:34:56.708822751 +0000
@@ -0,0 +1,55 @@
+/*
+ * 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 de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security.{ UserId, Username }
+import doobie._
+import doobie.implicits._
+import doobie.postgres.implicits._
+
+final class DoobieTicketServiceApi[F[_]: Sync](tx: Transactor[F]) extends TicketServiceApi[F] {
+  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/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2025-01-31 02:34:56.708822751 +0000
@@ -24,6 +24,24 @@
   */
 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
@@ -55,4 +73,14 @@
     *   An option to successfully found project owner.
     */
   def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]]
+
+  /** 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	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala	2025-01-31 02:34:56.708822751 +0000
@@ -128,7 +128,7 @@
 
   given Eq[ProjectOwnerId] = Eq.fromUniversalEquals
 
-  given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId
+  // given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId
 
   /** Create an instance of ProjectOwnerId from the given UUID type.
     *
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketServiceApi.scala	2025-01-31 02:34:56.708822751 +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/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	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala	2025-01-31 02:34:56.708822751 +0000
@@ -15,10 +15,11 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-package de.semderee.tickets
+package de.smederee.tickets
 
 import cats._
 import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ UserId, Username }
 
 /** A user of the tickets service.
@@ -29,8 +30,10 @@
   *   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)
+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/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 02:34:56.704822746 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 02:34:56.708822751 +0000
@@ -18,9 +18,13 @@
 package de.smederee.tickets
 
 import java.time._
-import java.util.Locale
+import java.util.{ Locale, 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, Gen }
 
@@ -67,15 +71,58 @@
       )
     } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset)
 
-  val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId)
+  given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime)
 
-  val genValidSubmitterName: Gen[SubmitterName] = for {
+  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 genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID)
+
+  val genUserId: Gen[UserId] = Gen.delay(UserId.randomUserId)
+
+  val genValidUsername: 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 genValidEmailAddress: Gen[EmailAddress] =
+    for {
+      length <- Gen.choose(4, 64)
+      chars  <- Gen.nonEmptyListOf(Gen.alphaNumChar)
+      email = chars.take(length).mkString
+    } yield EmailAddress(email + "@example.com")
+
+  val genValidTicketsUser: Gen[TicketsUser] = for {
+    uid      <- genUserId
+    name     <- genValidUsername
+    email    <- genValidEmailAddress
+    language <- Gen.option(genLanguageCode)
+  } yield TicketsUser(uid, name, email, language)
+
+  val genValidProjectOwnerName: Gen[ProjectOwnerName] = for {
     length <- Gen.choose(2, 30)
     prefix <- Gen.alphaChar
     chars <- Gen
       .nonEmptyListOf(Gen.alphaNumChar)
       .map(_.take(length).mkString.toLowerCase(Locale.ROOT))
-  } yield SubmitterName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+  } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars)
+
+  val genValidProjectOwner: Gen[ProjectOwner] = for {
+    id    <- genProjectOwnerId
+    name  <- genValidProjectOwnerName
+    email <- genValidEmailAddress
+  } yield ProjectOwner(uid = id, name = name, email = email)
+
+  given Arbitrary[ProjectOwner] = Arbitrary(genValidProjectOwner)
+
+  val genValidProjectOwners: Gen[List[ProjectOwner]] = Gen
+    .nonEmptyListOf(genValidProjectOwner)
+    .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names.
 
   val genLabelName: Gen[LabelName] =
     Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
@@ -115,32 +162,64 @@
 
   val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
 
-  val genSubmitter: Gen[Submitter] =
-    for {
-      uid  <- genSubmitterId
-      name <- genValidSubmitterName
-    } yield Submitter(uid, name)
+  val genValidProjectName: 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 genTicket: Gen[Ticket] =
+  val genValidProject: Gen[Project] =
     for {
-      ticketNumber <- Gen.choose(0, Int.MaxValue).map(TicketNumber.apply)
-      ticketTitle <- Gen
-        .nonEmptyListOf(Gen.alphaNumChar)
-        .map(chars => TicketTitle(chars.take(TicketTitle.MaxLength).mkString))
-      ticketContent    <- Gen.alphaNumStr.map(TicketContent.from)
-      ticketStatus     <- Gen.oneOf(TicketStatus.values.toList)
-      ticketResolution <- Gen.option(Gen.oneOf(TicketResolution.values.toList))
-      submitter        <- Gen.option(genSubmitter)
-      createdAt        <- genOffsetDateTime
-      updatedAt        <- genOffsetDateTime
-    } yield Ticket(
-      number = ticketNumber,
-      title = ticketTitle,
-      content = ticketContent,
-      status = ticketStatus,
-      resolution = ticketResolution.filter(_ => ticketStatus === TicketStatus.Resolved),
-      submitter = submitter,
-      createdAt = createdAt,
-      updatedAt = updatedAt
-    )
+      name        <- genValidProjectName
+      description <- genProjectDescription
+      owner       <- genValidProjectOwner
+      isPrivate   <- Gen.oneOf(List(false, true))
+    } yield Project(owner, name, description, isPrivate)
+
+  val genValidProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genValidProject)
+
 }