Archive collections (#7266)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2024-10-06 18:07:11 +05:30
committed by GitHub
parent 8b5fdba6f4
commit 35ff70bf14
38 changed files with 1983 additions and 289 deletions

View File

@@ -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: <SearchIcon />,
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: <ArchiveIcon />,
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: (
<ConfirmationDialog
onSubmit={async () => {
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."
)}
</ConfirmationDialog>
),
});
},
});
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
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",

View File

@@ -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<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
};

View File

@@ -133,16 +133,16 @@ function AppSidebar() {
<Section>
<SharedWithMe />
</Section>
<Section auto>
<Section>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
)}
<Section>
{can.createDocument && (
<>
<ArchiveLink />
<TrashLink />
</>
)}
{can.createDocument && <TrashLink />}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>

View File

@@ -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<boolean>(false);
const [expanded, setExpanded] = React.useState<boolean | undefined>();
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 (
<div ref={dropToArchiveDocument}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
);
}

View File

@@ -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 (
<>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={expanded}
prefetchDocument={documents.prefetchDocument}
/>
</Relative>
</>
);
}

View File

@@ -30,6 +30,8 @@ type Props = {
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
depth?: number;
onClick?: () => void;
};
const CollectionLink: React.FC<Props> = ({
@@ -37,6 +39,8 @@ const CollectionLink: React.FC<Props> = ({
expanded,
onDisclosureClick,
isDraggingAnyCollection,
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -115,6 +119,7 @@ const CollectionLink: React.FC<Props> = ({
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
@@ -140,7 +145,7 @@ const CollectionLink: React.FC<Props> = ({
/>
}
exact={false}
depth={0}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (

View File

@@ -55,7 +55,7 @@ function Collections() {
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.orderedData}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -84,7 +84,7 @@ function Collections() {
);
}
const StyledError = styled(Error)`
export const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;

View File

@@ -149,6 +149,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : 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<void>,
@@ -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<void>,
{ 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(),
}),
});
}

View File

@@ -407,6 +407,48 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"collections.archive",
async (event: PartialExcept<Collection, "id">) => {
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<Collection, "id">) => {
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<Team, "id">) => {
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
documents.all.forEach((document) => {

View File

@@ -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: <ExportIcon />,
},
actionToMenuItem(archiveCollection, context),
actionToMenuItem(searchInCollection, context),
{
type: "separator",

View File

@@ -215,8 +215,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
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: <RestoreIcon />,
},
@@ -224,9 +224,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
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,

View File

@@ -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,

View File

@@ -3,5 +3,5 @@ import ParanoidModel from "./ParanoidModel";
export default abstract class ArchivableModel extends ParanoidModel {
@observable
archivedAt: string | undefined;
archivedAt: string | null;
}

View File

@@ -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 (
<ErrorBoundary>
{collection.isArchived && !collection.isDeleted && (
<Notice icon={<ArchiveIcon />}>
{t("Archived by {{userName}}", {
userName: collection.archivedBy?.name ?? t("Unknown"),
})}
&nbsp;
<Time dateTime={collection.archivedAt} addSuffix />
</Notice>
)}
</ErrorBoundary>
);
}

View File

@@ -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 ? (
<CollectionBreadcrumb collection={collection} />
) : collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -163,6 +169,7 @@ function CollectionScene() {
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
@@ -192,26 +199,28 @@ function CollectionScene() {
<CollectionDescription collection={collection} />
<Documents>
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
{!collection.isArchived && (
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
)}
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
) : !collection.isArchived ? (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
@@ -279,6 +288,24 @@ function CollectionScene() {
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
statusFilter: [StatusFilter.Archived],
}}
showParentDocuments
/>
</Route>
</Switch>
)}
</Documents>
</CenteredContent>

View File

@@ -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<Collection> {
: 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<Collection> {
}
};
@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<Collection>): Promise<Collection> {
const result = await super.update(params);
@@ -119,6 +153,52 @@ export default class CollectionsStore extends Store<Collection> {
return model;
}
@action
fetchNamedPage = async (
request = "list",
options:
| (PaginationParams & { statusFilter: CollectionStatusFilter[] })
| undefined
): Promise<Collection[]> => {
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<Collection[]> =>
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,

View File

@@ -121,6 +121,33 @@ export default class DocumentsStore extends Store<Document> {
);
}
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<Document> {
};
@action
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("archived", options);
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
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<Document[]> =>

View File

@@ -161,6 +161,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
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":

View File

@@ -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,
});
});
},
};

View File

@@ -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");
},
};

View File

@@ -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();

View File

@@ -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<Collection>
options?: NonNullFindOptions<Collection> & AdditionalFindOptions
): Promise<Collection>;
static async findByPk(
id: Identifier,
options?: FindOptions<Collection>
options?: FindOptions<Collection> & AdditionalFindOptions
): Promise<Collection | null>;
static async findByPk(
id: Identifier,
options: FindOptions<Collection> = {}
options: FindOptions<Collection> & AdditionalFindOptions = {}
): Promise<Collection | null> {
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) {

View File

@@ -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<Document>
options?: FindOptions<Document> & { includeArchived?: boolean }
): Promise<NavigationNode> => {
// 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<Document>
) => {
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<Document>
@@ -1138,6 +1186,7 @@ class Document extends ArchivableModel<
where: {
parentDocumentId,
},
...options,
});
for (const child of childDocuments) {
await archiveChildren(child.id);

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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),
};
}

View File

@@ -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}`)

View File

@@ -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);
});
});

View File

@@ -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<T.CollectionsInfoReq>) => {
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<T.CollectionsListReq>) => {
const { includeListOnly } = ctx.input.body;
const { includeListOnly, statusFilter } = ctx.input.body;
const { user } = ctx.state.auth;
const collectionIds = await user.collectionIds();
const where: WhereOptions<Collection> =
includeListOnly && user.isAdmin
? {
teamId: user.teamId,
}
: {
teamId: user.teamId,
id: collectionIds,
};
const { transaction } = ctx.state;
const collectionIds = await user.collectionIds({ transaction });
const where: WhereOptions<Collection> = {
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<T.CollectionsArchiveReq>) => {
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<T.CollectionsRestoreReq>) => {
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(),

View File

@@ -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<typeof CollectionsUpdateSchema>;
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<typeof CollectionsDeleteSchema>;
export const CollectionsArchiveSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type CollectionsArchiveReq = z.infer<typeof CollectionsArchiveSchema>;
export const CollectionsRestoreSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type CollectionsRestoreReq = z.infer<typeof CollectionsRestoreSchema>;
export const CollectionsArchivedSchema = BaseSchema;
export type CollectionsArchivedReq = z.infer<typeof CollectionsArchivedSchema>;
export const CollectionsMoveSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
index: z

View File

@@ -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 () => {

View File

@@ -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<T.DocumentsListReq>) => {
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<Document> = {
const where: WhereOptions<Document> = {
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<T.DocumentsRestoreReq>) => {
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<T.DocumentsArchiveReq>) => {
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),

View File

@@ -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) => {

View File

@@ -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),

View File

@@ -175,7 +175,6 @@ export type DocumentEvent = BaseEvent<Document> &
| "documents.delete"
| "documents.permanent_delete"
| "documents.archive"
| "documents.unarchive"
| "documents.restore";
documentId: string;
collectionId: string;
@@ -184,6 +183,16 @@ export type DocumentEvent = BaseEvent<Document> &
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<Collection> &
};
}
| {
name: "collections.update" | "collections.delete";
name:
| "collections.update"
| "collections.delete"
| "collections.archive"
| "collections.restore";
collectionId: string;
data: {
name: string;
archivedAt: string;
};
}
| {

View File

@@ -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<Collection>
): 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;

View File

@@ -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",
"Youre editing a template": "Youre editing a template",
"Archived by {{userName}}": "Archived by {{userName}}",
"Deleted by {{userName}}": "Deleted by {{userName}}",
"Observing {{ userName }}": "Observing {{ userName }}",
"Backlinks": "Backlinks",

View File

@@ -13,6 +13,10 @@ export enum StatusFilter {
Draft = "draft",
}
export enum CollectionStatusFilter {
Archived = "archived",
}
export enum CommentStatusFilter {
Resolved = "resolved",
Unresolved = "unresolved",