From 35ff70bf146aece924c54df76beeec385eb09507 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sun, 6 Oct 2024 18:07:11 +0530 Subject: [PATCH] Archive collections (#7266) Co-authored-by: Tom Moor --- app/actions/definitions/collections.tsx | 87 +++++- app/components/CollectionBreadcrumb.tsx | 45 ++++ app/components/Sidebar/App.tsx | 14 +- .../Sidebar/components/ArchiveLink.tsx | 112 ++++++-- .../components/ArchivedCollectionLink.tsx | 47 ++++ .../Sidebar/components/CollectionLink.tsx | 7 +- .../Sidebar/components/Collections.tsx | 4 +- .../Sidebar/hooks/useDragAndDrop.tsx | 51 +++- app/components/WebsocketProvider.tsx | 42 +++ app/menus/CollectionMenu.tsx | 4 + app/menus/DocumentMenu.tsx | 9 +- app/models/Collection.ts | 31 +++ app/models/base/ArchivableModel.ts | 2 +- app/scenes/Collection/components/Notices.tsx | 29 ++ app/scenes/Collection/index.tsx | 65 +++-- app/stores/CollectionsStore.ts | 86 +++++- app/stores/DocumentsStore.ts | 41 ++- .../server/tasks/DeliverWebhookTask.ts | 2 + ...61527-add-column-archivedAt-collections.js | 27 ++ ...-add-column-archivedById-to-collections.js | 18 ++ server/models/Collection.test.ts | 61 +++++ server/models/Collection.ts | 62 ++++- server/models/Document.ts | 173 +++++++----- server/policies/collection.test.ts | 144 ++++++++-- server/policies/collection.ts | 70 ++++- server/presenters/collection.ts | 3 + .../queues/processors/WebsocketsProcessor.ts | 65 ++++- .../api/collections/collections.test.ts | 189 ++++++++++++- server/routes/api/collections/collections.ts | 207 ++++++++++++-- server/routes/api/collections/schema.ts | 24 +- server/routes/api/documents/documents.test.ts | 249 +++++++++++++++++ server/routes/api/documents/documents.ts | 253 ++++++++++++------ server/routes/api/documents/schema.ts | 3 + server/test/factories.ts | 4 + server/types.ts | 18 +- server/utils/indexing.ts | 7 +- shared/i18n/locales/en_US/translation.json | 13 +- shared/types.ts | 4 + 38 files changed, 1983 insertions(+), 289 deletions(-) create mode 100644 app/components/CollectionBreadcrumb.tsx create mode 100644 app/components/Sidebar/components/ArchivedCollectionLink.tsx create mode 100644 app/scenes/Collection/components/Notices.tsx create mode 100644 server/migrations/20240717061527-add-column-archivedAt-collections.js create mode 100644 server/migrations/20240809054702-add-column-archivedById-to-collections.js diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index 6ab1e5d6d0..d0f4af5890 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -1,8 +1,10 @@ import { + ArchiveIcon, CollectionIcon, EditIcon, PadlockIcon, PlusIcon, + RestoreIcon, SearchIcon, ShapesIcon, StarredIcon, @@ -10,11 +12,13 @@ import { UnstarredIcon, } from "outline-icons"; import * as React from "react"; +import { toast } from "sonner"; import stores from "~/stores"; import Collection from "~/models/Collection"; import { CollectionEdit } from "~/components/Collection/CollectionEdit"; import { CollectionNew } from "~/components/Collection/CollectionNew"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import DynamicCollectionIcon from "~/components/Icons/CollectionIcon"; import SharePopover from "~/components/Sharing/Collection/SharePopover"; import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; @@ -129,9 +133,20 @@ export const searchInCollection = createAction({ analyticsName: "Search collection", section: ActiveCollectionSection, icon: , - visible: ({ activeCollectionId }) => - !!activeCollectionId && - stores.policies.abilities(activeCollectionId).readDocument, + visible: ({ activeCollectionId }) => { + if (!activeCollectionId) { + return false; + } + + const collection = stores.collections.get(activeCollectionId); + + if (!collection?.isActive) { + return false; + } + + return stores.policies.abilities(activeCollectionId).readDocument; + }, + perform: ({ activeCollectionId }) => { history.push(searchPath(undefined, { collectionId: activeCollectionId })); }, @@ -190,6 +205,72 @@ export const unstarCollection = createAction({ }, }); +export const archiveCollection = createAction({ + name: ({ t }) => `${t("Archive")}…`, + analyticsName: "Archive collection", + section: CollectionSection, + icon: , + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + return !!stores.policies.abilities(activeCollectionId).archive; + }, + perform: async ({ activeCollectionId, stores, t }) => { + const { dialogs, collections } = stores; + if (!activeCollectionId) { + return; + } + const collection = collections.get(activeCollectionId); + if (!collection) { + return; + } + + dialogs.openModal({ + title: t("Archive collection"), + content: ( + { + await collection.archive(); + toast.success(t("Collection archived")); + }} + submitText={t("Archive")} + savingText={`${t("Archiving")}…`} + > + {t( + "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results." + )} + + ), + }); + }, +}); + +export const restoreCollection = createAction({ + name: ({ t }) => t("Restore"), + analyticsName: "Restore collection", + section: CollectionSection, + icon: , + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + return !!stores.policies.abilities(activeCollectionId).restore; + }, + perform: async ({ activeCollectionId, stores, t }) => { + if (!activeCollectionId) { + return; + } + const collection = stores.collections.get(activeCollectionId); + if (!collection) { + return; + } + + await collection.restore(); + toast.success(t("Collection restored")); + }, +}); + export const deleteCollection = createAction({ name: ({ t }) => `${t("Delete")}…`, analyticsName: "Delete collection", diff --git a/app/components/CollectionBreadcrumb.tsx b/app/components/CollectionBreadcrumb.tsx new file mode 100644 index 0000000000..bf8c59648f --- /dev/null +++ b/app/components/CollectionBreadcrumb.tsx @@ -0,0 +1,45 @@ +import { ArchiveIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Collection from "~/models/Collection"; +import CollectionIcon from "~/components/Icons/CollectionIcon"; +import { MenuInternalLink } from "~/types"; +import { archivePath, collectionPath } from "~/utils/routeHelpers"; +import Breadcrumb from "./Breadcrumb"; + +type Props = { + collection: Collection; +}; + +export const CollectionBreadcrumb: React.FC = ({ collection }) => { + const { t } = useTranslation(); + + const items = React.useMemo(() => { + const collectionNode: MenuInternalLink = { + type: "route", + title: collection.name, + icon: , + to: collectionPath(collection.path), + }; + + const category: MenuInternalLink | undefined = collection.isArchived + ? { + type: "route", + icon: , + title: t("Archive"), + to: archivePath(), + } + : undefined; + + const output = []; + if (category) { + output.push(category); + } + + output.push(collectionNode); + + return output; + }, [collection, t]); + + return ; +}; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index f0dc3e673b..e3bfd91d47 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -133,16 +133,16 @@ function AppSidebar() {
-
+
+ {can.createDocument && ( +
+ +
+ )}
- {can.createDocument && ( - <> - - - - )} + {can.createDocument && }
diff --git a/app/components/Sidebar/components/ArchiveLink.tsx b/app/components/Sidebar/components/ArchiveLink.tsx index cfa44b54e2..8315ebc83e 100644 --- a/app/components/Sidebar/components/ArchiveLink.tsx +++ b/app/components/Sidebar/components/ArchiveLink.tsx @@ -1,41 +1,101 @@ +import isUndefined from "lodash/isUndefined"; import { observer } from "mobx-react"; import { ArchiveIcon } from "outline-icons"; import * as React from "react"; -import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; +import Flex from "@shared/components/Flex"; +import Collection from "~/models/Collection"; +import PaginatedList from "~/components/PaginatedList"; +import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import { archivePath } from "~/utils/routeHelpers"; -import SidebarLink, { DragObject } from "./SidebarLink"; +import { useDropToArchive } from "../hooks/useDragAndDrop"; +import { ArchivedCollectionLink } from "./ArchivedCollectionLink"; +import { StyledError } from "./Collections"; +import PlaceholderCollections from "./PlaceholderCollections"; +import Relative from "./Relative"; +import SidebarLink from "./SidebarLink"; function ArchiveLink() { - const { policies, documents } = useStores(); + const { collections } = useStores(); const { t } = useTranslation(); - const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({ - accept: "document", - drop: async (item: DragObject) => { - const document = documents.get(item.id); - await document?.archive(); - toast.success(t("Document archived")); - }, - canDrop: (item) => policies.abilities(item.id).archive, - collect: (monitor) => ({ - isDocumentDropping: monitor.isOver(), - }), - }); + const [disclosure, setDisclosure] = React.useState(false); + const [expanded, setExpanded] = React.useState(); + + const { request, data, loading, error } = useRequest( + collections.fetchArchived, + true + ); + + React.useEffect(() => { + if (!isUndefined(data) && !loading && isUndefined(error)) { + setDisclosure(data.length > 0); + } + }, [data, loading, error]); + + React.useEffect(() => { + setDisclosure(collections.archived.length > 0); + }, [collections.archived]); + + React.useEffect(() => { + if (disclosure && isUndefined(expanded)) { + setExpanded(false); + } + }, [disclosure]); + + React.useEffect(() => { + if (expanded) { + void request(); + } + }, [expanded, request]); + + const handleDisclosureClick = React.useCallback((ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setExpanded((e) => !e); + }, []); + + const handleClick = React.useCallback(() => { + setExpanded(true); + }, []); + + const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] = + useDropToArchive(); return ( -
- } - exact={false} - label={t("Archive")} - active={documents.active?.isArchived && !documents.active?.isDeleted} - isActiveDrop={isDocumentDropping} - /> -
+ +
+ } + exact={false} + label={t("Archive")} + isActiveDrop={isOverArchiveSection && isDragging} + depth={0} + expanded={disclosure ? expanded : undefined} + onDisclosureClick={handleDisclosureClick} + onClick={handleClick} + /> +
+ {expanded === true ? ( + + } + renderError={(props) => } + renderItem={(item: Collection) => ( + + )} + /> + + ) : null} +
); } diff --git a/app/components/Sidebar/components/ArchivedCollectionLink.tsx b/app/components/Sidebar/components/ArchivedCollectionLink.tsx new file mode 100644 index 0000000000..413d0f2630 --- /dev/null +++ b/app/components/Sidebar/components/ArchivedCollectionLink.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import Collection from "~/models/Collection"; +import useStores from "~/hooks/useStores"; +import CollectionLink from "./CollectionLink"; +import CollectionLinkChildren from "./CollectionLinkChildren"; +import Relative from "./Relative"; + +type Props = { + collection: Collection; + depth?: number; +}; + +export function ArchivedCollectionLink({ collection, depth }: Props) { + const { documents } = useStores(); + + const [expanded, setExpanded] = React.useState(false); + + const handleDisclosureClick = React.useCallback((ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setExpanded((e) => !e); + }, []); + + const handleClick = React.useCallback(() => { + setExpanded(true); + }, []); + + return ( + <> + + + + + + ); +} diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 2587bbdc66..fee3734ea2 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -30,6 +30,8 @@ type Props = { onDisclosureClick: (ev?: React.MouseEvent) => void; activeDocument: Document | undefined; isDraggingAnyCollection?: boolean; + depth?: number; + onClick?: () => void; }; const CollectionLink: React.FC = ({ @@ -37,6 +39,8 @@ const CollectionLink: React.FC = ({ expanded, onDisclosureClick, isDraggingAnyCollection, + depth, + onClick, }: Props) => { const { dialogs, documents, collections } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); @@ -115,6 +119,7 @@ const CollectionLink: React.FC = ({ = ({ /> } exact={false} - depth={0} + depth={depth ? depth : 0} menu={ !isEditing && !isDraggingAnyCollection && ( diff --git a/app/components/Sidebar/components/Collections.tsx b/app/components/Sidebar/components/Collections.tsx index 1b5419b31d..7ee0194200 100644 --- a/app/components/Sidebar/components/Collections.tsx +++ b/app/components/Sidebar/components/Collections.tsx @@ -55,7 +55,7 @@ function Collections() { } heading={ isDraggingAnyCollection ? ( @@ -84,7 +84,7 @@ function Collections() { ); } -const StyledError = styled(Error)` +export const StyledError = styled(Error)` font-size: 15px; padding: 0 8px; `; diff --git a/app/components/Sidebar/hooks/useDragAndDrop.tsx b/app/components/Sidebar/hooks/useDragAndDrop.tsx index c9ce217fc5..26ce3759ba 100644 --- a/app/components/Sidebar/hooks/useDragAndDrop.tsx +++ b/app/components/Sidebar/hooks/useDragAndDrop.tsx @@ -149,6 +149,7 @@ export function useDragDocument( icon: icon ? : undefined, collectionId: document?.collectionId || "", } as DragObject), + canDrag: () => !!document?.isActive, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), @@ -245,6 +246,7 @@ export function useDropToReparentDocument( !!pathToNode && !pathToNode.includes(monitor.getItem().id) && item.id !== node.id && + !!document?.isActive && policies.abilities(node.id).update && policies.abilities(item.id).move, hover: (_item, monitor) => { @@ -297,6 +299,8 @@ export function useDropToReorderDocument( const { t } = useTranslation(); const { documents, collections, dialogs, policies } = useStores(); + const document = documents.get(node.id); + return useDrop< DragObject, Promise, @@ -304,7 +308,11 @@ export function useDropToReorderDocument( >({ accept: "document", canDrop: (item: DragObject) => { - if (item.id === node.id || !policies.abilities(item.id)?.move) { + if ( + item.id === node.id || + !policies.abilities(item.id)?.move || + !document?.isActive + ) { return false; } @@ -427,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) { }), }); } + +/** + * Hook for shared logic that allows dropping documents and collections onto archive section + */ +export function useDropToArchive() { + const accept = ["document", "collection"]; + const { documents, collections, policies } = useStores(); + const { t } = useTranslation(); + + return useDrop< + DragObject, + Promise, + { isOverArchiveSection: boolean; isDragging: boolean } + >({ + accept, + drop: async (item, monitor) => { + const type = monitor.getItemType(); + let model; + + if (type === "collection") { + model = collections.get(item.id); + } else { + model = documents.get(item.id); + } + + if (model) { + await model.archive(); + toast.success( + type === "collection" + ? t("Collection archived") + : t("Document archived") + ); + } + }, + canDrop: (item) => policies.abilities(item.id).archive, + collect: (monitor) => ({ + isOverArchiveSection: !!monitor.isOver(), + isDragging: monitor.canDrop(), + }), + }); +} diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 65e4820fad..ff297d67d6 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -407,6 +407,48 @@ class WebsocketProvider extends React.Component { }) ); + this.socket.on( + "collections.archive", + async (event: PartialExcept) => { + const collectionId = event.id; + + // Fetch collection to update policies + await collections.fetch(collectionId, { force: true }); + + documents.unarchivedInCollection(collectionId).forEach( + action((doc) => { + if (!doc.publishedAt) { + // draft is to be detached from collection, not archived + doc.collectionId = null; + } else { + doc.archivedAt = event.archivedAt as string; + } + policies.remove(doc.id); + }) + ); + } + ); + + this.socket.on( + "collections.restore", + async (event: PartialExcept) => { + const collectionId = event.id; + documents + .archivedInCollection(collectionId, { + archivedAt: event.archivedAt as string, + }) + .forEach( + action((doc) => { + doc.archivedAt = null; + policies.remove(doc.id); + }) + ); + + // Fetch collection to update policies + await collections.fetch(collectionId, { force: true }); + } + ); + this.socket.on("teams.update", (event: PartialExcept) => { if ("sharing" in event && event.sharing !== auth.team?.sharing) { documents.all.forEach((document) => { diff --git a/app/menus/CollectionMenu.tsx b/app/menus/CollectionMenu.tsx index 0dc95fe4bc..7dc0d9c752 100644 --- a/app/menus/CollectionMenu.tsx +++ b/app/menus/CollectionMenu.tsx @@ -29,6 +29,8 @@ import { unstarCollection, searchInCollection, createTemplate, + archiveCollection, + restoreCollection, } from "~/actions/definitions/collections"; import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; @@ -151,6 +153,7 @@ function CollectionMenu({ const canUserInTeam = usePolicy(team); const items: MenuItem[] = React.useMemo( () => [ + actionToMenuItem(restoreCollection, context), actionToMenuItem(starCollection, context), actionToMenuItem(unstarCollection, context), { @@ -224,6 +227,7 @@ function CollectionMenu({ onClick: handleExport, icon: , }, + actionToMenuItem(archiveCollection, context), actionToMenuItem(searchInCollection, context), { type: "separator", diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 04fee5e12d..97b86ed1e8 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -215,8 +215,8 @@ const MenuContent: React.FC = ({ type: "button", title: t("Restore"), visible: - ((document.isWorkspaceTemplate || !!collection) && can.restore) || - !!can.unarchive, + !!(document.isWorkspaceTemplate || collection?.isActive) && + !!(can.restore || can.unarchive), onClick: (ev) => handleRestore(ev), icon: , }, @@ -224,9 +224,8 @@ const MenuContent: React.FC = ({ type: "submenu", title: t("Restore"), visible: - !document.isWorkspaceTemplate && - !collection && - !!can.restore && + !(document.isWorkspaceTemplate || collection?.isActive) && + !!(can.restore || can.unarchive) && restoreItems.length !== 0, style: { left: -170, diff --git a/app/models/Collection.ts b/app/models/Collection.ts index c469fcca2e..5dfec65c86 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -80,6 +80,18 @@ export default class Collection extends ParanoidModel { @observable urlId: string; + /** + * The date and time the collection was archived. + */ + @observable + archivedAt: string; + + /** + * User who archived the collection. + */ + @observable + archivedBy?: User; + /** Returns whether the collection is empty, or undefined if not loaded. */ @computed get isEmpty(): boolean | undefined { @@ -154,6 +166,21 @@ export default class Collection extends ParanoidModel { .filter(Boolean); } + @computed + get isArchived() { + return !!this.archivedAt; + } + + @computed + get isDeleted() { + return !!this.deletedAt; + } + + @computed + get isActive() { + return !this.isArchived && !this.isDeleted; + } + fetchDocuments = async (options?: { force: boolean }) => { if (this.isFetching) { return; @@ -314,6 +341,10 @@ export default class Collection extends ParanoidModel { @action unstar = async () => this.store.unstar(this); + archive = () => this.store.archive(this); + + restore = () => this.store.restore(this); + export = (format: FileOperationFormat, includeAttachments: boolean) => client.post("/collections.export", { id: this.id, diff --git a/app/models/base/ArchivableModel.ts b/app/models/base/ArchivableModel.ts index d6489368d7..2ab3c63e7c 100644 --- a/app/models/base/ArchivableModel.ts +++ b/app/models/base/ArchivableModel.ts @@ -3,5 +3,5 @@ import ParanoidModel from "./ParanoidModel"; export default abstract class ArchivableModel extends ParanoidModel { @observable - archivedAt: string | undefined; + archivedAt: string | null; } diff --git a/app/scenes/Collection/components/Notices.tsx b/app/scenes/Collection/components/Notices.tsx new file mode 100644 index 0000000000..345053e251 --- /dev/null +++ b/app/scenes/Collection/components/Notices.tsx @@ -0,0 +1,29 @@ +import { ArchiveIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Collection from "~/models/Collection"; +import ErrorBoundary from "~/components/ErrorBoundary"; +import Notice from "~/components/Notice"; +import Time from "~/components/Time"; + +type Props = { + collection: Collection; +}; + +export default function Notices({ collection }: Props) { + const { t } = useTranslation(); + + return ( + + {collection.isArchived && !collection.isDeleted && ( + }> + {t("Archived by {{userName}}", { + userName: collection.archivedBy?.name ?? t("Unknown"), + })} +   + + )} + + ); +} diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index 449014dc93..f8f391c428 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -13,11 +13,13 @@ import { import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; +import { StatusFilter } from "@shared/types"; import { colorPalette } from "@shared/utils/collections"; import Collection from "~/models/Collection"; import Search from "~/scenes/Search"; import { Action } from "~/components/Actions"; import CenteredContent from "~/components/CenteredContent"; +import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb"; import CollectionDescription from "~/components/CollectionDescription"; import Heading from "~/components/Heading"; import Icon, { IconTitleWrapper } from "~/components/Icon"; @@ -28,6 +30,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PinnedDocuments from "~/components/PinnedDocuments"; import PlaceholderText from "~/components/PlaceholderText"; import Scene from "~/components/Scene"; +import Subheading from "~/components/Subheading"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; import { editCollection } from "~/actions/definitions/collections"; @@ -41,6 +44,7 @@ import Actions from "./components/Actions"; import DropToImport from "./components/DropToImport"; import Empty from "./components/Empty"; import MembershipPreview from "./components/MembershipPreview"; +import Notices from "./components/Notices"; import ShareButton from "./components/ShareButton"; const IconPicker = React.lazy(() => import("~/components/IconPicker")); @@ -132,7 +136,9 @@ function CollectionScene() { centered={false} textTitle={collection.name} left={ - collection.isEmpty ? undefined : ( + collection.isArchived ? ( + + ) : collection.isEmpty ? undefined : ( + {can.update ? ( @@ -192,26 +199,28 @@ function CollectionScene() { - - - {t("Documents")} - - - {t("Recently updated")} - - - {t("Recently published")} - - - {t("Least recently updated")} - - - {t("A–Z")} - - + {!collection.isArchived && ( + + + {t("Documents")} + + + {t("Recently updated")} + + + {t("Recently published")} + + + {t("Least recently updated")} + + + {t("A–Z")} + + + )} {collection.isEmpty ? ( - ) : ( + ) : !collection.isArchived ? ( + ) : ( + + + {t("Documents")}} + options={{ + collectionId: collection.id, + parentDocumentId: null, + sort: collection.sort.field, + direction: collection.sort.direction, + statusFilter: [StatusFilter.Archived], + }} + showParentDocuments + /> + + )} diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index e077d60812..b0676bc94e 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -1,11 +1,16 @@ import invariant from "invariant"; import find from "lodash/find"; import isEmpty from "lodash/isEmpty"; +import orderBy from "lodash/orderBy"; import sortBy from "lodash/sortBy"; -import { computed, action } from "mobx"; -import { CollectionPermission, FileOperationFormat } from "@shared/types"; +import { computed, action, runInAction } from "mobx"; +import { + CollectionPermission, + CollectionStatusFilter, + FileOperationFormat, +} from "@shared/types"; import Collection from "~/models/Collection"; -import { Properties } from "~/types"; +import { PaginationParams, Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store from "./base/Store"; @@ -27,6 +32,11 @@ export default class CollectionsStore extends Store { : undefined; } + @computed + get allActive() { + return this.orderedData.filter((c) => c.isActive); + } + @computed get orderedData(): Collection[] { let collections = Array.from(this.data.values()); @@ -97,6 +107,30 @@ export default class CollectionsStore extends Store { } }; + @action + archive = async (collection: Collection) => { + const res = await client.post("/collections.archive", { + id: collection.id, + }); + runInAction("Collection#archive", () => { + invariant(res?.data, "Data should be available"); + this.add(res.data); + this.addPolicies(res.policies); + }); + }; + + @action + restore = async (collection: Collection) => { + const res = await client.post("/collections.restore", { + id: collection.id, + }); + runInAction("Collection#restore", () => { + invariant(res?.data, "Data should be available"); + this.add(res.data); + this.addPolicies(res.policies); + }); + }; + async update(params: Properties): Promise { const result = await super.update(params); @@ -119,6 +153,52 @@ export default class CollectionsStore extends Store { return model; } + @action + fetchNamedPage = async ( + request = "list", + options: + | (PaginationParams & { statusFilter: CollectionStatusFilter[] }) + | undefined + ): Promise => { + this.isFetching = true; + + try { + const res = await client.post(`/collections.${request}`, options); + invariant(res?.data, "Collection list not available"); + runInAction("CollectionsStore#fetchNamedPage", () => { + res.data.forEach(this.add); + this.addPolicies(res.policies); + this.isLoaded = true; + }); + return res.data; + } finally { + this.isFetching = false; + } + }; + + @action + fetchArchived = async (options?: PaginationParams): Promise => + this.fetchNamedPage("list", { + ...options, + statusFilter: [CollectionStatusFilter.Archived], + }); + + @computed + get archived(): Collection[] { + return orderBy(this.orderedData, "archivedAt", "desc").filter( + (c) => c.isArchived && !c.isDeleted + ); + } + + @computed + get publicCollections() { + return this.orderedData.filter( + (collection) => + collection.permission && + Object.values(CollectionPermission).includes(collection.permission) + ); + } + star = async (collection: Collection, index?: string) => { await this.rootStore.stars.create({ collectionId: collection.id, diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 850a816df3..8639ecefe2 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -121,6 +121,33 @@ export default class DocumentsStore extends Store { ); } + archivedInCollection( + collectionId: string, + options?: { archivedAt: string } + ): Document[] { + const filterCond = (document: Document) => + options + ? document.collectionId === collectionId && + document.isArchived && + document.archivedAt === options.archivedAt && + !document.isDeleted + : document.collectionId === collectionId && + document.isArchived && + !document.isDeleted; + + return filter(this.orderedData, filterCond); + } + + unarchivedInCollection(collectionId: string): Document[] { + return filter( + this.orderedData, + (document) => + document.collectionId === collectionId && + !document.isArchived && + !document.isDeleted + ); + } + templatesInCollection(collectionId: string): Document[] { return orderBy( filter( @@ -313,8 +340,18 @@ export default class DocumentsStore extends Store { }; @action - fetchArchived = async (options?: PaginationParams): Promise => - this.fetchNamedPage("archived", options); + fetchArchived = async (options?: PaginationParams): Promise => { + const archivedInResponse = await this.fetchNamedPage("archived", options); + const archivedInMemory = this.archived; + + archivedInMemory.forEach((docInMemory) => { + !archivedInResponse.find( + (docInResponse) => docInResponse.id === docInMemory.id + ) && this.remove(docInMemory.id); + }); + + return archivedInResponse; + }; @action fetchDeleted = async (options?: PaginationParams): Promise => diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index 867249856d..801e3a6f7b 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -161,6 +161,8 @@ export default class DeliverWebhookTask extends BaseTask { case "collections.delete": case "collections.move": case "collections.permission_changed": + case "collections.archive": + case "collections.restore": await this.handleCollectionEvent(subscription, event); return; case "collections.add_user": diff --git a/server/migrations/20240717061527-add-column-archivedAt-collections.js b/server/migrations/20240717061527-add-column-archivedAt-collections.js new file mode 100644 index 0000000000..b53197d1c1 --- /dev/null +++ b/server/migrations/20240717061527-add-column-archivedAt-collections.js @@ -0,0 +1,27 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn("collections", "archivedAt", { + type: Sequelize.DATE, + allowNull: true, + transaction, + }); + await queryInterface.addIndex("collections", ["archivedAt"], { + transaction, + }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex("collections", ["archivedAt"], { + transaction, + }); + await queryInterface.removeColumn("collections", "archivedAt", { + transaction, + }); + }); + }, +}; diff --git a/server/migrations/20240809054702-add-column-archivedById-to-collections.js b/server/migrations/20240809054702-add-column-archivedById-to-collections.js new file mode 100644 index 0000000000..6e3bf3c15f --- /dev/null +++ b/server/migrations/20240809054702-add-column-archivedById-to-collections.js @@ -0,0 +1,18 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("collections", "archivedById", { + type: Sequelize.UUID, + allowNull: true, + references: { + model: "users", + }, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn("collections", "archivedById"); + }, +}; diff --git a/server/models/Collection.test.ts b/server/models/Collection.test.ts index d1e22631c7..2acf22370e 100644 --- a/server/models/Collection.test.ts +++ b/server/models/Collection.test.ts @@ -175,6 +175,67 @@ describe("#addDocumentToStructure", () => { expect(collection.documentStructure![0].children.length).toBe(2); expect(collection.documentStructure![0].children[0].id).toBe(id); }); + + test("should add the document along with its nested document(s)", async () => { + const collection = await buildCollection(); + + const document = await buildDocument({ + title: "New doc", + teamId: collection.teamId, + }); + + // create a nested doc within New doc + const nestedDocument = await buildDocument({ + title: "Nested doc", + parentDocumentId: document.id, + teamId: collection.teamId, + }); + + expect(collection.documentStructure).toBeNull(); + + await collection.addDocumentToStructure(document); + + expect(collection.documentStructure).not.toBeNull(); + expect(collection.documentStructure).toHaveLength(1); + expect(collection.documentStructure![0].id).toBe(document.id); + expect(collection.documentStructure![0].children).toHaveLength(1); + expect(collection.documentStructure![0].children[0].id).toBe( + nestedDocument.id + ); + }); + + test("should add the document along with its archived nested document(s)", async () => { + const collection = await buildCollection(); + + const document = await buildDocument({ + title: "New doc", + teamId: collection.teamId, + }); + + // create a nested doc within New doc + const nestedDocument = await buildDocument({ + title: "Nested doc", + parentDocumentId: document.id, + teamId: collection.teamId, + }); + + nestedDocument.archivedAt = new Date(); + await nestedDocument.save(); + + expect(collection.documentStructure).toBeNull(); + + await collection.addDocumentToStructure(document, undefined, { + includeArchived: true, + }); + + expect(collection.documentStructure).not.toBeNull(); + expect(collection.documentStructure).toHaveLength(1); + expect(collection.documentStructure![0].id).toBe(document.id); + expect(collection.documentStructure![0].children).toHaveLength(1); + expect(collection.documentStructure![0].children[0].id).toBe( + nestedDocument.id + ); + }); describe("options: documentJson", () => { test("should append supplied json over document's own", async () => { const collection = await buildCollection(); diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 45487db054..43b26f7910 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -10,6 +10,7 @@ import { NonNullFindOptions, InferAttributes, InferCreationAttributes, + EmptyResultError, } from "sequelize"; import { Sequelize, @@ -29,6 +30,8 @@ import { DataType, Length as SimpleLength, BeforeDestroy, + IsDate, + AllowNull, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; import type { CollectionSort, ProsemirrorData } from "@shared/types"; @@ -54,6 +57,10 @@ import IsHexColor from "./validators/IsHexColor"; import Length from "./validators/Length"; import NotContainsUrl from "./validators/NotContainsUrl"; +type AdditionalFindOptions = { + rejectOnEmpty?: boolean | Error; +}; + @Scopes(() => ({ withAllMemberships: { include: [ @@ -99,6 +106,13 @@ import NotContainsUrl from "./validators/NotContainsUrl"; }, ], }), + withArchivedBy: () => ({ + include: [ + { + association: "archivedBy", + }, + ], + }), withMembership: (userId: string) => { if (!userId) { return {}; @@ -249,6 +263,11 @@ class Collection extends ParanoidModel< }) sort: CollectionSort; + /** Whether the collection is archived, and if so when. */ + @IsDate + @Column + archivedAt: Date | null; + // getters /** @@ -268,6 +287,16 @@ class Collection extends ParanoidModel< return `/collection/${slugify(this.name)}-${this.urlId}`; } + /** + * Whether this collection is considered active or not. A collection is active if + * it has not been archived or deleted. + * + * @returns boolean + */ + get isActive(): boolean { + return !this.archivedAt && !this.deletedAt; + } + // hooks @BeforeValidate @@ -321,6 +350,14 @@ class Collection extends ParanoidModel< @Column(DataType.UUID) importId: string | null; + @BelongsTo(() => User, "archivedById") + archivedBy?: User | null; + + @AllowNull + @ForeignKey(() => User) + @Column(DataType.UUID) + archivedById?: string | null; + @HasMany(() => Document, "collectionId") documents: Document[]; @@ -390,37 +427,51 @@ class Collection extends ParanoidModel< */ static async findByPk( id: Identifier, - options?: NonNullFindOptions + options?: NonNullFindOptions & AdditionalFindOptions ): Promise; static async findByPk( id: Identifier, - options?: FindOptions + options?: FindOptions & AdditionalFindOptions ): Promise; static async findByPk( id: Identifier, - options: FindOptions = {} + options: FindOptions & AdditionalFindOptions = {} ): Promise { if (typeof id !== "string") { return null; } if (isUUID(id)) { - return this.findOne({ + const collection = await this.findOne({ where: { id, }, ...options, + rejectOnEmpty: false, }); + + if (!collection && options.rejectOnEmpty) { + throw new EmptyResultError(`Collection doesn't exist with id: ${id}`); + } + + return collection; } const match = id.match(UrlHelper.SLUG_URL_REGEX); if (match) { - return this.findOne({ + const collection = await this.findOne({ where: { urlId: match[1], }, ...options, + rejectOnEmpty: false, }); + + if (!collection && options.rejectOnEmpty) { + throw new EmptyResultError(`Collection doesn't exist with id: ${id}`); + } + + return collection; } return null; @@ -662,6 +713,7 @@ class Collection extends ParanoidModel< options: FindOptions & { save?: boolean; documentJson?: NavigationNode; + includeArchived?: boolean; } = {} ) { if (!this.documentStructure) { diff --git a/server/models/Document.ts b/server/models/Document.ts index f3b84fbe12..1f2427a246 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -975,68 +975,76 @@ class Document extends ArchivableModel< // Moves a document from being visible to the team within a collection // to the archived area, where it can be subsequently restored. - archive = async (user: User) => { - await this.sequelize.transaction(async (transaction: Transaction) => { - const collection = this.collectionId - ? await Collection.findByPk(this.collectionId, { - transaction, - lock: transaction.LOCK.UPDATE, - }) - : undefined; + archive = async (user: User, options?: FindOptions) => { + const { transaction } = { ...options }; + const collection = this.collectionId + ? await Collection.findByPk(this.collectionId, { + transaction, + lock: transaction?.LOCK.UPDATE, + }) + : undefined; - if (collection) { - await collection.removeDocumentInStructure(this, { transaction }); - if (this.collection) { - this.collection.documentStructure = collection.documentStructure; - } + if (collection) { + await collection.removeDocumentInStructure(this, { transaction }); + if (this.collection) { + this.collection.documentStructure = collection.documentStructure; } - }); + } - await this.archiveWithChildren(user); + await this.archiveWithChildren(user, { transaction }); return this; }; // Restore an archived document back to being visible to the team - unarchive = async (user: User) => { - await this.sequelize.transaction(async (transaction: Transaction) => { - const collection = this.collectionId - ? await Collection.findByPk(this.collectionId, { - transaction, - lock: transaction.LOCK.UPDATE, - }) - : undefined; - - // check to see if the documents parent hasn't been archived also - // If it has then restore the document to the collection root. - if (this.parentDocumentId) { - const parent = await (this.constructor as typeof Document).findOne({ - where: { - id: this.parentDocumentId, - }, - }); - if (parent?.isDraft || !parent?.isActive) { - this.parentDocumentId = null; - } - } - - if (!this.template && this.publishedAt && collection) { - await collection.addDocumentToStructure(this, undefined, { + restoreTo = async ( + collectionId: string, + options: FindOptions & { user: User } + ) => { + const { transaction } = { ...options }; + const collection = collectionId + ? await Collection.findByPk(collectionId, { transaction, - }); - if (this.collection) { - this.collection.documentStructure = collection.documentStructure; - } - } - }); + lock: transaction?.LOCK.UPDATE, + }) + : undefined; - if (this.deletedAt) { - await this.restore(); + // check to see if the documents parent hasn't been archived also + // If it has then restore the document to the collection root. + if (this.parentDocumentId) { + const parent = await (this.constructor as typeof Document).findOne({ + where: { + id: this.parentDocumentId, + }, + transaction, + }); + if (parent?.isDraft || !parent?.isActive) { + this.parentDocumentId = null; + } } - this.archivedAt = null; - this.lastModifiedById = user.id; - this.updatedBy = user; - await this.save(); + if (!this.template && this.publishedAt && collection?.isActive) { + await collection.addDocumentToStructure(this, undefined, { + includeArchived: true, + transaction, + }); + } + + if (this.deletedAt) { + await this.restore({ transaction }); + this.collectionId = collectionId; + await this.save({ transaction }); + } + + if (this.archivedAt) { + await this.restoreWithChildren(collectionId, options.user, { + transaction, + }); + } + + if (this.collection && collection) { + // updating the document structure in memory just in case it's later accessed somewhere + this.collection.documentStructure = collection.documentStructure; + } return this; }; @@ -1088,7 +1096,7 @@ class Document extends ArchivableModel< * @returns Promise resolving to a NavigationNode */ toNavigationNode = async ( - options?: FindOptions + options?: FindOptions & { includeArchived?: boolean } ): Promise => { // Checking if the record is new is a performance optimization – new docs cannot have children const childDocuments = this.isNewRecord @@ -1097,16 +1105,24 @@ class Document extends ArchivableModel< .unscoped() .scope("withoutState") .findAll({ - where: { - teamId: this.teamId, - parentDocumentId: this.id, - archivedAt: { - [Op.is]: null, - }, - publishedAt: { - [Op.ne]: null, - }, - }, + where: options?.includeArchived + ? { + teamId: this.teamId, + parentDocumentId: this.id, + publishedAt: { + [Op.ne]: null, + }, + } + : { + teamId: this.teamId, + parentDocumentId: this.id, + publishedAt: { + [Op.ne]: null, + }, + archivedAt: { + [Op.is]: null, + }, + }, transaction: options?.transaction, }); @@ -1124,6 +1140,38 @@ class Document extends ArchivableModel< }; }; + private restoreWithChildren = async ( + collectionId: string, + user: User, + options?: FindOptions + ) => { + const restoreChildren = async (parentDocumentId: string) => { + const childDocuments = await ( + this.constructor as typeof Document + ).findAll({ + where: { + parentDocumentId, + }, + ...options, + }); + for (const child of childDocuments) { + await restoreChildren(child.id); + child.archivedAt = null; + child.lastModifiedById = user.id; + child.updatedBy = user; + child.collectionId = collectionId; + await child.save(options); + } + }; + + await restoreChildren(this.id); + this.archivedAt = null; + this.lastModifiedById = user.id; + this.updatedBy = user; + this.collectionId = collectionId; + return this.save(options); + }; + private archiveWithChildren = async ( user: User, options?: FindOptions @@ -1138,6 +1186,7 @@ class Document extends ArchivableModel< where: { parentDocumentId, }, + ...options, }); for (const child of childDocuments) { await archiveChildren(child.id); diff --git a/server/policies/collection.test.ts b/server/policies/collection.test.ts index 5a314d67ee..a177716c2c 100644 --- a/server/policies/collection.test.ts +++ b/server/policies/collection.test.ts @@ -9,6 +9,23 @@ import { import { serialize } from "./index"; describe("admin", () => { + it("should allow team admin to archive collection", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + // reload to get membership + const reloaded = await Collection.scope({ + method: ["withMembership", admin.id], + }).findByPk(collection.id); + const abilities = serialize(admin, reloaded); + expect(abilities.read).toBeTruthy(); + expect(abilities.update).toBeTruthy(); + expect(abilities.readDocument).toBeTruthy(); + expect(abilities.updateDocument).toBeTruthy(); + expect(abilities.createDocument).toBeTruthy(); + expect(abilities.archive).toBeTruthy(); + }); + it("should allow updating collection but not reading documents", async () => { const team = await buildTeam(); const user = await buildAdmin({ @@ -29,6 +46,7 @@ describe("admin", () => { expect(abilities.share).toEqual(false); expect(abilities.read).toBeTruthy(); expect(abilities.update).toBeTruthy(); + expect(abilities.archive).toBeTruthy(); }); it("should allow updating documents in view only collection", async () => { @@ -40,47 +58,76 @@ describe("admin", () => { teamId: team.id, permission: CollectionPermission.Read, }); - const abilities = serialize(user, collection); + // reload to get membership + const reloaded = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collection.id); + const abilities = serialize(user, reloaded); expect(abilities.readDocument).toBeTruthy(); expect(abilities.updateDocument).toBeTruthy(); expect(abilities.createDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.read).toBeTruthy(); expect(abilities.update).toBeTruthy(); + expect(abilities.archive).toBeTruthy(); }); }); describe("member", () => { describe("admin permission", () => { - it("should allow updating collection", async () => { + it("should allow member to update collection", async () => { const team = await buildTeam(); - const user = await buildUser({ - teamId: team.id, - }); - const collection = await buildCollection({ - teamId: team.id, - permission: CollectionPermission.ReadWrite, - }); - await UserMembership.create({ - createdById: user.id, - collectionId: collection.id, - userId: user.id, - permission: CollectionPermission.Admin, + const admin = await buildAdmin({ teamId: team.id }); + const member = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + await collection.$add("user", member, { + through: { + permission: CollectionPermission.Admin, + createdById: admin.id, + }, }); // reload to get membership const reloaded = await Collection.scope({ - method: ["withMembership", user.id], + method: ["withMembership", member.id], }).findByPk(collection.id); - const abilities = serialize(user, reloaded); + const abilities = serialize(member, reloaded); expect(abilities.read).toBeTruthy(); - expect(abilities.readDocument).toBeTruthy(); - // expect(abilities.createDocument).toBeTruthy(); - // expect(abilities.share).toBeTruthy(); expect(abilities.update).toBeTruthy(); + expect(abilities.readDocument).toBeTruthy(); + expect(abilities.updateDocument).toBeTruthy(); + expect(abilities.createDocument).toBeTruthy(); + expect(abilities.share).toBeTruthy(); + expect(abilities.update).toBeTruthy(); + expect(abilities.archive).toBeTruthy(); }); }); describe("read_write permission", () => { + it("should disallow member to update collection", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const member = await buildUser({ teamId: team.id }); + + const collection = await buildCollection({ teamId: team.id }); + await collection.$add("user", member, { + through: { + permission: CollectionPermission.ReadWrite, + createdById: admin.id, + }, + }); + // reload to get membership + const reloaded = await Collection.scope({ + method: ["withMembership", member.id], + }).findByPk(collection.id); + const abilities = serialize(member, reloaded); + expect(abilities.read).toBeTruthy(); + expect(abilities.update).toBe(false); + expect(abilities.readDocument).toBeTruthy(); + expect(abilities.updateDocument).toBeTruthy(); + expect(abilities.createDocument).toBeTruthy(); + expect(abilities.archive).toBe(false); + }); + it("should allow read write documents for team member", async () => { const team = await buildTeam(); const user = await buildUser({ @@ -95,6 +142,7 @@ describe("member", () => { expect(abilities.readDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); it("should override read membership permission", async () => { @@ -121,10 +169,38 @@ describe("member", () => { expect(abilities.readDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); describe("read permission", () => { + it("should disallow member to archive collection", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const member = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + permission: CollectionPermission.Read, + }); + await collection.$add("user", member, { + through: { + permission: CollectionPermission.Read, + createdById: admin.id, + }, + }); + // reload to get membership + const reloaded = await Collection.scope({ + method: ["withMembership", member.id], + }).findByPk(collection.id); + const abilities = serialize(member, reloaded); + expect(abilities.read).toBeTruthy(); + expect(abilities.update).not.toBeTruthy(); + expect(abilities.readDocument).toBeTruthy(); + expect(abilities.updateDocument).toBe(false); + expect(abilities.createDocument).toBe(false); + expect(abilities.archive).toBe(false); + }); + it("should allow read permissions for team member", async () => { const team = await buildTeam(); const user = await buildUser({ @@ -138,32 +214,33 @@ describe("member", () => { expect(abilities.read).toBeTruthy(); expect(abilities.update).toEqual(false); expect(abilities.share).toEqual(false); + expect(abilities.archive).toEqual(false); }); it("should allow override with read_write membership permission", async () => { const team = await buildTeam(); - const user = await buildUser({ - teamId: team.id, - }); + const admin = await buildAdmin({ teamId: team.id }); + const member = await buildUser({ teamId: team.id }); const collection = await buildCollection({ teamId: team.id, permission: CollectionPermission.Read, }); - await UserMembership.create({ - createdById: user.id, - collectionId: collection.id, - userId: user.id, - permission: CollectionPermission.ReadWrite, + await collection.$add("user", member, { + through: { + permission: CollectionPermission.ReadWrite, + createdById: admin.id, + }, }); // reload to get membership const reloaded = await Collection.scope({ - method: ["withMembership", user.id], + method: ["withMembership", member.id], }).findByPk(collection.id); - const abilities = serialize(user, reloaded); + const abilities = serialize(member, reloaded); expect(abilities.read).toBeTruthy(); expect(abilities.readDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); @@ -183,6 +260,7 @@ describe("member", () => { expect(abilities.createDocument).toEqual(false); expect(abilities.share).toEqual(false); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); it("should allow override with team member membership permission", async () => { @@ -210,6 +288,7 @@ describe("member", () => { expect(abilities.createDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); }); @@ -232,6 +311,7 @@ describe("viewer", () => { expect(abilities.createDocument).toEqual(false); expect(abilities.update).toEqual(false); expect(abilities.share).toEqual(false); + expect(abilities.archive).toEqual(false); }); it("should override read membership permission", async () => { @@ -259,6 +339,7 @@ describe("viewer", () => { expect(abilities.readDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); @@ -289,6 +370,7 @@ describe("viewer", () => { expect(abilities.createDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); @@ -307,6 +389,7 @@ describe("viewer", () => { expect(abilities.read).toEqual(false); expect(abilities.update).toEqual(false); expect(abilities.share).toEqual(false); + expect(abilities.archive).toEqual(false); }); it("should allow override with team member membership permission", async () => { @@ -335,6 +418,7 @@ describe("viewer", () => { expect(abilities.createDocument).toBeTruthy(); expect(abilities.share).toBeTruthy(); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); }); @@ -357,6 +441,7 @@ describe("guest", () => { expect(abilities.createDocument).toEqual(false); expect(abilities.update).toEqual(false); expect(abilities.share).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); @@ -386,5 +471,6 @@ describe("guest", () => { expect(abilities.createDocument).toEqual(false); expect(abilities.share).toEqual(false); expect(abilities.update).toEqual(false); + expect(abilities.archive).toEqual(false); }); }); diff --git a/server/policies/collection.ts b/server/policies/collection.ts index 96c5f609bb..2ec24f42d2 100644 --- a/server/policies/collection.ts +++ b/server/policies/collection.ts @@ -28,7 +28,7 @@ allow(User, "move", Collection, (actor, collection) => // isTeamAdmin(actor, collection), isTeamMutable(actor), - !collection?.deletedAt + !!collection?.isActive ) ); @@ -105,14 +105,38 @@ allow(User, "share", Collection, (user, collection) => { return true; }); +allow(User, "updateDocument", Collection, (user, collection) => { + if (!collection || !isTeamModel(user, collection) || !isTeamMutable(user)) { + return false; + } + + if (!collection.isPrivate && user.isAdmin) { + return true; + } + + if ( + collection.permission !== CollectionPermission.ReadWrite || + user.isViewer || + user.isGuest + ) { + return includesMembership(collection, [ + CollectionPermission.ReadWrite, + CollectionPermission.Admin, + ]); + } + + return true; +}); + allow( User, - ["updateDocument", "createDocument", "deleteDocument"], + ["createDocument", "deleteDocument"], Collection, (user, collection) => { if ( !collection || - user.teamId !== collection.teamId || + !collection.isActive || + !isTeamModel(user, collection) || !isTeamMutable(user) ) { return false; @@ -137,16 +161,38 @@ allow( } ); -allow(User, ["update", "delete"], Collection, (user, collection) => { - if (!collection || user.isGuest || user.teamId !== collection.teamId) { - return false; - } - if (user.isAdmin) { - return true; - } +allow(User, ["update", "archive"], Collection, (user, collection) => + and( + !!collection, + !!collection?.isActive, + or( + isTeamAdmin(user, collection), + includesMembership(collection, [CollectionPermission.Admin]) + ) + ) +); - return includesMembership(collection, [CollectionPermission.Admin]); -}); +allow(User, "delete", Collection, (user, collection) => + and( + !!collection, + !collection?.deletedAt, + or( + isTeamAdmin(user, collection), + includesMembership(collection, [CollectionPermission.Admin]) + ) + ) +); + +allow(User, "restore", Collection, (user, collection) => + and( + !!collection, + !collection?.isActive, + or( + isTeamAdmin(user, collection), + includesMembership(collection, [CollectionPermission.Admin]) + ) + ) +); function includesMembership( collection: Collection | null, diff --git a/server/presenters/collection.ts b/server/presenters/collection.ts index 0e7a6da5e4..cc379dedb3 100644 --- a/server/presenters/collection.ts +++ b/server/presenters/collection.ts @@ -1,6 +1,7 @@ import Collection from "@server/models/Collection"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { APIContext } from "@server/types"; +import presentUser from "./user"; export default async function presentCollection( ctx: APIContext | undefined, @@ -24,5 +25,7 @@ export default async function presentCollection( createdAt: collection.createdAt, updatedAt: collection.updatedAt, deletedAt: collection.deletedAt, + archivedAt: collection.archivedAt, + archivedBy: collection.archivedBy && presentUser(collection.archivedBy), }; } diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 12ab205fa4..87c730b2dd 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -1,4 +1,6 @@ +import concat from "lodash/concat"; import uniq from "lodash/uniq"; +import uniqBy from "lodash/uniqBy"; import { Server } from "socket.io"; import { Comment, @@ -41,8 +43,7 @@ export default class WebsocketsProcessor { case "documents.create": case "documents.publish": case "documents.unpublish": - case "documents.restore": - case "documents.unarchive": { + case "documents.restore": { const document = await Document.findByPk(event.documentId, { paranoid: false, }); @@ -54,6 +55,7 @@ export default class WebsocketsProcessor { } const channels = await this.getDocumentEventChannels(event, document); + return socketio.to(channels).emit("entities", { event: event.name, fetchIfMissing: true, @@ -71,6 +73,50 @@ export default class WebsocketsProcessor { }); } + case "documents.unarchive": { + const [document, srcCollection] = await Promise.all([ + Document.findByPk(event.documentId, { paranoid: false }), + Collection.findByPk(event.data.sourceCollectionId, { + paranoid: false, + }), + ]); + if (!document || !srcCollection) { + return; + } + const documentChannels = await this.getDocumentEventChannels( + event, + document + ); + const collectionChannels = this.getCollectionEventChannels( + event, + srcCollection + ); + + const channels = uniq(concat(documentChannels, collectionChannels)); + + return socketio.to(channels).emit("entities", { + event: event.name, + fetchIfMissing: true, + documentIds: [ + { + id: document.id, + updatedAt: document.updatedAt, + }, + ], + collectionIds: uniqBy( + [ + { + id: document.collectionId, + }, + { + id: srcCollection.id, + }, + ], + "id" + ), + }); + } + case "documents.permanent_delete": { return socketio .to(`collection-${event.collectionId}`) @@ -235,6 +281,21 @@ export default class WebsocketsProcessor { }); } + case "collections.archive": + case "collections.restore": { + const collection = await Collection.findByPk(event.collectionId); + if (!collection) { + return; + } + + return socketio + .to(this.getCollectionEventChannels(event, collection)) + .emit(event.name, { + id: event.collectionId, + archivedAt: event.data.archivedAt, + }); + } + case "collections.move": { return socketio .to(`collection-${event.collectionId}`) diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index c3ca6b72af..1fc40f1a3d 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -1,4 +1,4 @@ -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, CollectionStatusFilter } from "@shared/types"; import { Document, UserMembership, GroupMembership } from "@server/models"; import { buildUser, @@ -40,6 +40,44 @@ describe("#collections.list", () => { expect(body.policies[0].abilities.read).toBeTruthy(); }); + it("should include archived collections", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + archivedAt: new Date(), + }); + const res = await server.post("/api/collections.list", { + body: { + token: admin.getJwtToken(), + statusFilter: [CollectionStatusFilter.Archived], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].archivedAt).toBeTruthy(); + expect(body.data[0].archivedBy).toBeTruthy(); + expect(body.data[0].archivedBy.id).toBe(collection.archivedById); + }); + + it("should exclude archived collections", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + await buildCollection({ + teamId: team.id, + archivedAt: new Date(), + }); + const res = await server.post("/api/collections.list", { + body: { + token: admin.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(0); + }); + it("should not return private collections actor is not a member of", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); @@ -122,6 +160,62 @@ describe("#collections.list", () => { expect(body.policies.length).toEqual(2); expect(body.policies[0].abilities.read).toBeTruthy(); }); + + it("should not include archived collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + await buildCollection({ + userId: user.id, + teamId: team.id, + archivedAt: new Date(), + }); + const res = await server.post("/api/collections.list", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it("should not include archived collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + + const beforeArchiveRes = await server.post("/api/collections.list", { + body: { + token: user.getJwtToken(), + }, + }); + const beforeArchiveBody = await beforeArchiveRes.json(); + expect(beforeArchiveRes.status).toEqual(200); + expect(beforeArchiveBody.data).toHaveLength(1); + expect(beforeArchiveBody.data[0].id).toEqual(collection.id); + + const archiveRes = await server.post("/api/collections.archive", { + body: { + token: user.getJwtToken(), + id: collection.id, + }, + }); + + expect(archiveRes.status).toEqual(200); + + const afterArchiveRes = await server.post("/api/collections.list", { + body: { + token: user.getJwtToken(), + }, + }); + + const afterArchiveBody = await afterArchiveRes.json(); + expect(afterArchiveRes.status).toEqual(200); + expect(afterArchiveBody.data).toHaveLength(0); + }); }); describe("#collections.import", () => { @@ -1056,6 +1150,26 @@ describe("#collections.memberships", () => { }); describe("#collections.info", () => { + it("should return archivedBy for archived collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + archivedAt: new Date(), + archivedById: user.id, + }); + const res = await server.post("/api/collections.info", { + body: { + token: user.getJwtToken(), + id: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.archivedBy.id).toEqual(collection.archivedById); + }); + it("should return collection", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); @@ -1705,3 +1819,76 @@ describe("#collections.delete", () => { expect(body.success).toBe(true); }); }); + +describe("#collections.archive", () => { + it("should archive collection", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + const document = await buildDocument({ + collectionId: collection.id, + teamId: team.id, + publishedAt: new Date(), + }); + + await collection.reload(); + expect(collection.documentStructure).not.toBe(null); + expect(document.archivedAt).toBe(null); + const res = await server.post("/api/collections.archive", { + body: { + token: admin.getJwtToken(), + id: collection.id, + }, + }); + const [, , body] = await Promise.all([ + collection.reload(), + document.reload(), + res.json(), + ]); + expect(res.status).toEqual(200); + expect(body.data.archivedAt).not.toBe(null); + expect(body.data.archivedBy).toBeTruthy(); + expect(body.data.archivedBy.id).toBe(collection.archivedById); + expect(document.archivedAt).not.toBe(null); + }); +}); + +describe("#collections.restore", () => { + it("should restore collection", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + }); + await buildDocument({ + collectionId: collection.id, + teamId: team.id, + publishedAt: new Date(), + }); + // reload to ensure documentStructure is set + await collection.reload(); + expect(collection.documentStructure).not.toBe(null); + const archiveRes = await server.post("/api/collections.archive", { + body: { + token: admin.getJwtToken(), + id: collection.id, + }, + }); + const [, archiveBody] = await Promise.all([ + collection.reload(), + archiveRes.json(), + ]); + expect(archiveRes.status).toEqual(200); + expect(archiveBody.data.archivedAt).not.toBe(null); + const res = await server.post("/api/collections.restore", { + body: { + token: admin.getJwtToken(), + id: collection.id, + }, + }); + const [, body] = await Promise.all([collection.reload(), res.json()]); + expect(res.status).toEqual(200); + expect(body.data.archivedAt).toBe(null); + expect(collection.documentStructure).not.toBe(null); + }); +}); diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index 244b06b172..f75ed915c8 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -4,6 +4,7 @@ import Router from "koa-router"; import { Sequelize, Op, WhereOptions } from "sequelize"; import { CollectionPermission, + CollectionStatusFilter, FileOperationState, FileOperationType, } from "@shared/types"; @@ -25,6 +26,7 @@ import { Group, Attachment, FileOperation, + Document, } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { authorize } from "@server/policies"; @@ -125,9 +127,12 @@ router.post( async (ctx: APIContext) => { const { id } = ctx.input.body; const { user } = ctx.state.auth; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); + const collection = await Collection.scope([ + { + method: ["withMembership", user.id], + }, + "withArchivedBy", + ]).findByPk(id); authorize(user, "read", collection); @@ -801,23 +806,60 @@ router.post( auth(), validate(T.CollectionsListSchema), pagination(), + transaction(), async (ctx: APIContext) => { - const { includeListOnly } = ctx.input.body; + const { includeListOnly, statusFilter } = ctx.input.body; const { user } = ctx.state.auth; - const collectionIds = await user.collectionIds(); - const where: WhereOptions = - includeListOnly && user.isAdmin - ? { - teamId: user.teamId, - } - : { - teamId: user.teamId, - id: collectionIds, - }; + const { transaction } = ctx.state; + const collectionIds = await user.collectionIds({ transaction }); + + const where: WhereOptions = { + teamId: user.teamId, + [Op.and]: [ + { + deletedAt: { + [Op.eq]: null, + }, + }, + ], + }; + + if (!statusFilter) { + where[Op.and].push({ archivedAt: { [Op.eq]: null } }); + } + + if (!includeListOnly || !user.isAdmin) { + where[Op.and].push({ id: collectionIds }); + } + + const statusQuery = []; + if (statusFilter?.includes(CollectionStatusFilter.Archived)) { + statusQuery.push({ + archivedAt: { + [Op.ne]: null, + }, + }); + } + + if (statusQuery.length) { + where[Op.and].push({ + [Op.or]: statusQuery, + }); + } + const [collections, total] = await Promise.all([ - Collection.scope({ - method: ["withMembership", user.id], - }).findAll({ + Collection.scope( + statusFilter?.includes(CollectionStatusFilter.Archived) + ? [ + { + method: ["withMembership", user.id], + }, + "withArchivedBy", + ] + : { + method: ["withMembership", user.id], + } + ).findAll({ where, order: [ Sequelize.literal('"collection"."index" collate "C"'), @@ -825,8 +867,9 @@ router.post( ], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, + transaction, }), - Collection.count({ where }), + Collection.count({ where, transaction }), ]); const nullIndex = collections.findIndex( @@ -834,7 +877,9 @@ router.post( ); if (nullIndex !== -1) { - const indexedCollections = await collectionIndexing(user.teamId); + const indexedCollections = await collectionIndexing(user.teamId, { + transaction, + }); collections.forEach((collection) => { collection.index = indexedCollections[collection.id]; }); @@ -881,6 +926,130 @@ router.post( } ); +router.post( + "collections.archive", + auth(), + validate(T.CollectionsArchiveSchema), + transaction(), + async (ctx: APIContext) => { + const { transaction } = ctx.state; + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + + const collection = await Collection.scope([ + { + method: ["withMembership", user.id], + }, + ]).findByPk(id, { + transaction, + rejectOnEmpty: true, + }); + + authorize(user, "archive", collection); + + collection.archivedAt = new Date(); + collection.archivedById = user.id; + await collection.save({ transaction }); + collection.archivedBy = user; + + // Archive all documents within the collection + await Document.update( + { + lastModifiedById: user.id, + archivedAt: collection.archivedAt, + }, + { + where: { + teamId: collection.teamId, + collectionId: collection.id, + archivedAt: { + [Op.is]: null, + }, + }, + transaction, + } + ); + + await Event.createFromContext( + ctx, + { + name: "collections.archive", + collectionId: collection.id, + data: { + name: collection.name, + archivedAt: collection.archivedAt, + }, + }, + { transaction } + ); + + ctx.body = { + data: await presentCollection(ctx, collection), + policies: presentPolicies(user, [collection]), + }; + } +); + +router.post( + "collections.restore", + auth(), + validate(T.CollectionsRestoreSchema), + transaction(), + async (ctx: APIContext) => { + const { transaction } = ctx.state; + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id, { + transaction, + rejectOnEmpty: true, + }); + + authorize(user, "restore", collection); + + const collectionArchivedAt = collection.archivedAt; + + await Document.update( + { + lastModifiedById: user.id, + archivedAt: null, + }, + { + where: { + collectionId: collection.id, + teamId: user.teamId, + archivedAt: collection.archivedAt, + }, + transaction, + } + ); + + collection.archivedAt = null; + collection.archivedById = null; + await collection.save({ transaction }); + + await Event.createFromContext( + ctx, + { + name: "collections.restore", + collectionId: collection.id, + data: { + name: collection.name, + archivedAt: collectionArchivedAt, + }, + }, + { transaction } + ); + + ctx.body = { + data: await presentCollection(ctx, collection!), + policies: presentPolicies(user, [collection]), + }; + } +); + router.post( "collections.move", auth(), diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index 24a9574200..0d807dc1ca 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -1,6 +1,10 @@ import isUndefined from "lodash/isUndefined"; import { z } from "zod"; -import { CollectionPermission, FileOperationFormat } from "@shared/types"; +import { + CollectionPermission, + CollectionStatusFilter, + FileOperationFormat, +} from "@shared/types"; import { Collection } from "@server/models"; import { zodIconType } from "@server/utils/zod"; import { ValidateColor, ValidateIndex } from "@server/validation"; @@ -174,6 +178,8 @@ export type CollectionsUpdateReq = z.infer; export const CollectionsListSchema = BaseSchema.extend({ body: z.object({ includeListOnly: z.boolean().default(false), + /** Collection statuses to include in results */ + statusFilter: z.nativeEnum(CollectionStatusFilter).array().optional(), }), }); @@ -185,6 +191,22 @@ export const CollectionsDeleteSchema = BaseSchema.extend({ export type CollectionsDeleteReq = z.infer; +export const CollectionsArchiveSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type CollectionsArchiveReq = z.infer; + +export const CollectionsRestoreSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type CollectionsRestoreReq = z.infer; + +export const CollectionsArchivedSchema = BaseSchema; + +export type CollectionsArchivedReq = z.infer; + export const CollectionsMoveSchema = BaseSchema.extend({ body: BaseIdSchema.extend({ index: z diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index c826e99989..40afef9f3a 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -806,6 +806,85 @@ describe("#documents.list", () => { expect(body.data.length).toEqual(0); }); + it("should return only archived documents in a collection", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + }); + const docs = await Promise.all([ + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }), + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }), + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }), + ]); + await docs[0].archive(user); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + statusFilter: [StatusFilter.Archived], + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toEqual(docs[0].id); + }); + + it("should return archived documents across all collections user has access to", async () => { + const user = await buildUser(); + const collections = await Promise.all([ + buildCollection({ + teamId: user.teamId, + }), + buildCollection({ + teamId: user.teamId, + }), + ]); + const docs = await Promise.all([ + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collections[0].id, + }), + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collections[1].id, + }), + buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collections[1].id, + }), + ]); + await Promise.all([docs[0].archive(user), docs[1].archive(user)]); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + statusFilter: [StatusFilter.Archived], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(2); + const docIds = body.data.map((doc: any) => doc.id); + expect(docIds).toContain(docs[0].id); + expect(docIds).toContain(docs[1].id); + expect(docIds).not.toContain(docs[2].id); + }); + it("should not return documents in private collections not a member of", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); @@ -2678,6 +2757,131 @@ describe("#documents.move", () => { }); describe("#documents.restore", () => { + it("should correctly restore document from an archived collection", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + }); + const anotherCollection = await buildCollection({ + teamId: user.teamId, + }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }); + + const archiveRes = await server.post("/api/collections.archive", { + body: { + token: user.getJwtToken(), + id: collection.id, + }, + }); + + expect(archiveRes.status).toEqual(200); + + // check if document is part of the correct collection's structure + await collection.reload(); + expect(collection.archivedAt).not.toBe(null); + expect(collection.documentStructure).not.toBe(null); + expect(collection.documentStructure).toHaveLength(1); + expect(collection?.documentStructure?.[0].id).toBe(document.id); + expect(anotherCollection.documentStructure).toBeNull(); + + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: anotherCollection.id, + }, + }); + + const [, , body] = await Promise.all([ + collection.reload(), + anotherCollection.reload(), + res.json(), + ]); + expect(res.status).toEqual(200); + expect(body.data.deletedAt).toEqual(null); + expect(body.data.collectionId).toEqual(anotherCollection.id); + + // re-check collection structure after restore + expect(collection.documentStructure).toHaveLength(0); + expect(anotherCollection.documentStructure).not.toBe(null); + expect(anotherCollection.documentStructure).toHaveLength(1); + expect(anotherCollection?.documentStructure?.[0].id).toBe(document.id); + }); + + it("should fail if attempting to restore document to an archived collection", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }); + + const archiveRes = await server.post("/api/collections.archive", { + body: { + token: user.getJwtToken(), + id: collection.id, + }, + }); + + expect(archiveRes.status).toEqual(200); + + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "Unable to restore, the collection may have been deleted or archived" + ); + }); + + it("should fail if attempting to restore to a collection for which the user does not have access", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + createdById: user.id, + teamId: user.teamId, + }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }); + + const archiveRes = await server.post("/api/collections.archive", { + body: { + token: user.getJwtToken(), + id: collection.id, + }, + }); + + expect(archiveRes.status).toEqual(200); + + const anotherCollection = await buildCollection(); + + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: anotherCollection.id, + }, + }); + + expect(res.status).toEqual(403); + }); + it("should require id", async () => { const user = await buildUser(); const document = await buildDocument({ @@ -2788,13 +2992,58 @@ describe("#documents.restore", () => { }); await document.destroy(); await collection.destroy({ hooks: false }); + // passing deleted collection's id const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: collection.id, + }, + }); + // not passing collection's id + const anotherRes = await server.post("/api/documents.restore", { body: { token: user.getJwtToken(), id: document.id, }, }); + const body = await res.json(); + const anotherBody = await anotherRes.json(); expect(res.status).toEqual(400); + expect(body.message).toEqual( + "Unable to restore, the collection may have been deleted or archived" + ); + expect(anotherRes.status).toEqual(400); + expect(anotherBody.message).toEqual( + "Unable to restore, the collection may have been deleted or archived" + ); + }); + + it("should not allow restore of documents in archived collection", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: collection.id, + }); + await document.destroy(); + collection.archivedAt = new Date(); + await collection.save(); + const res = await server.post("/api/documents.restore", { + body: { + token: user.getJwtToken(), + id: document.id, + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual( + "Unable to restore, the collection may have been deleted or archived" + ); }); it("should not allow restore of trashed documents to collection user cannot access", async () => { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 3da40f7d5a..3323eebda2 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -5,11 +5,13 @@ import invariant from "invariant"; import JSZip from "jszip"; import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; +import has from "lodash/has"; +import remove from "lodash/remove"; import uniq from "lodash/uniq"; import mime from "mime-types"; import { Op, ScopeOptions, Sequelize, WhereOptions } from "sequelize"; import { v4 as uuidv4 } from "uuid"; -import { TeamPreference, UserRole } from "@shared/types"; +import { StatusFilter, TeamPreference, UserRole } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import slugify from "@shared/utils/slugify"; import documentCreator from "@server/commands/documentCreator"; @@ -20,7 +22,6 @@ import documentPermanentDeleter from "@server/commands/documentPermanentDeleter" import documentUpdater from "@server/commands/documentUpdater"; import env from "@server/env"; import { - NotFoundError, InvalidRequestError, AuthenticationError, ValidationError, @@ -83,43 +84,52 @@ router.post( pagination(), validate(T.DocumentsListSchema), async (ctx: APIContext) => { - let { sort } = ctx.input.body; const { + sort, direction, template, collectionId, backlinkDocumentId, parentDocumentId, userId: createdById, + statusFilter, } = ctx.input.body; // always filter by the current team const { user } = ctx.state.auth; - let where: WhereOptions = { + const where: WhereOptions = { teamId: user.teamId, - archivedAt: { - [Op.is]: null, - }, + [Op.and]: [ + { + deletedAt: { + [Op.eq]: null, + }, + }, + ], }; + // Exclude archived docs by default + if (!statusFilter) { + where[Op.and].push({ archivedAt: { [Op.eq]: null } }); + } + if (template) { - where = { - ...where, + where[Op.and].push({ template: true, - }; + }); } // if a specific user is passed then add to filters. If the user doesn't // exist in the team then nothing will be returned, so no need to check auth if (createdById) { - where = { ...where, createdById }; + where[Op.and].push({ createdById }); } let documentIds: string[] = []; // if a specific collection is passed then we need to check auth to view it if (collectionId) { - where = { ...where, collectionId }; + where[Op.and].push({ collectionId: [collectionId] }); const collection = await Collection.scope({ method: ["withMembership", user.id], }).findByPk(collectionId); @@ -131,19 +141,18 @@ router.post( documentIds = (collection?.documentStructure || []) .map((node) => node.id) .slice(ctx.state.pagination.offset, ctx.state.pagination.limit); - where = { ...where, id: documentIds }; + where[Op.and].push({ id: documentIds }); } // otherwise, filter by all collections the user has access to } else { const collectionIds = await user.collectionIds(); - where = { - ...where, + where[Op.and].push({ collectionId: template && can(user, "readTemplate", user.team) ? { [Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }], } : collectionIds, - }; + }); } if (parentDocumentId) { @@ -177,21 +186,20 @@ router.post( ]); if (groupMembership || membership) { - delete where.collectionId; + remove(where[Op.and], (cond) => has(cond, "collectionId")); } - where = { ...where, parentDocumentId }; + where[Op.and].push({ parentDocumentId }); } // Explicitly passing 'null' as the parentDocumentId allows listing documents // that have no parent document (aka they are at the root of the collection) if (parentDocumentId === null) { - where = { - ...where, + where[Op.and].push({ parentDocumentId: { [Op.is]: null, }, - }; + }); } if (backlinkDocumentId) { @@ -201,29 +209,81 @@ router.post( documentId: backlinkDocumentId, }, }); - where = { - ...where, + where[Op.and].push({ id: backlinks.map((backlink) => backlink.reverseDocumentId), - }; + }); } - if (sort === "index") { - sort = "updatedAt"; + const statusQuery = []; + if (statusFilter?.includes(StatusFilter.Published)) { + statusQuery.push({ + [Op.and]: [ + { + publishedAt: { + [Op.ne]: null, + }, + archivedAt: { + [Op.eq]: null, + }, + }, + ], + }); + } + + if (statusFilter?.includes(StatusFilter.Draft)) { + statusQuery.push({ + [Op.and]: [ + { + publishedAt: { + [Op.eq]: null, + }, + archivedAt: { + [Op.eq]: null, + }, + [Op.or]: [ + // Only ever include draft results for the user's own documents + { createdById: user.id }, + { "$memberships.id$": { [Op.ne]: null } }, + ], + }, + ], + }); + } + + if (statusFilter?.includes(StatusFilter.Archived)) { + statusQuery.push({ + archivedAt: { + [Op.ne]: null, + }, + }); + } + + if (statusQuery.length) { + where[Op.and].push({ + [Op.or]: statusQuery, + }); } const [documents, total] = await Promise.all([ Document.defaultScopeWithUser(user.id).findAll({ where, - order: [[sort, direction]], + order: [ + [ + // this needs to be done otherwise findAll will throw citing + // that the column "document"."index" doesn't exist – value of sort + // is required to be a column name + sort === "index" ? "updatedAt" : sort, + direction, + ], + ], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, }), Document.count({ where }), ]); - // index sort is special because it uses the order of the documents in the - // collection.documentStructure rather than a database column - if (documentIds.length) { + if (sort === "index") { + // sort again so as to retain the order of documents as in collection.documentStructure documents.sort( (a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id) ); @@ -233,6 +293,7 @@ router.post( documents.map((document) => presentDocument(ctx, document)) ); const policies = presentPolicies(user, documents); + ctx.body = { pagination: { ...ctx.state.pagination, total }, data, @@ -738,81 +799,105 @@ router.post( "documents.restore", auth({ role: UserRole.Member }), validate(T.DocumentsRestoreSchema), + transaction(), async (ctx: APIContext) => { const { id, collectionId, revisionId } = ctx.input.body; const { user } = ctx.state.auth; + const { transaction } = ctx.state; const document = await Document.findByPk(id, { userId: user.id, paranoid: false, + rejectOnEmpty: true, + transaction, }); - if (!document) { - throw NotFoundError(); - } + const sourceCollectionId = document.collectionId; + const destCollectionId = collectionId ?? sourceCollectionId; - // Passing collectionId allows restoring to a different collection than the - // document was originally within - if (collectionId) { - document.collectionId = collectionId; - } - - const collection = document.collectionId + const srcCollection = sourceCollectionId ? await Collection.scope({ method: ["withMembership", user.id], - }).findByPk(document.collectionId) + }).findByPk(sourceCollectionId) : undefined; - // if the collectionId was provided in the request and isn't valid then it will - // be caught as a 403 on the authorize call below. Otherwise we're checking here - // that the original collection still exists and advising to pass collectionId - // if not. - if (document.collection && !collectionId && !collection) { + const destCollection = destCollectionId + ? await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(destCollectionId) + : undefined; + + if (!destCollection?.isActive) { throw ValidationError( - "Unable to restore to original collection, it may have been deleted" + "Unable to restore, the collection may have been deleted or archived" ); } + if (sourceCollectionId !== destCollectionId) { + authorize(user, "updateDocument", srcCollection); + await srcCollection?.removeDocumentInStructure(document, { + save: true, + transaction, + }); + } + if (document.deletedAt) { authorize(user, "restore", document); + authorize(user, "updateDocument", destCollection); + // restore a previously deleted document - await document.unarchive(user); - await Event.createFromContext(ctx, { - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, + await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here + await Event.createFromContext( + ctx, + { + name: "documents.restore", + documentId: document.id, + collectionId: document.collectionId, + data: { + title: document.title, + }, }, - }); + { transaction } + ); } else if (document.archivedAt) { authorize(user, "unarchive", document); + authorize(user, "updateDocument", destCollection); + // restore a previously archived document - await document.unarchive(user); - await Event.createFromContext(ctx, { - name: "documents.unarchive", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, + await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here + await Event.createFromContext( + ctx, + { + name: "documents.unarchive", + documentId: document.id, + collectionId: document.collectionId, + data: { + title: document.title, + sourceCollectionId, + }, }, - }); + { transaction } + ); } else if (revisionId) { // restore a document to a specific revision authorize(user, "update", document); - const revision = await Revision.findByPk(revisionId); + const revision = await Revision.findByPk(revisionId, { transaction }); authorize(document, "restore", revision); document.restoreFromRevision(revision); - await document.save(); + await document.save({ transaction }); - await Event.createFromContext(ctx, { - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, + await Event.createFromContext( + ctx, + { + name: "documents.restore", + documentId: document.id, + collectionId: document.collectionId, + data: { + title: document.title, + }, }, - }); + { transaction } + ); } else { assertPresent(revisionId, "revisionId is required"); } @@ -1286,24 +1371,32 @@ router.post( "documents.archive", auth(), validate(T.DocumentsArchiveSchema), + transaction(), async (ctx: APIContext) => { const { id } = ctx.input.body; const { user } = ctx.state.auth; + const { transaction } = ctx.state; const document = await Document.findByPk(id, { userId: user.id, + rejectOnEmpty: true, + transaction, }); authorize(user, "archive", document); - await document.archive(user); - await Event.createFromContext(ctx, { - name: "documents.archive", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, + await document.archive(user, { transaction }); + await Event.createFromContext( + ctx, + { + name: "documents.archive", + documentId: document.id, + collectionId: document.collectionId, + data: { + title: document.title, + }, }, - }); + { transaction } + ); ctx.body = { data: await presentDocument(ctx, document), diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index d994a04483..ed3e76c538 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -68,6 +68,9 @@ export const DocumentsListSchema = BaseSchema.extend({ /** Boolean which denotes whether the document is a template */ template: z.boolean().optional(), + + /** Document statuses to include in results */ + statusFilter: z.nativeEnum(StatusFilter).array().optional(), }), // Maintains backwards compatibility }).transform((req) => { diff --git a/server/test/factories.ts b/server/test/factories.ts index 94c4a9e3bf..b24bd8c303 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -284,6 +284,10 @@ export async function buildCollection( overrides.userId = user.id; } + if (overrides.archivedAt && !overrides.archivedById) { + overrides.archivedById = overrides.userId; + } + return Collection.create({ name: faker.lorem.words(2), description: faker.lorem.words(4), diff --git a/server/types.ts b/server/types.ts index be7dd51107..49c0495722 100644 --- a/server/types.ts +++ b/server/types.ts @@ -175,7 +175,6 @@ export type DocumentEvent = BaseEvent & | "documents.delete" | "documents.permanent_delete" | "documents.archive" - | "documents.unarchive" | "documents.restore"; documentId: string; collectionId: string; @@ -184,6 +183,16 @@ export type DocumentEvent = BaseEvent & source?: "import"; }; } + | { + name: "documents.unarchive"; + documentId: string; + collectionId: string; + data: { + title: string; + /** Id of collection from which the document is unarchived */ + sourceCollectionId: string; + }; + } | { name: "documents.move"; documentId: string; @@ -294,10 +303,15 @@ export type CollectionEvent = BaseEvent & }; } | { - name: "collections.update" | "collections.delete"; + name: + | "collections.update" + | "collections.delete" + | "collections.archive" + | "collections.restore"; collectionId: string; data: { name: string; + archivedAt: string; }; } | { diff --git a/server/utils/indexing.ts b/server/utils/indexing.ts index e64030a3c1..56dbc6b465 100644 --- a/server/utils/indexing.ts +++ b/server/utils/indexing.ts @@ -1,9 +1,11 @@ import fractionalIndex from "fractional-index"; +import { FindOptions } from "sequelize"; import naturalSort from "@shared/utils/naturalSort"; import { Collection, Document, Star } from "@server/models"; export async function collectionIndexing( - teamId: string + teamId: string, + { transaction }: FindOptions ): Promise<{ [id: string]: string }> { const collections = await Collection.findAll({ where: { @@ -12,6 +14,7 @@ export async function collectionIndexing( deletedAt: null, }, attributes: ["id", "index", "name"], + transaction, }); const sortable = naturalSort(collections, (collection) => collection.name); @@ -23,7 +26,7 @@ export async function collectionIndexing( for (const collection of sortable) { if (collection.index === null) { collection.index = fractionalIndex(previousIndex, null); - promises.push(collection.save()); + promises.push(collection.save({ transaction })); } previousIndex = collection.index; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 7bce972af1..b623947feb 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -11,6 +11,13 @@ "Search in collection": "Search in collection", "Star": "Star", "Unstar": "Unstar", + "Archive": "Archive", + "Archive collection": "Archive collection", + "Collection archived": "Collection archived", + "Archiving": "Archiving", + "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.", + "Restore": "Restore", + "Collection restored": "Collection restored", "Delete": "Delete", "Delete collection": "Delete collection", "New template": "New template", @@ -72,10 +79,8 @@ "Move": "Move", "Move to collection": "Move to collection", "Move {{ documentType }}": "Move {{ documentType }}", - "Archive": "Archive", "Are you sure you want to archive this document?": "Are you sure you want to archive this document?", "Document archived": "Document archived", - "Archiving": "Archiving", "Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.", "Delete {{ documentName }}": "Delete {{ documentName }}", "Permanently delete": "Permanently delete", @@ -342,6 +347,7 @@ "{{ count }} groups added to the document": "{{ count }} groups added to the document", "{{ count }} groups added to the document_plural": "{{ count }} groups added to the document", "Logo": "Logo", + "Archived collections": "Archived collections", "Change permissions?": "Change permissions?", "New doc": "New doc", "You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection", @@ -497,7 +503,6 @@ "Show document menu": "Show document menu", "{{ documentName }} restored": "{{ documentName }} restored", "Document options": "Document options", - "Restore": "Restore", "Choose a collection": "Choose a collection", "Enable embeds": "Enable embeds", "Export options": "Export options", @@ -558,6 +563,7 @@ "{{ usersCount }} users with access_plural": "{{ usersCount }} users with access", "{{ groupsCount }} groups with access": "{{ groupsCount }} group with access", "{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access", + "Archived by {{userName}}": "Archived by {{userName}}", "Share": "Share", "Recently updated": "Recently updated", "Recently published": "Recently published", @@ -630,7 +636,6 @@ "This document will be permanently deleted in <2> unless restored.": "This document will be permanently deleted in <2> unless restored.", "Highlight some text and use the <1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1> control to add placeholders that can be filled out when creating new documents", "You’re editing a template": "You’re editing a template", - "Archived by {{userName}}": "Archived by {{userName}}", "Deleted by {{userName}}": "Deleted by {{userName}}", "Observing {{ userName }}": "Observing {{ userName }}", "Backlinks": "Backlinks", diff --git a/shared/types.ts b/shared/types.ts index 37ce916b2f..659ded7184 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -13,6 +13,10 @@ export enum StatusFilter { Draft = "draft", } +export enum CollectionStatusFilter { + Archived = "archived", +} + export enum CommentStatusFilter { Resolved = "resolved", Unresolved = "unresolved",