[#2077] Possbility to revert message deletion
diff --git a/backend/oasst_backend/api/v1/messages.py b/backend/oasst_backend/api/v1/messages.py
index ef95bcd40d..74103b6022 100644
--- a/backend/oasst_backend/api/v1/messages.py
+++ b/backend/oasst_backend/api/v1/messages.py
@@ -14,7 +14,7 @@
 from oasst_shared.exceptions.oasst_api_error import OasstError, OasstErrorCode
 from oasst_shared.schemas import protocol
 from sqlmodel import Session
-from starlette.status import HTTP_204_NO_CONTENT
+from starlette.status import HTTP_202_ACCEPTED, HTTP_204_NO_CONTENT
 
 router = APIRouter()
 
@@ -325,7 +325,19 @@ def mark_message_deleted(
     pr.mark_messages_deleted(message_id)
 
 
-@router.post("/{message_id}/emoji", response_model=protocol.Message)
+@router.put("/{message_id}/undelete", status_code=HTTP_202_ACCEPTED, response_model=None)
+def undelete_message(
+    *,
+    message_id: UUID,
+    frontend_user: deps.FrontendUserId = Depends(deps.get_frontend_user_id),
+    api_client: ApiClient = Depends(deps.get_api_client),
+    db: Session = Depends(deps.get_db),
+):
+    pr = PromptRepository(db, api_client, frontend_user=frontend_user)
+    pr.undelete_deleted_message(message_id)
+
+
+@router.post("/{message_id}/emoji", status_code=HTTP_202_ACCEPTED)
 def post_message_emoji(
     *,
     message_id: UUID,
diff --git a/backend/oasst_backend/prompt_repository.py b/backend/oasst_backend/prompt_repository.py
index 6237d3a502..26c5cc8168 100644
--- a/backend/oasst_backend/prompt_repository.py
+++ b/backend/oasst_backend/prompt_repository.py
@@ -1069,6 +1069,37 @@ def mark_messages_deleted(self, messages: Message | UUID | list[Message | UUID],
 
                 parent_ids = self.db.execute(query).scalars().all()
 
+    @managed_tx_method(CommitMode.COMMIT)
+    def undelete_deleted_message(self, message: Message | UUID):
+        """
+        Undelete deleted messages and all their parents.
+        """
+        message_id = None
+        if isinstance(message, UUID):
+            message_id = message
+        elif isinstance(message, Message):
+            message_id = message.id
+        else:
+            raise OasstError("Server error", OasstErrorCode.SERVER_ERROR1, HTTPStatus.INTERNAL_SERVER_ERROR)
+
+        query = update(Message).where(Message.id == message_id).values(deleted=False)
+        self.db.execute(query)
+
+        parent_id = None
+        if isinstance(message, UUID):
+            parent_id = self.db.query(Message.parent_id).where(Message.id == message_id).first()[0]
+        elif isinstance(message, Message):
+            parent_id = message.parent_id
+
+        if parent_id is None:
+            return
+
+        # Fetching the entire parent_message so there is no parent_id query executed after
+        parent_message: Message = self.db.query(Message).where(Message.id == parent_id).first()
+
+        if parent_message is not None:
+            self.undelete_deleted_message(parent_message)
+
     def get_stats(self) -> SystemStats:
         """
         Get data stats such as number of all messages in the system,
diff --git a/website/src/components/Messages/AdminMessageTable.tsx b/website/src/components/Messages/AdminMessageTable.tsx
index 0adae1f58b..a20ffb9851 100644
--- a/website/src/components/Messages/AdminMessageTable.tsx
+++ b/website/src/components/Messages/AdminMessageTable.tsx
@@ -14,7 +14,7 @@ import {
 } from "@chakra-ui/react";
 import { createColumnHelper } from "@tanstack/table-core";
 import { formatDistanceToNow, formatISO9075 } from "date-fns";
-import { Eye, Trash } from "lucide-react";
+import { Eye, RotateCw, Trash } from "lucide-react";
 import NextLink from "next/link";
 import { useTranslation } from "next-i18next";
 import { useMemo, useState } from "react";
@@ -26,6 +26,7 @@ import { FetchMessagesCursorResponse, Message } from "src/types/Conversation";
 import { isKnownEmoji } from "src/types/Emoji";
 import useSWRImmutable from "swr/immutable";
 
+import { useUndeleteMessage } from "../../hooks/message/useUndeleteMessage";
 import { DataTable, DataTableRowPropsCallback } from "../DataTable/DataTable";
 import { DataTableAction } from "../DataTable/DataTableAction";
 import { useCursorPagination } from "../DataTable/useCursorPagination";
@@ -51,6 +52,7 @@ const DateDiff = ({ children }: { children: string | Date | number }) => {
 
 export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; includeUser?: boolean }) => {
   const [deleteMessageId, setDeleteMessageId] = useState<string | null>(null);
+  const [undeleteMessageId, setUndeleteMessageId] = useState<string | null>(null);
   const [activeMessageId, setActiveMessageId] = useState<string>();
   const { pagination, toNextPage, toPreviousPage } = useCursorPagination();
   const {
@@ -73,10 +75,22 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
   const data = useMemo(() => {
     return res?.items.map((m) => ({ ...m, isActive: m.id === activeMessageId })) || [];
   }, [activeMessageId, res?.items]);
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  const { isMutating, trigger } = useDeleteMessage(deleteMessageId!, mutateMessageList);
 
-  const { isOpen, onOpen, onClose } = useDisclosure();
+  const { isMutating: isDeleteMutating, trigger: deleteTrigger } = useDeleteMessage(
+    deleteMessageId!,
+    mutateMessageList
+  );
+  const { isMutating: isUndeleteMutating, trigger: undeleteTrigger } = useUndeleteMessage(
+    undeleteMessageId,
+    mutateMessageList
+  );
+
+  const { isOpen, onOpen, onClose: disclosureClose } = useDisclosure();
+  const onClose = () => {
+    disclosureClose();
+    if (deleteMessageId) setDeleteMessageId(null);
+    if (undeleteMessageId) setUndeleteMessageId(null);
+  };
 
   const columns = useMemo(() => {
     return [
@@ -184,7 +198,7 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
                 icon={Eye}
                 aria-label="View message"
               />
-              {!row.original.deleted && (
+              {!row.original.deleted ? (
                 <DataTableAction
                   onClick={() => {
                     setDeleteMessageId(id);
@@ -192,7 +206,17 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
                   }}
                   icon={Trash}
                   aria-label="Delete message"
-                  isLoading={isMutating && deleteMessageId === id}
+                  isLoading={isDeleteMutating && deleteMessageId === id}
+                />
+              ) : (
+                <DataTableAction
+                  onClick={() => {
+                    setUndeleteMessageId(id);
+                    onOpen();
+                  }}
+                  icon={RotateCw}
+                  aria-label="Undelete message"
+                  isLoading={isUndeleteMutating && undeleteMessageId === id}
                 />
               )}
             </HStack>
@@ -200,7 +224,7 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
         },
       }),
     ];
-  }, [deleteMessageId, isMutating, onOpen]);
+  }, [deleteMessageId, isDeleteMutating, isUndeleteMutating, onOpen, undeleteMessageId]);
 
   const { t } = useTranslation(["common", "message"]);
   const rowProps: DataTableRowPropsCallback<Message> = useCallback(
@@ -219,10 +243,15 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
       <Modal isOpen={isOpen} onClose={onClose} isCentered>
         <ModalOverlay />
         <ModalContent>
-          <ModalHeader>Confirm deleting this message</ModalHeader>
+          <ModalHeader>Confirm {deleteMessageId ? "deleting" : "undeleting"} this message</ModalHeader>
           <ModalCloseButton />
           <ModalBody>
-            <div>Delete this message?</div>
+            <p>
+              {undeleteMessageId
+                ? "By undeleting this message you take the risk to undelete every parent messages that may also be deleted."
+                : ""}
+            </p>
+            {deleteMessageId ? "Delete" : "Are you sure to undelete"} this message? <p></p>
           </ModalBody>
           <ModalFooter>
             <Button variant="ghost" mr={3} onClick={onClose}>
@@ -230,9 +259,10 @@ export const AdminMessageTable = ({ userId, includeUser }: { userId?: string; in
             </Button>
             <Button
               colorScheme="blue"
-              onClick={() => {
+              onClick={async () => {
+                if (deleteMessageId) await deleteTrigger();
+                else await undeleteTrigger();
                 onClose();
-                trigger();
               }}
             >
               {t("confirm")}
diff --git a/website/src/components/Messages/MessageTableEntry.tsx b/website/src/components/Messages/MessageTableEntry.tsx
index 19c8ce20ab..c761f54048 100644
--- a/website/src/components/Messages/MessageTableEntry.tsx
+++ b/website/src/components/Messages/MessageTableEntry.tsx
@@ -23,6 +23,7 @@ import {
   Link,
   MessageSquare,
   MoreHorizontal,
+  RefreshCw,
   Shield,
   Slash,
   Trash,
@@ -46,6 +47,7 @@ import { Message, MessageEmojis } from "src/types/Conversation";
 import { emojiIcons, isKnownEmoji } from "src/types/Emoji";
 import useSWRMutation from "swr/mutation";
 
+import { useUndeleteMessage } from "../../hooks/message/useUndeleteMessage";
 import { BaseMessageEntry } from "./BaseMessageEntry";
 import { MessageInlineEmojiRow } from "./MessageInlineEmojiRow";
 import { MessageSyntheticBadge } from "./MessageSyntheticBadge";
@@ -250,6 +252,11 @@ const MessageActions = ({
   });
 
   const { trigger: handleDelete } = useDeleteMessage(message.id);
+  const { trigger: undeleteTrigger } = useUndeleteMessage(message.id);
+
+  const handleUndelete = () => {
+    undeleteTrigger();
+  };
 
   const handleStop = () => {
     stopTree();
@@ -323,10 +330,14 @@ const MessageActions = ({
               <MenuItem as={NextLink} href={ROUTES.ADMIN_USER_DETAIL(message.user_id)} target="_blank" icon={<User />}>
                 {t("view_user")}
               </MenuItem>
-              {!message.deleted && (
+              {!message.deleted ? (
                 <MenuItem onClick={handleDelete} icon={<Trash />}>
                   {t("common:delete")}
                 </MenuItem>
+              ) : (
+                <MenuItem onClick={handleUndelete} icon={<RefreshCw />}>
+                  Undelete message
+                </MenuItem>
               )}
               <MenuItem onClick={handleStop} icon={<Slash />}>
                 {t("stop_tree")}
diff --git a/website/src/hooks/message/useUndeleteMessage.ts b/website/src/hooks/message/useUndeleteMessage.ts
new file mode 100644
index 0000000000..7da03ca77e
--- /dev/null
+++ b/website/src/hooks/message/useUndeleteMessage.ts
@@ -0,0 +1,18 @@
+import { put } from "src/lib/api";
+import { API_ROUTES } from "src/lib/routes";
+import { mutate } from "swr";
+import useSWRMutation from "swr/mutation";
+
+export const useUndeleteMessage = (messageId: string, onSuccess?: () => void) => {
+  return useSWRMutation(API_ROUTES.ADMIN_UNDELETE_MESSAGE(messageId), put, {
+    onSuccess: async () => {
+      onSuccess?.();
+      await mutate(
+        (key) =>
+          typeof key === "string" && (key.startsWith("/api/messages") || key.startsWith("/api/admin/user_messages")),
+        undefined,
+        { revalidate: true }
+      );
+    },
+  });
+};
diff --git a/website/src/lib/oasst_api_client.ts b/website/src/lib/oasst_api_client.ts
index 7204b4e6da..75bcfaaf7d 100644
--- a/website/src/lib/oasst_api_client.ts
+++ b/website/src/lib/oasst_api_client.ts
@@ -200,6 +200,13 @@ export class OasstApiClient {
     return this.delete<void>(`/api/v1/messages/${message_id}`);
   }
 
+  /**
+   * Undelete a message by its id
+   */
+  async undelete_message(message_id: string): Promise<void> {
+    return this.put<void>(`/api/v1/messages/${message_id}/undelete`);
+  }
+
   /**
    * Stop message tree
    */
diff --git a/website/src/lib/routes.ts b/website/src/lib/routes.ts
index f76fd28d33..ef3a002843 100644
--- a/website/src/lib/routes.ts
+++ b/website/src/lib/routes.ts
@@ -46,9 +46,9 @@ export const API_ROUTES = {
   AVAILABLE_TASK: withLang("/api/available_tasks"),
   RECENT_MESSAGES: withLang("/api/messages"),
   ADMIN_DELETE_MESSAGE: (messageId: string) => createRoute(`/api/admin/delete_message/${messageId}`),
+  ADMIN_UNDELETE_MESSAGE: (messageId: string) => createRoute(`/api/admin/undelete_message/${messageId}`),
   ADMIN_MESSAGE_LIST: (query: CursorPaginationState & { user_id?: string; include_user?: boolean }) =>
     createRoute("/api/admin/messages", query),
-
   // chat:
   GET_CHAT: (chat_id: string) => createRoute(`/api/chat`, { chat_id }),
   GET_MESSAGE: (chat_id: string, message_id) => createRoute(`/api/chat/message`, { chat_id, message_id }),
diff --git a/website/src/pages/404.tsx b/website/src/pages/404.tsx
index ac8ad416a5..618d7b6cfd 100644
--- a/website/src/pages/404.tsx
+++ b/website/src/pages/404.tsx
@@ -1,7 +1,7 @@
 import { Box, Button, Center, Link, Text, theme, useColorModeValue } from "@chakra-ui/react";
-import { useTranslation } from "next-i18next";
 import { AlertTriangle } from "lucide-react";
 import Head from "next/head";
+import { useTranslation } from "next-i18next";
 import { EmptyState } from "src/components/EmptyState";
 import { getTransparentHeaderLayout } from "src/components/Layout";
 export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props";
diff --git a/website/src/pages/admin/trollboard.tsx b/website/src/pages/admin/trollboard.tsx
index c8f36594cf..28b9c2c133 100644
--- a/website/src/pages/admin/trollboard.tsx
+++ b/website/src/pages/admin/trollboard.tsx
@@ -1,4 +1,3 @@
-import React from "react";
 import {
   Box,
   Card,
@@ -16,6 +15,7 @@ import {
 } from "@chakra-ui/react";
 import Head from "next/head";
 import { useTranslation } from "next-i18next";
+import React from "react";
 export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props";
 import { AdminArea } from "src/components/AdminArea";
 import { getAdminLayout } from "src/components/Layout";
diff --git a/website/src/pages/api/admin/undelete_message/[id].ts b/website/src/pages/api/admin/undelete_message/[id].ts
new file mode 100644
index 0000000000..9ac87c1bc6
--- /dev/null
+++ b/website/src/pages/api/admin/undelete_message/[id].ts
@@ -0,0 +1,15 @@
+import { withAnyRole } from "src/lib/auth";
+import { createApiClient } from "src/lib/oasst_client_factory";
+
+const handler = withAnyRole(["admin", "moderator"], async (req, res, token) => {
+  const { id } = req.query;
+  try {
+    const client = await createApiClient(token);
+    await client.undelete_message(id as string);
+    res.status(200).json({ message: "Message undeleted" });
+  } catch (e) {
+    res.status(500).json({ message: "Failed to undelete the message" });
+  }
+});
+
+export default handler;

Hello, here is the PR for that feature ! 

On the web side I've added a button when a message is deleted in the context menu : 
![image](https://user-images.githubusercontent.com/12647984/225399397-34c51336-8e52-4485-9726-60f0659e845b.png)

Also added that feature to the messages board :
![image](https://user-images.githubusercontent.com/12647984/225399584-b63a5e3c-09cd-431d-91a6-5897b0caae95.png)

With a warning message on reintroduction : 
![image](https://user-images.githubusercontent.com/12647984/225399750-4b46f418-91b1-4f49-8358-11aae2d317fc.png)

Might need to still do the translation part, however I don't see any "admin" local :/ 


Original issue : https://github.com/LAION-AI/Open-Assistant/issues/2077