();
+
+ 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>2> unless restored.": "This document will be permanently deleted in <2>2> unless restored.",
"Highlight some text and use the <1>1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1>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",