~jan0sch/smederee
Showing details for patch f2925545a468ee68878007aebeb472bb107eff4b.
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) + }