~jan0sch/smederee
Showing details for patch f31fdcd29191906e80b6192849af9df27435df8b.
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-30 15:43:55.172754038 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-30 15:43:55.176754042 +0000 @@ -285,6 +285,18 @@ 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 +form.ticket.content.help=The description can be as detailed as needed. +form.ticket.content.placeholder=A detailed description about the issue at hand. +form.ticket.content=Describe the ticket in detail. +form.ticket.create.button.submit=Create ticket +form.ticket.labels=Labels +form.ticket.labels.help=Select labels to apply to this ticket. +form.ticket.resolution=Resolution +form.ticket.resolution.help=This can be set to further describe the resolution of a ticket. +form.ticket.status=Status +form.ticket.status.help=The current status of the ticket describing its life cycle. +form.ticket.title.placeholder=Short concise summary what the ticket is about. +form.ticket.title=Title project.label.edit.link=Edit project.label.edit.title=Edit label ''{0}''. @@ -304,6 +316,8 @@ project.milestones.list.empty=There are no milestones defined for this project. project.milestones.list.title={0} milestones found. project.milestones.view.title=Milestones +project.tickets.add.link=New ticket +project.tickets.add.title=Create a new ticket for {0}. project.tickets.view.title=Tickets project.tickets.list.empty=There are no tickets defined for this project. project.tickets.list.title={0} tickets found. 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-30 15:43:55.176754042 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-30 15:43:55.176754042 +0000 @@ -155,13 +155,13 @@ 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) - ticketsRepo = new DoobieTicketRepository[IO](ticketsTransactor) - ticketRoutes = new TicketRoutes[IO](ticketsConfiguration, ticketProjectsRepo, ticketsRepo) - ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo) + ticketServiceApi = new DoobieTicketServiceApi[IO](ticketsTransactor) + ticketLabelsRepo = new DoobieLabelRepository[IO](ticketsTransactor) + ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor) + ticketProjectsRepo = new DoobieProjectRepository[IO](ticketsTransactor) + ticketsRepo = new DoobieTicketRepository[IO](ticketsTransactor) + ticketRoutes = new TicketRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo, ticketsRepo) + ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo) ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo) cryptoClock = java.time.Clock.systemUTC csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala 2025-01-30 15:43:55.176754042 +0000 @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import cats._ +import cats.data._ +import cats.syntax.all._ +import de.smederee.tickets.forms.FormValidator +import de.smederee.tickets.forms.types._ + +import scala.util.Try + +/** Data container to edit a ticket. + * + * @param number + * The unique identifier of a ticket within the project scope is its number. + * @param title + * A concise and short description of the ticket which should not exceed 72 characters. + * @param content + * An optional field to describe the ticket in great detail if needed. + * @param status + * The current status of the ticket describing its life cycle. + * @param resolution + * An optional resolution state of the ticket that should be set if it is closed. + * @param submitter + * The unique id of the person who submitted (created) this ticket which is optional because of possible account + * deletion or other reasons. + */ +final case class TicketForm( + number: Option[TicketNumber], + title: TicketTitle, + content: Option[TicketContent], + status: TicketStatus, + resolution: Option[TicketResolution], + submitter: Option[SubmitterId] +) + +object TicketForm extends FormValidator[TicketForm] { + val fieldNumber: FormField = FormField("number") + val fieldTitle: FormField = FormField("title") + val fieldContent: FormField = FormField("content") + val fieldStatus: FormField = FormField("status") + val fieldResolution: FormField = FormField("resolution") + val fieldSubmitter: FormField = FormField("submitter") + + /** Create a form for editing a ticket from the given ticket data. + * + * @param ticket + * The ticket which provides the data for the edit form. + * @return + * A ticket form filled with the data from the given ticket. + */ + def fromTicket(ticket: Ticket): TicketForm = + TicketForm( + number = ticket.number.some, + title = ticket.title, + content = ticket.content, + status = ticket.status, + resolution = ticket.resolution, + submitter = ticket.submitter.map(_.id) + ) + + override def validate(data: Map[String, String]): ValidatedNec[FormErrors, TicketForm] = { + val number = data + .get(fieldNumber) + .fold(Option.empty[TicketNumber].validNec)(s => + TicketNumber + .fromString(s) + .fold(FormFieldError("Invalid ticket number!").invalidNec)(number => Option(number).validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldNumber -> es.toList))) + val title = data + .get(fieldTitle) + .map(_.trim) + .fold(FormFieldError("No ticket title given!").invalidNec)(s => + TicketTitle.from(s).fold(FormFieldError("Invalid ticket title!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList))) + val content = data + .get(fieldContent) + .filter(_.nonEmpty) + .fold(none[TicketContent].validNec)(s => + TicketContent.from(s).fold(FormFieldError("Invalid ticket content!").invalidNec)(_.some.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldContent -> es.toList))) + val status = data + .get(fieldStatus) + .fold(FormFieldError("No ticket status given!").invalidNec)(s => + Try(TicketStatus.valueOf(s)).toOption.fold(FormFieldError("Invalid ticket status!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldStatus -> es.toList))) + val resolution = data + .get(fieldResolution) + .fold(none[TicketResolution].validNec)(s => + Try(TicketResolution.valueOf(s)).toOption + .fold(FormFieldError("Invalid ticket resolution!").invalidNec)(_.some.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldResolution -> es.toList))) + val submitterId = data + .get(fieldSubmitter) + .fold(none[SubmitterId].validNec)(s => + SubmitterId.fromString(s).toOption.fold(FormFieldError("Invalid submitter id!").invalidNec)(_.some.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldSubmitter -> es.toList))) + (number, title, content, status, resolution, submitterId).mapN { + case (number, title, content, status, resolution, submitterId) => + TicketForm(number, title, content, status, resolution, submitterId) + } + } + + extension (form: TicketForm) { + + /** Convert the form class into a stringified map which is used as underlying data type for form handling in the + * twirl templating library. + * + * @return + * A stringified map containing the data of the form. + */ + def toMap: Map[String, String] = + Map( + TicketForm.fieldNumber.toString -> form.number.map(_.toString).getOrElse(""), + TicketForm.fieldTitle.toString -> form.title.toString, + TicketForm.fieldContent.toString -> form.content.map(_.toString).getOrElse(""), + TicketForm.fieldStatus.toString -> form.status.toString, + TicketForm.fieldResolution.toString -> form.resolution.map(_.toString).getOrElse(""), + TicketForm.fieldSubmitter.toString -> form.submitter.map(_.toString).getOrElse("") + ) + } +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-30 15:43:55.176754042 +0000 @@ -17,18 +17,23 @@ package de.smederee.tickets +import java.time.{ OffsetDateTime, ZoneOffset } + import cats._ +import cats.data._ import cats.effect._ import cats.syntax.all._ import de.smederee.html.LinkTools._ import de.smederee.html._ -import de.smederee.hub.RequestHelpers.instances.given import de.smederee.hub.Account +import de.smederee.hub.RequestHelpers.instances.given import de.smederee.i18n.LanguageCode import de.smederee.security.{ CsrfToken, Username } import de.smederee.tickets.config._ +import de.smederee.tickets.forms.types._ import org.http4s._ import org.http4s.dsl.Http4sDsl +import org.http4s.headers.Location import org.http4s.twirl.TwirlInstances._ import org.slf4j.LoggerFactory @@ -36,6 +41,8 @@ * * @param configuration * The ticket service configuration. + * @param labelRepo + * A repository for handling database operations for labels. * @param projectRepo * A repository for handling database operations regarding our projects and their metadata. * @param ticketRepo @@ -45,6 +52,7 @@ */ final class TicketRoutes[F[_]: Async]( configuration: SmedereeTicketsConfiguration, + labelRepo: LabelRepository[F], projectRepo: ProjectRepository[F], ticketRepo: TicketRepository[F] ) extends Http4sDsl[F] { @@ -144,6 +152,119 @@ } } yield resp + private val addTicket: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "tickets" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some((project, projectId)) => + for { + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + new Error("Only maintainers may add milestones!") + ) + labels <- labelRepo.allLabels(projectId).compile.toList + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + form <- Sync[F].delay(TicketForm.validate(formData)) + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.createTicket(lang = language)( + projectBaseUri.addSegment("tickets"), + csrf, + labels, + projectBaseUri, + "Create a new ticket.".some, + user.some, + project + )(formData, FormErrors.fromNec(errors)) + ) + case Validated.Valid(ticketData) => + for { + timestamp <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC)) + number <- projectRepo.incrementNextTicketNumber(projectId) + ticket <- Sync[F].delay( + Ticket( + number = number, + title = ticketData.title, + content = ticketData.content, + status = ticketData.status, + resolution = ticketData.resolution, + submitter = None, // FIXME: Enable storing a submitter or default to current user. + createdAt = timestamp, + updatedAt = timestamp + ) + ) + _ <- ticketRepo.createTicket(projectId)(ticket) + resp <- SeeOther(Location(projectBaseUri.addSegment("tickets"))) + } yield resp + } + } yield resp + case _ => NotFound() + } + } yield resp + } + } + + private val showCreateTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "newTicket" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some((project, projectId)) => + for { + labels <- labelRepo.allLabels(projectId).compile.toList + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + resp <- Ok( + views.html.createTicket(lang = language)( + projectBaseUri.addSegment("tickets"), + csrf, + labels, + projectBaseUri, + "Create a new ticket.".some, + user.some, + project + )() + ) + } yield resp + case _ => NotFound() + } + } yield resp + } + private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName @@ -164,7 +285,7 @@ } yield resp } - val protectedRoutes = showTicketsPage + val protectedRoutes = addTicket <+> showCreateTicketPage <+> showTicketsPage val routes = showTicketsForGuests diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html 2025-01-30 15:43:55.176754042 +0000 @@ -0,0 +1,127 @@ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets.TicketForm._ +@import de.smederee.tickets._ +@import de.smederee.tickets.forms._ +@import de.smederee.tickets.forms.types._ +@import de.smederee.tickets.views.html.forms.renderFormErrors + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + labels: List[Label] = Nil, + projectBaseUri: Uri, + title: Option[String] = None, + user: Option[Account], + project: Project +)(formData: Map[String, String] = Map.empty, + formErrors: FormErrors = FormErrors.empty +) +@footer = { + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", () => { + const ticketStatus = document.getElementById("@fieldStatus"); + const ticketResolution = document.getElementById("@fieldResolution"); + + ticketStatus.addEventListener("change", (event) => { + if (event.target.value == "@{TicketStatus.Resolved.toString}") { + ticketResolution.disabled = false + } else { + ticketResolution.disabled = true + ticketResolution.value = "" + } + }); + }); + </script> +} +@main(baseUri, lang)(customFooters = footer)(csrf, title, user) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1"> + <div class="l-box-left-right"> + <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project) + <div class="project-summary-description"> + @Messages("project.tickets.view.title") + </div> + </div> + </div> + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <h4>@Messages("project.tickets.add.title", s"~${project.owner.name}/${project.name}")</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="edit-tickets-form"> + <form action="@projectBaseUri.addSegment("tickets")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8"> + <fieldset class="pure-group"> + <div class="pure-g"> + <div class="pure-u-18-24 pure-u-md-18-24"> + <div class="pure-control-group pure-u-23-24"> + <label for="@{fieldTitle}">@Messages("form.ticket.title")</label> + <input class="pure-input-1" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="72" placeholder="@Messages("form.ticket.title.placeholder")" required="required" type="text" value="@{formData.get(fieldTitle)}"> + @renderFormErrors(fieldTitle, formErrors) + </div> + <div class="pure-control-group pure-u-23-24"> + <label for="@{fieldContent}">@Messages("form.ticket.content")</label> + <textarea class="pure-input-1" id="@{fieldContent}" name="@{fieldContent}" placeholder="@Messages("form.ticket.content.placeholder")" rows="8" value="@{formData.get(fieldContent)}"></textarea> + <span class="pure-form-message" id="@{fieldContent}.help">@Messages("form.ticket.content.help")</span> + @renderFormErrors(fieldContent, formErrors) + </div> + </div> + <div class="pure-6-24 pure-u-md-6-24"> + <div class="pure-control-group"> + <label for="ticket_labels">@Messages("form.ticket.labels")</label> + <select class="pure-input-1" id="ticket_labels" name="ticket_labels" multiple> + @for(label <- labels) { + <option value="@label.id">@label.name</option> + } + </select> + <span class="pure-form-message" id="ticket_labels.help">@Messages("form.ticket.labels.help")</span> + </div> + <div class="pure-control-group"> + <label for="@{fieldStatus}">@Messages("form.ticket.status")</label> + <select class="pure-input-1" id="@{fieldStatus}" name="@{fieldStatus}"> + @for(status <- TicketStatus.Submitted :: TicketStatus.values.toList.filterNot(_ === TicketStatus.Submitted)) { + <option value="@status" @if(formData.get(fieldStatus).exists(_ === status.toString)){selected="selected"}else{}>@status</option> + } + </select> + <span class="pure-form-message" id="@{fieldStatus}.help">@Messages("form.ticket.status.help")</span> + @renderFormErrors(fieldStatus, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldResolution}">@Messages("form.ticket.resolution")</label> + <select class="pure-input-1" id="@{fieldResolution}" name="@{fieldResolution}" @if(formData.get(fieldStatus).exists(_ === TicketStatus.Resolved.toString)){}else{disabled="disabled"}> + <option value=""></option> + @for(resolution <- TicketResolution.values) { + <option value="@resolution" @if(formData.get(fieldResolution).exists(_ === resolution.toString)){selected="selected"}else{}>@resolution</option> + } + </select> + <span class="pure-form-message" id="@{fieldResolution}.help">@Messages("form.ticket.resolution.help")</span> + @renderFormErrors(fieldResolution, formErrors) + </div> + </div> + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-success">@Messages("form.ticket.create.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html 2025-01-30 15:43:55.176754042 +0000 @@ -0,0 +1,6 @@ +@import java.time._ +@import java.time.format._ +@import java.util.Locale + +@(timestamp: OffsetDateTime, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale) +(@DateTimeFormatter.ofLocalizedDateTime(style).withLocale(locale).format(timestamp)) diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html 2025-01-30 15:43:55.176754042 +0000 @@ -1,7 +1,7 @@ @import de.smederee.hub.Account @import de.smederee.hub.views.html.main @import de.smederee.tickets._ -@import de.smederee.tickets.views.html.showProjectMenu +@import de.smederee.tickets.views.html.format.formatDateTime @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") @@ -30,33 +30,41 @@ <div class="pure-g"> <div class="pure-u-1-1 pure-u-md-1-1"> <div class="l-box"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <a class="pure-button pure-button-success" href="@projectBaseUri.addSegment("newTicket")" title="@Messages("project.tickets.add.title", s"~${project.owner.name}/${project.name}")">@Messages("project.tickets.add.link")</a> + } else { + } + </div> + </div> + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> <div class="ticket-list"> <h4>@Messages("project.tickets.list.title", tickets.size)</h4> @if(tickets.size === 0) { <div class="alert alert-info">@Messages("project.tickets.list.empty")</div> } else { - @defining(32) { lineHeight => - @for(ticket <- tickets) { - <div class="pure-g ticket"> - <div class="pure-u-1-24 ticket-icon">@icon(baseUri)("crosshair", lineHeight.some)</div> - <div class="pure-u-3-24">@ticket.number</div> - <div class="pure-u-18-24">@ticket.title</div> - <div class="pure-u-4-24"></div> - </div> - <div class="pure-g ticket-content"> - <div class="pure-u-20-24">@ticket.content.take(176) ...</div> - <div class="pure-u-4-24"></div> - </div> - <div class="pure-g ticket-details"> - <div class="pure-u-3-24"></div> - <div class="pure-u-3-24"></div> - <div class="pure-u-3-24"></div> - <div class="pure-u-3-24">@ticket.status</div> - <div class="pure-u-3-24">@ticket.resolution</div> - <div class="pure-u-3-24">@ticket.submitter</div> - <div class="pure-u-3-24">@ticket.updatedAt</div> - </div> - } + @for(ticket <- tickets) { + <div class="pure-g ticket"> + <div class="pure-u-1-24"><input id="ticket-@ticket.number" type="checkbox"/></div> + <div class="pure-u-1-24"><span @if(ticket.status === TicketStatus.Resolved){style="text-decoration: line-through;"}else{}>@ticket.number</span></div> + <div class="pure-u-20-24"><a href="#">@ticket.title</a></div> + <div class="pure-u-4-24"></div> + </div> + <div class="pure-g ticket-content"> + <div class="pure-u-1-24"></div> + <div class="pure-u-20-24">@if(ticket.content.exists(_.toString.length < 180)) { @ticket.content } else { @ticket.content.map(_.toString.take(176)) ...} </div> + <div class="pure-u-3-24"></div> + </div> + <div class="pure-g ticket-details" style="font-size: 14px;"> + <div class="pure-u-1-24"></div> + <div class="pure-u-8-24"></div> + <div class="pure-u-3-24">@ticket.status</div> + <div class="pure-u-3-24">@ticket.resolution</div> + <div class="pure-u-3-24">@ticket.submitter</div> + <div class="pure-u-6-24">@formatDateTime(ticket.updatedAt)</div> + </div> } } </div> diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-30 15:43:55.176754042 +0000 @@ -243,6 +243,32 @@ } yield r } + /** Return the next ticket number for the given project. + * + * @param projectId + * The internal database ID of the project. + * @return + * The next ticket number. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT next_ticket_number FROM "tickets"."projects" WHERE id = ?""" + ) + ) + _ <- IO.delay(statement.setLong(1, projectId.toLong)) + result <- IO.delay(statement.executeQuery) + number <- IO.delay { + result.next() + result.getInt("next_ticket_number") + } + _ <- IO(statement.close()) + } yield number + } + /** Find the project ID for the given owner and project name. * * @param owner @@ -264,7 +290,7 @@ _ <- IO.delay(statement.setObject(1, owner)) _ <- IO.delay(statement.setString(2, name.toString)) result <- IO.delay(statement.executeQuery) - account <- IO.delay { + projectId <- IO.delay { if (result.next()) { ProjectId.from(result.getLong("id")) } else { @@ -272,7 +298,7 @@ } } _ <- IO(statement.close()) - } yield account + } yield projectId } /** Find the ticket ID for the given project ID and ticket number. @@ -296,7 +322,7 @@ _ <- IO.delay(statement.setLong(1, project.toLong)) _ <- IO.delay(statement.setInt(2, number.toInt)) result <- IO.delay(statement.executeQuery) - account <- IO.delay { + ticketId <- IO.delay { if (result.next()) { TicketId.from(result.getLong("id")) } else { @@ -304,7 +330,7 @@ } } _ <- IO(statement.close()) - } yield account + } yield ticketId } /** Find the ticket service user with the given user id. diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala 2025-01-30 15:43:55.176754042 +0000 @@ -124,6 +124,36 @@ } } + test("incrementNextTicketNumber must return and increment the old value") { + (genProjectOwner.sample, genProject.sample) match { + case (Some(owner), Some(firstProject)) => + val project = firstProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val projectRepo = new DoobieProjectRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- projectRepo.createProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => fail("Project was not created!") + case Some(projectId) => + for { + before <- loadNextTicketNumber(projectId) + number <- projectRepo.incrementNextTicketNumber(projectId) + after <- loadNextTicketNumber(projectId) + } yield (TicketNumber(before), number, TicketNumber(after)) + } + } yield result + test.map { result => + val (before, number, after) = result + assertEquals(before, number) + assertEquals(after, TicketNumber(number.toInt + 1)) + } + case _ => fail("Could not generate data samples!") + } + } + test("updateProject must update a project") { (genProjectOwner.sample, genProject.sample, genProject.sample) match { case (Some(owner), Some(firstProject), Some(secondProject)) => diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala 2025-01-30 15:43:55.176754042 +0000 @@ -37,6 +37,7 @@ given Meta[ProjectName] = Meta[String].timap(ProjectName.apply)(_.toString) given Meta[ProjectOwnerId] = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID) given Meta[ProjectOwnerName] = Meta[String].timap(ProjectOwnerName.apply)(_.toString) + given Meta[TicketNumber] = Meta[Int].timap(TicketNumber.apply)(_.toInt) override def createProject(project: Project): F[Int] = sql"""INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at) @@ -88,6 +89,31 @@ FROM "tickets"."users" WHERE name = $name""".query[ProjectOwner].option.transact(tx) + override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = { + // TODO: Find out which of the queries is more reliable and more performant. + val sqlQuery1 = sql"""UPDATE "tickets"."projects" AS alias1 + SET next_ticket_number = alias2.next_ticket_number + 1 + FROM ( + SELECT + id, + next_ticket_number + FROM "tickets"."projects" + WHERE id = $projectId + ) AS alias2 + WHERE alias1.id = alias2.id + RETURNING alias2.next_ticket_number AS next_ticket_number""" + val sqlQuery2 = sql"""WITH old_number AS ( + SELECT next_ticket_number FROM "tickets"."projects" WHERE id = $projectId + ) + UPDATE "tickets"."projects" + SET next_ticket_number = next_ticket_number + 1 + WHERE id = $projectId + RETURNING ( + SELECT next_ticket_number FROM old_number + )""" + sqlQuery2.query[TicketNumber].unique.transact(tx) + } + override def updateProject(project: Project): F[Int] = sql"""UPDATE "tickets"."projects" SET is_private = ${project.isPrivate}, diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-30 15:43:55.180754046 +0000 @@ -74,6 +74,16 @@ */ def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] + /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was + * incremented). + * + * @param projectId + * The internal database id of the project. + * @return + * The ticket number _before_ it was incremented. + */ + def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] + /** Update the database entry for the given project. * * @param project diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 15:43:55.176754042 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 15:43:55.180754046 +0000 @@ -95,6 +95,8 @@ given Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y) given Order[TicketNumber] = Order.fromOrdering + val Format: Regex = "^-?\\d+$".r + /** Create an instance of TicketNumber from the given Int type. * * @param source @@ -113,6 +115,16 @@ */ def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0) + /** Try to create an instance of TicketNumber from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a TicketNumber. + * @return + * An option to the successfully converted TicketNumber. + */ + def fromString(source: String): Option[TicketNumber] = + Option(source).filter(Format.matches).map(_.toInt).flatMap(from) + extension (number: TicketNumber) { def toInt: Int = number.toInt }