~jan0sch/smederee

Showing details for patch 43f5137c62426c5ab616c5616669e9795c9c24e1.
2023-09-12 (Tue), 3:21 PM - Jens Grassel - 43f5137c62426c5ab616c5616669e9795c9c24e1

Tickets: Milestones can be closed and re-opened.

- extend data model for milestones with closed flag
- add needed functionality to be able to close and open milestones
- add some css to visualise the closed/open status
- fix an issue with the edit milestone form using invalid values
- display number of closed tickets from total number on milestone page
Summary of changes
1 files added
  • modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
12 files modified with 303 lines added and 18 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 9 added and 0 removed lines
  • modules/hub/src/main/resources/messages.properties with 5 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 138 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html with 3 added and 3 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html with 1 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html with 27 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala with 12 added and 4 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 2 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala with 4 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala with 18 added and 0 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala with 78 added and 0 removed lines
  • modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala with 6 added and 5 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-15 05:53:54.398587511 +0000
@@ -216,6 +216,15 @@
   vertical-align: middle;
 }
 
+.milestone-closed {
+  color: var(--nord3);
+  text-decoration: line-through;
+}
+
+.milestone-closed a {
+  color: var(--nord3);
+}
+
 .milestone-description {
   margin: auto;
   overflow: overlay;
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-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 05:53:54.398587511 +0000
@@ -292,6 +292,8 @@
 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.close.button.submit=Close
+form.milestone.close.button.submit.help=Mark this milestone as closed e.g. completed or obsolete.
 form.milestone.create.button.submit=Create milestone
 form.milestone.delete.button.submit=Delete
 form.milestone.delete.i-am-sure=Yes, I'm sure!
@@ -301,6 +303,8 @@
 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.open.button.submit=Open
+form.milestone.open.button.submit.help=Re-open this 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
@@ -336,6 +340,7 @@
 project.menu.tickets=Tickets
 project.milestone.edit.link=Edit
 project.milestone.edit.title=Edit milestone ''{0}''.
+project.milestone.status.tickets={0} tickets closed from {1} total.
 project.milestone.title=Milestone {0}
 project.milestones.add.title=Add a new milestone.
 project.milestones.edit.title=Edit project milestones.
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-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-15 05:53:54.398587511 +0000
@@ -248,7 +248,13 @@
                     )
                   case Validated.Valid(milestoneData) =>
                     val milestone =
-                      Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate)
+                      Milestone(
+                        None,
+                        milestoneData.title,
+                        milestoneData.description,
+                        milestoneData.dueDate,
+                        closed = false
+                      )
                     for {
                       checkDuplicate <- milestoneRepo.findMilestone(projectId)(milestoneData.title)
                       resp <- checkDuplicate match {
@@ -286,6 +292,71 @@
       }
   }
 
+  private val closeMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "milestones" / MilestoneTitlePathParameter(milestoneTitle) / "close" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          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 close milestones!")
+                )
+                milestone <- milestoneRepo.findMilestone(projectId)(milestoneTitle)
+                resp <- milestone match {
+                  case Some(milestone) =>
+                    for {
+                      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)!
+                        }
+                      }
+                      projectBaseUri <- Sync[F].delay(
+                        linkConfig.createFullUri(
+                          Uri(path =
+                            Uri.Path(
+                              Vector(
+                                Uri.Path.Segment(s"~$projectOwnerName"),
+                                Uri.Path.Segment(projectName.toString)
+                              )
+                            )
+                          )
+                        )
+                      )
+                      milestoneIdMatches <- Sync[F].delay(
+                        formData
+                          .get(MilestoneForm.fieldId)
+                          .flatMap(MilestoneId.fromString)
+                          .exists(id => milestone.id.exists(_ === id))
+                      )
+                      milestoneTitleMatches <- Sync[F].delay(
+                        formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle)
+                      )
+                      resp <- (milestoneIdMatches && milestoneTitleMatches) match {
+                        case false => BadRequest("Invalid form data!")
+                        case true =>
+                          milestone.id.traverse(milestoneRepo.closeMilestone) *> SeeOther(
+                            Location(projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString))
+                          )
+                      }
+                    } yield resp
+                  case _ => NotFound("Milestone not found!")
+                }
+              } yield resp
+            case _ => NotFound("Repository not found!")
+          }
+        } yield resp
+      }
+  }
+
   private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
@@ -457,6 +528,71 @@
       }
   }
 
+  private val openMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "milestones" / MilestoneTitlePathParameter(milestoneTitle) / "open" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          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 open milestones!")
+                )
+                milestone <- milestoneRepo.findMilestone(projectId)(milestoneTitle)
+                resp <- milestone match {
+                  case Some(milestone) =>
+                    for {
+                      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)!
+                        }
+                      }
+                      projectBaseUri <- Sync[F].delay(
+                        linkConfig.createFullUri(
+                          Uri(path =
+                            Uri.Path(
+                              Vector(
+                                Uri.Path.Segment(s"~$projectOwnerName"),
+                                Uri.Path.Segment(projectName.toString)
+                              )
+                            )
+                          )
+                        )
+                      )
+                      milestoneIdMatches <- Sync[F].delay(
+                        formData
+                          .get(MilestoneForm.fieldId)
+                          .flatMap(MilestoneId.fromString)
+                          .exists(id => milestone.id.exists(_ === id))
+                      )
+                      milestoneTitleMatches <- Sync[F].delay(
+                        formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle)
+                      )
+                      resp <- (milestoneIdMatches && milestoneTitleMatches) match {
+                        case false => BadRequest("Invalid form data!")
+                        case true =>
+                          milestone.id.traverse(milestoneRepo.openMilestone) *> SeeOther(
+                            Location(projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString))
+                          )
+                      }
+                    } yield resp
+                  case _ => NotFound("Milestone not found!")
+                }
+              } yield resp
+            case _ => NotFound("Repository not found!")
+          }
+        } yield resp
+      }
+  }
+
   private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
@@ -552,7 +688,7 @@
   }
 
   val protectedRoutes =
-    addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage <+> showMilestone
+    addMilestone <+> closeMilestone <+> deleteMilestone <+> editMilestone <+> openMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage <+> showMilestone
 
   val routes = showMilestoneForGuests <+> showMilestonesForGuests
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html	2025-01-15 05:53:54.398587511 +0000
@@ -55,19 +55,19 @@
               <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id">
               <div class="pure-control-group">
                 <label for="@{fieldTitle}">@Messages("form.milestone.title")</label>
-                <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle).headOption}">
+                <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData(fieldTitle).headOption}">
                 <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span>
                 @renderFormErrors(fieldTitle, formErrors)
               </div>
               <div class="pure-control-group">
                 <label for="@{fieldDescription}">@Messages("form.milestone.description")</label>
-                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription).headOption}"></textarea>
+                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData(fieldDescription).headOption}"></textarea>
                 <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span>
                 @renderFormErrors(fieldDescription, formErrors)
               </div>
               <div class="pure-control-group">
                 <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label>
-                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate).headOption}">
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData(fieldDueDate).headOption}">
                 <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span>
                 @renderFormErrors(fieldDueDate, formErrors)
               </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html	2025-01-15 05:53:54.398587511 +0000
@@ -99,7 +99,7 @@
                   <div class="pure-u-1-24 milestone-icon">
                     @icon(baseUri)("flag", lineHeight.some)
                   </div>
-                  <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;"><a href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</a></div>
+                  <div class="pure-u-5-24 milestone-title @if(milestone.closed){milestone-closed}else{}" style="height: @{lineHeight}px; line-height: @{lineHeight}px;"><a href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</a></div>
                   <div class="pure-u-8-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">
                   @for(description <- milestone.description) {
                     @if(description.toString.length < 180) { @description } else { @description.toString.take(176)) ...}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showMilestone.scala.html	2025-01-15 05:53:54.398587511 +0000
@@ -39,11 +39,37 @@
       <div class="l-box">
         @if(user.nonEmpty) {
           <div class="milestone-buttons">
+            @if(milestone.closed) {
+            <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("milestones").addSegment(milestone.title.toString).addSegment("open")}" accept-charset="UTF-8" class="pure-form" method="POST" style="display: inline-flex;">
+              <fieldset>
+                <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id">
+                <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title">
+                @csrfToken(csrf)
+                <button type="submit" class="pure-button" title="@Messages("form.milestone.open.button.submit.help")">@Messages("form.milestone.open.button.submit")</button>
+              </fieldset>
+            </form>
+            } else {
+            <form action="@{linkToHubService.addPath(projectBaseUri.path.toString).addSegment("milestones").addSegment(milestone.title.toString).addSegment("close")}" accept-charset="UTF-8" class="pure-form" method="POST" style="display: inline-flex;">
+              <fieldset>
+                <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id">
+                <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title">
+                @csrfToken(csrf)
+                <button type="submit" class="pure-button" title="@Messages("form.milestone.close.button.submit.help")">@Messages("form.milestone.close.button.submit")</button>
+              </fieldset>
+            </form>
+            }
             <a class="pure-button" href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("project.milestone.edit.title", milestone.title)">@Messages("project.milestone.edit.link")</a>
           </div>
         } else {}
-        <h1>@milestone.title</h1>
+        <h1@if(milestone.closed){ class="milestone-closed"}else{}>@milestone.title</h1>
         <div class="milestone-description">@Html(renderedMilestoneDescription)</div>
+        <div class="milestone-status">
+        @if(tickets.nonEmpty) {
+          @defining(tickets.filter(_.status =!= TicketStatus.Resolved).size) { openTickets =>
+            @Messages("project.milestone.status.tickets", (tickets.size - openTickets), tickets.size)
+          }
+        } else {}
+        </div>
       </div>
     </div>
     <div class="pure-u-8-24 pure-u-md-8-24">
diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql
--- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V5__add_closeable_milestones.sql	2025-01-15 05:53:54.398587511 +0000
@@ -0,0 +1,5 @@
+ALTER TABLE "tickets"."milestones"
+  ADD COLUMN "closed" BOOLEAN DEFAULT FALSE;
+
+COMMENT ON COLUMN "tickets"."milestones"."closed" IS 'This flag indicates if the milestone is closed e.g. considered done or obsolete.';
+
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2025-01-15 05:53:54.398587511 +0000
@@ -68,7 +68,7 @@
           ON "tickets".submitter = "submitters".uid"""
 
   override def allMilestones(projectId: ProjectId): Stream[F, Milestone] =
-    sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC"""
+    sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC"""
       .query[Milestone]
       .stream
       .transact(tx)
@@ -96,19 +96,24 @@
     tickets.query[Ticket].stream.transact(tx)
   }
 
+  override def closeMilestone(milestoneId: MilestoneId): F[Int] =
+    sql"""UPDATE "tickets"."milestones" SET closed = TRUE WHERE id = $milestoneId""".update.run.transact(tx)
+
   override def createMilestone(projectId: ProjectId)(milestone: Milestone): F[Int] =
     sql"""INSERT INTO "tickets"."milestones"
           (
             project,
             title,
             due_date,
-            description
+            description,
+            closed
           )
           VALUES (
             $projectId,
             ${milestone.title},
             ${milestone.dueDate},
-            ${milestone.description}
+            ${milestone.description},
+            ${milestone.closed}
           )""".update.run.transact(tx)
 
   override def deleteMilestone(milestone: Milestone): F[Int] =
@@ -118,11 +123,14 @@
     }
 
   override def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]] =
-    sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1"""
+    sql"""SELECT id, title, description, due_date, closed FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1"""
       .query[Milestone]
       .option
       .transact(tx)
 
+  override def openMilestone(milestoneId: MilestoneId): F[Int] =
+    sql"""UPDATE "tickets"."milestones" SET closed = FALSE WHERE id = $milestoneId""".update.run.transact(tx)
+
   override def updateMilestone(milestone: Milestone): F[Int] =
     milestone.id match {
       case None => Sync[F].pure(0)
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-15 05:53:54.398587511 +0000
@@ -222,7 +222,8 @@
              "milestones".id AS id,
              "milestones".title AS title,
              "milestones".description AS description,
-             "milestones".due_date AS due_date
+             "milestones".due_date AS due_date,
+             "milestones".closed AS closed
            FROM "tickets"."milestones"        AS "milestones"
            JOIN "tickets"."milestone_tickets" AS "milestone_tickets"
              ON "milestones".id = "milestone_tickets"."milestone"
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2025-01-15 05:53:54.398587511 +0000
@@ -46,6 +46,15 @@
     */
   def allTickets(filter: Option[TicketFilter])(milestoneId: MilestoneId): Stream[F, Ticket]
 
+  /** Change the milestone status with the given id to closed.
+    *
+    * @param milestoneId
+    *   The unique internal ID of a milestone for which all tickets shall be returned.
+    * @return
+    *   The number of affected database rows.
+    */
+  def closeMilestone(milestoneId: MilestoneId): F[Int]
+
   /** Create a database entry for the given milestone definition.
     *
     * @param projectId
@@ -77,6 +86,15 @@
     */
   def findMilestone(projectId: ProjectId)(title: MilestoneTitle): F[Option[Milestone]]
 
+  /** Change the milestone status with the given id to open.
+    *
+    * @param milestoneId
+    *   The unique internal ID of a milestone for which all tickets shall be returned.
+    * @return
+    *   The number of affected database rows.
+    */
+  def openMilestone(milestoneId: MilestoneId): F[Int]
+
   /** Update the database entry for the given milestone.
     *
     * @param milestone
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-15 05:53:54.398587511 +0000
@@ -144,12 +144,15 @@
   *   An optional longer description of the milestone.
   * @param dueDate
   *   An optional date on which the milestone is supposed to be reached.
+  * @param closed
+  *   This flag indicates if the milestone is closed e.g. considered done or obsolete.
   */
 final case class Milestone(
     id: Option[MilestoneId],
     title: MilestoneTitle,
     description: Option[MilestoneDescription],
-    dueDate: Option[LocalDate]
+    dueDate: Option[LocalDate],
+    closed: Boolean
 )
 
 object Milestone {
diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2025-01-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2025-01-15 05:53:54.398587511 +0000
@@ -150,6 +150,45 @@
     }
   }
 
+  test("closeMilestone must set the closed flag correctly".tag(NeedsDatabase)) {
+    (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
+        val milestone = generatedMilestone.copy(closed = false)
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _            <- createProjectOwner(owner)
+          createdRepos <- createTicketsProject(project)
+          repoId       <- loadProjectId(owner.uid, project.name)
+          milestones <- repoId match {
+            case None => IO.pure((None, None))
+            case Some(projectId) =>
+              for {
+                _      <- milestoneRepo.createMilestone(projectId)(milestone)
+                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                _      <- before.flatMap(_.id).traverse(milestoneRepo.closeMilestone)
+                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
+              } yield (before, after)
+          }
+        } yield milestones
+        test.map { result =>
+          val (before, after) = result
+          val expected        = before.map(m => milestone.copy(id = m.id))
+          assertEquals(before, expected, "Test milestone not properly initialised!")
+          assertEquals(after, before.map(_.copy(closed = true)), "Test milestone not properly closed!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("createMilestone must create the milestone".tag(NeedsDatabase)) {
     (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
       case (Some(owner), Some(generatedProject), Some(milestone)) =>
@@ -272,6 +311,45 @@
         }
       case _ => fail("Could not generate data samples!")
     }
+  }
+
+  test("openMilestone must reset the closed flag correctly".tag(NeedsDatabase)) {
+    (genProjectOwner.sample, genProject.sample, genMilestone.sample) match {
+      case (Some(owner), Some(generatedProject), Some(generatedMilestone)) =>
+        val milestone = generatedMilestone.copy(closed = true)
+        val project   = generatedProject.copy(owner = owner)
+        val dbConfig  = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val test = for {
+          _            <- createProjectOwner(owner)
+          createdRepos <- createTicketsProject(project)
+          repoId       <- loadProjectId(owner.uid, project.name)
+          milestones <- repoId match {
+            case None => IO.pure((None, None))
+            case Some(projectId) =>
+              for {
+                _      <- milestoneRepo.createMilestone(projectId)(milestone)
+                before <- milestoneRepo.findMilestone(projectId)(milestone.title)
+                _      <- before.flatMap(_.id).traverse(milestoneRepo.openMilestone)
+                after  <- milestoneRepo.findMilestone(projectId)(milestone.title)
+              } yield (before, after)
+          }
+        } yield milestones
+        test.map { result =>
+          val (before, after) = result
+          val expected        = before.map(m => milestone.copy(id = m.id))
+          assertEquals(before, expected, "Test milestone not properly initialised!")
+          assertEquals(after, before.map(_.copy(closed = false)), "Test milestone not properly opened!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
   }
 
   test("updateMilestone must update an existing milestone".tag(NeedsDatabase)) {
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-15 05:53:54.394587504 +0000
+++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-15 05:53:54.398587511 +0000
@@ -213,11 +213,12 @@
 
   val genMilestone: Gen[Milestone] =
     for {
-      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
-      title <- genMilestoneTitle
-      due   <- Gen.option(genLocalDate)
-      descr <- Gen.option(genMilestoneDescription)
-    } yield Milestone(id, title, descr, due)
+      id     <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
+      title  <- genMilestoneTitle
+      due    <- Gen.option(genLocalDate)
+      descr  <- Gen.option(genMilestoneDescription)
+      closed <- Gen.oneOf(List(false, true))
+    } yield Milestone(id, title, descr, due, closed)
 
   val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)