From f999662905c337d1edc1eece77b107453bc6d4dc Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sat, 14 Aug 2021 16:45:51 +0200
Subject: [PATCH] Add routes to restore deleted items

---
 .../scala/docspell/backend/ops/OItem.scala    | 13 +++++
 .../scala/docspell/common/ItemState.scala     |  1 +
 .../src/main/resources/docspell-openapi.yml   | 47 ++++++++++++++++++-
 .../restserver/routes/ItemMultiRoutes.scala   |  8 ++++
 .../restserver/routes/ItemRoutes.scala        |  6 +++
 .../scala/docspell/store/records/RItem.scala  | 16 ++++++-
 6 files changed, 89 insertions(+), 2 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index e586bf34..cc2ebfed 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -124,6 +124,8 @@ trait OItem[F[_]] {
       collective: Ident
   ): F[AddResult]
 
+  def restore(items: NonEmptyList[Ident], collective: Ident): F[UpdateResult]
+
   def setItemDate(
       item: NonEmptyList[Ident],
       date: Option[Timestamp],
@@ -582,6 +584,17 @@ object OItem {
             .attempt
             .map(AddResult.fromUpdate)
 
+        def restore(
+            items: NonEmptyList[Ident],
+            collective: Ident
+        ): F[UpdateResult] =
+          UpdateResult.fromUpdate(
+            store
+              .transact(
+                RItem.restoreStateForCollective(items, ItemState.Created, collective)
+              )
+          )
+
         def setItemDate(
             items: NonEmptyList[Ident],
             date: Option[Timestamp],
diff --git a/modules/common/src/main/scala/docspell/common/ItemState.scala b/modules/common/src/main/scala/docspell/common/ItemState.scala
index 05149ad7..e59d5049 100644
--- a/modules/common/src/main/scala/docspell/common/ItemState.scala
+++ b/modules/common/src/main/scala/docspell/common/ItemState.scala
@@ -34,6 +34,7 @@ object ItemState {
   def processing: ItemState = Processing
   def created: ItemState    = Created
   def confirmed: ItemState  = Confirmed
+  def deleted: ItemState    = Deleted
 
   def fromString(str: String): Either[String, ItemState] =
     str.toLowerCase match {
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 48a65da4..2256ae44 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1609,7 +1609,9 @@ paths:
       tags: [ Item ]
       summary: Delete an item.
       description: |
-        Delete an item and all its data permanently.
+        Delete an item and all its data. This is a "soft delete", the
+        item is still in the database and can be undeleted. A periodic
+        job will eventually remove this item from the database.
       security:
         - authTokenHeader: []
       parameters:
@@ -1621,6 +1623,26 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/BasicResult"
+  /sec/item/{id}/restore:
+    post:
+      operationId: "sec-item-restore-by-id"
+      tags: [ Item ]
+      summary: Restore a deleted item.
+      description: |
+        A deleted item can be restored as long it is still in the
+        database. This action sets the item state to `created`.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/id"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+
   /sec/item/{id}/tags:
     put:
       operationId: "sec-item-get-tags"
@@ -2307,6 +2329,29 @@ paths:
               schema:
                 $ref: "#/components/schemas/BasicResult"
 
+  /sec/items/restoreAll:
+    post:
+      operationId: "sec-items-restore-all"
+      tags:
+        - Item (Multi Edit)
+      summary: Restore multiple items.
+      description: |
+        Given a list of item ids, restores all of them.
+      security:
+        - authTokenHeader: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/IdList"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+
   /sec/items/tags:
     post:
       operationId: "sec-items-add-all-tags"
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
index 2ffadde9..771734ec 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
@@ -187,6 +187,14 @@ object ItemMultiRoutes extends MultiIdSupport {
           resp <- Ok(res)
         } yield resp
 
+      case req @ POST -> Root / "restoreAll" =>
+        for {
+          json  <- req.as[IdList]
+          items <- readIds[F](json.ids)
+          res   <- backend.item.restore(items, user.account.collective)
+          resp  <- Ok(Conversions.basicResult(res, "Item(s) deleted"))
+        } yield resp
+
       case req @ PUT -> Root / "customfield" =>
         for {
           json  <- req.as[ItemsAndFieldValue]
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index 71bdbfb7..9453eb69 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -150,6 +150,12 @@ object ItemRoutes {
           resp <- Ok(Conversions.basicResult(res, "Item back to created."))
         } yield resp
 
+      case POST -> Root / Ident(id) / "restore" =>
+        for {
+          res  <- backend.item.restore(NonEmptyList.of(id), user.account.collective)
+          resp <- Ok(Conversions.basicResult(res, "Item restored."))
+        } yield resp
+
       case req @ PUT -> Root / Ident(id) / "tags" =>
         for {
           tags <- req.as[StringList].map(_.items)
diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala
index 017e1992..78eb5416 100644
--- a/modules/store/src/main/scala/docspell/store/records/RItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala
@@ -152,7 +152,21 @@ object RItem {
       t <- currentTime
       n <- DML.update(
         T,
-        T.id.in(itemIds) && T.cid === coll,
+        T.id.in(itemIds) && T.cid === coll && T.state.in(ItemState.validStates),
+        DML.set(T.state.setTo(itemState), T.updated.setTo(t))
+      )
+    } yield n
+
+  def restoreStateForCollective(
+      itemIds: NonEmptyList[Ident],
+      itemState: ItemState,
+      coll: Ident
+  ): ConnectionIO[Int] =
+    for {
+      t <- currentTime
+      n <- DML.update(
+        T,
+        T.id.in(itemIds) && T.cid === coll && T.state === ItemState.deleted,
         DML.set(T.state.setTo(itemState), T.updated.setTo(t))
       )
     } yield n