feat: Allow deletion of imports (#5907)
This commit is contained in:
@@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
|||||||
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||||
align-self: center;
|
align-self: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ import { DownloadIcon, TrashIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMenuState } from "reakit/Menu";
|
import { useMenuState } from "reakit/Menu";
|
||||||
|
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||||
|
import FileOperation from "~/models/FileOperation";
|
||||||
import ContextMenu from "~/components/ContextMenu";
|
import ContextMenu from "~/components/ContextMenu";
|
||||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||||
import Template from "~/components/ContextMenu/Template";
|
import Template from "~/components/ContextMenu/Template";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string;
|
fileOperation: FileOperation;
|
||||||
onDelete: (ev: React.SyntheticEvent) => Promise<void>;
|
onDelete: (ev: React.SyntheticEvent) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function FileOperationMenu({ id, onDelete }: Props) {
|
function FileOperationMenu({ fileOperation, onDelete }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const can = usePolicy(fileOperation.id);
|
||||||
const menu = useMenuState({
|
const menu = useMenuState({
|
||||||
modal: true,
|
modal: true,
|
||||||
});
|
});
|
||||||
@@ -28,7 +32,10 @@ function FileOperationMenu({ id, onDelete }: Props) {
|
|||||||
type: "link",
|
type: "link",
|
||||||
title: t("Download"),
|
title: t("Download"),
|
||||||
icon: <DownloadIcon />,
|
icon: <DownloadIcon />,
|
||||||
href: "/api/fileOperations.redirect?id=" + id,
|
visible:
|
||||||
|
fileOperation.type === FileOperationType.Export &&
|
||||||
|
fileOperation.state === FileOperationState.Complete,
|
||||||
|
href: fileOperation.downloadUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
@@ -36,6 +43,7 @@ function FileOperationMenu({ id, onDelete }: Props) {
|
|||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
title: t("Delete"),
|
title: t("Delete"),
|
||||||
|
visible: can.delete,
|
||||||
icon: <TrashIcon />,
|
icon: <TrashIcon />,
|
||||||
dangerous: true,
|
dangerous: true,
|
||||||
onClick: onDelete,
|
onClick: onDelete,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class FileOperation extends Model {
|
|||||||
get sizeInMB(): string {
|
get sizeInMB(): string {
|
||||||
return bytesToHumanReadable(this.size);
|
return bytesToHumanReadable(this.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get downloadUrl(): string {
|
||||||
|
return `/api/fileOperations.redirect?id=${this.id}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileOperation;
|
export default FileOperation;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Scene from "~/components/Scene";
|
|||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
|
||||||
import ExportDialog from "../../components/ExportDialog";
|
import ExportDialog from "../../components/ExportDialog";
|
||||||
import FileOperationListItem from "./components/FileOperationListItem";
|
import FileOperationListItem from "./components/FileOperationListItem";
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ function Export() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const { fileOperations, dialogs } = useStores();
|
const { fileOperations, dialogs } = useStores();
|
||||||
const { showToast } = useToasts();
|
|
||||||
|
|
||||||
const handleOpenDialog = React.useCallback(
|
const handleOpenDialog = React.useCallback(
|
||||||
async (ev: React.SyntheticEvent) => {
|
async (ev: React.SyntheticEvent) => {
|
||||||
@@ -33,20 +31,6 @@ function Export() {
|
|||||||
[dialogs, t]
|
[dialogs, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = React.useCallback(
|
|
||||||
async (fileOperation: FileOperation) => {
|
|
||||||
try {
|
|
||||||
await fileOperations.delete(fileOperation);
|
|
||||||
showToast(t("Export deleted"));
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, {
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fileOperations, showToast, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene title={t("Export")} icon={<DownloadIcon />}>
|
<Scene title={t("Export")} icon={<DownloadIcon />}>
|
||||||
<Heading>{t("Export")}</Heading>
|
<Heading>{t("Export")}</Heading>
|
||||||
@@ -77,11 +61,7 @@ function Export() {
|
|||||||
</h2>
|
</h2>
|
||||||
}
|
}
|
||||||
renderItem={(item: FileOperation) => (
|
renderItem={(item: FileOperation) => (
|
||||||
<FileOperationListItem
|
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||||
key={item.id}
|
|
||||||
fileOperation={item}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Scene>
|
</Scene>
|
||||||
|
|||||||
@@ -10,21 +10,26 @@ import {
|
|||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import FileOperation from "~/models/FileOperation";
|
import FileOperation from "~/models/FileOperation";
|
||||||
import { Action } from "~/components/Actions";
|
import { Action } from "~/components/Actions";
|
||||||
|
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||||
import ListItem from "~/components/List/Item";
|
import ListItem from "~/components/List/Item";
|
||||||
import Spinner from "~/components/Spinner";
|
import Spinner from "~/components/Spinner";
|
||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
import FileOperationMenu from "~/menus/FileOperationMenu";
|
import FileOperationMenu from "~/menus/FileOperationMenu";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
fileOperation: FileOperation;
|
fileOperation: FileOperation;
|
||||||
handleDelete?: (fileOperation: FileOperation) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { dialogs, fileOperations } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
|
||||||
const stateMapping = {
|
const stateMapping = {
|
||||||
[FileOperationState.Creating]: t("Processing"),
|
[FileOperationState.Creating]: t("Processing"),
|
||||||
[FileOperationState.Uploading]: t("Processing"),
|
[FileOperationState.Uploading]: t("Processing"),
|
||||||
@@ -55,6 +60,46 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
|||||||
? fileOperation.name
|
? fileOperation.name
|
||||||
: t("All collections");
|
: t("All collections");
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fileOperations.delete(fileOperation);
|
||||||
|
|
||||||
|
if (fileOperation.type === FileOperationType.Import) {
|
||||||
|
showToast(t("Import deleted"));
|
||||||
|
} else {
|
||||||
|
showToast(t("Export deleted"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fileOperation, fileOperations, showToast, t]);
|
||||||
|
|
||||||
|
const handleConfirmDelete = React.useCallback(async () => {
|
||||||
|
dialogs.openModal({
|
||||||
|
isCentered: true,
|
||||||
|
title: t("Are you sure you want to delete this import?"),
|
||||||
|
content: (
|
||||||
|
<ConfirmationDialog
|
||||||
|
onSubmit={handleDelete}
|
||||||
|
submitText={t("I’m sure")}
|
||||||
|
savingText={`${t("Deleting")}…`}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
|
||||||
|
)}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [dialogs, t, handleDelete]);
|
||||||
|
|
||||||
|
const showMenu =
|
||||||
|
(fileOperation.type === FileOperationType.Export &&
|
||||||
|
fileOperation.state === FileOperationState.Complete) ||
|
||||||
|
fileOperation.type === FileOperationType.Import;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={title}
|
title={title}
|
||||||
@@ -76,17 +121,18 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
fileOperation.state === FileOperationState.Complete && handleDelete ? (
|
showMenu && (
|
||||||
<Action>
|
<Action>
|
||||||
<FileOperationMenu
|
<FileOperationMenu
|
||||||
id={fileOperation.id}
|
fileOperation={fileOperation}
|
||||||
onDelete={async (ev) => {
|
onDelete={
|
||||||
ev.preventDefault();
|
fileOperation.type === FileOperationType.Import
|
||||||
await handleDelete(fileOperation);
|
? handleConfirmDelete
|
||||||
}}
|
: handleDelete
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Action>
|
</Action>
|
||||||
) : undefined
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
36
server/commands/collectionDestroyer.ts
Normal file
36
server/commands/collectionDestroyer.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Transaction } from "sequelize";
|
||||||
|
import { Collection, Event, User } from "@server/models";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The collection to delete */
|
||||||
|
collection: Collection;
|
||||||
|
/** The actor who is deleting the collection */
|
||||||
|
user: User;
|
||||||
|
/** The database transaction to use */
|
||||||
|
transaction: Transaction;
|
||||||
|
/** The IP address of the current request */
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function collectionDestroyer({
|
||||||
|
collection,
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
}: Props) {
|
||||||
|
await collection.destroy({ transaction });
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "collections.delete",
|
||||||
|
collectionId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
data: {
|
||||||
|
name: collection.name,
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { FileOperation } from "@server/models";
|
|
||||||
import { buildAdmin, buildFileOperation } from "@server/test/factories";
|
|
||||||
import fileOperationDeleter from "./fileOperationDeleter";
|
|
||||||
|
|
||||||
describe("fileOperationDeleter", () => {
|
|
||||||
const ip = "127.0.0.1";
|
|
||||||
|
|
||||||
it("should destroy file operation", async () => {
|
|
||||||
const admin = await buildAdmin();
|
|
||||||
const fileOp = await buildFileOperation({
|
|
||||||
userId: admin.id,
|
|
||||||
teamId: admin.teamId,
|
|
||||||
});
|
|
||||||
await fileOperationDeleter(fileOp, admin, ip);
|
|
||||||
expect(
|
|
||||||
await FileOperation.count({
|
|
||||||
where: {
|
|
||||||
teamId: admin.teamId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toEqual(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
|
import { Transaction } from "sequelize";
|
||||||
import { FileOperation, Event, User } from "@server/models";
|
import { FileOperation, Event, User } from "@server/models";
|
||||||
import { sequelize } from "@server/storage/database";
|
|
||||||
|
|
||||||
export default async function fileOperationDeleter(
|
type Props = {
|
||||||
fileOperation: FileOperation,
|
fileOperation: FileOperation;
|
||||||
user: User,
|
user: User;
|
||||||
ip: string
|
ip: string;
|
||||||
) {
|
transaction: Transaction;
|
||||||
const transaction = await sequelize.transaction();
|
};
|
||||||
|
|
||||||
try {
|
export default async function fileOperationDeleter({
|
||||||
await fileOperation.destroy({
|
fileOperation,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
}: Props) {
|
||||||
|
await fileOperation.destroy({ transaction });
|
||||||
await Event.create(
|
await Event.create(
|
||||||
{
|
{
|
||||||
name: "fileOperations.delete",
|
name: "fileOperations.delete",
|
||||||
@@ -24,9 +27,4 @@ export default async function fileOperationDeleter(
|
|||||||
transaction,
|
transaction,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await transaction.commit();
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
server/migrations/20231001032754-file-operation-paranoid.js
Normal file
14
server/migrations/20231001032754-file-operation-paranoid.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn("file_operations", "deletedAt", {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface) {
|
||||||
|
await queryInterface.removeColumn("file_operations", "deletedAt");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Scopes,
|
Scopes,
|
||||||
DataType,
|
DataType,
|
||||||
Length as SimpleLength,
|
Length as SimpleLength,
|
||||||
|
BeforeDestroy,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import type { CollectionSort } from "@shared/types";
|
import type { CollectionSort } from "@shared/types";
|
||||||
@@ -37,6 +38,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
|||||||
import slugify from "@shared/utils/slugify";
|
import slugify from "@shared/utils/slugify";
|
||||||
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import { CollectionValidation } from "@shared/validations";
|
import { CollectionValidation } from "@shared/validations";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import FileOperation from "./FileOperation";
|
import FileOperation from "./FileOperation";
|
||||||
import Group from "./Group";
|
import Group from "./Group";
|
||||||
@@ -265,6 +267,18 @@ class Collection extends ParanoidModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@BeforeDestroy
|
||||||
|
static async checkLastCollection(model: Collection) {
|
||||||
|
const total = await this.count({
|
||||||
|
where: {
|
||||||
|
teamId: model.teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (total === 1) {
|
||||||
|
throw ValidationError("Cannot delete last collection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@AfterDestroy
|
@AfterDestroy
|
||||||
static async onAfterDestroy(model: Collection) {
|
static async onAfterDestroy(model: Collection) {
|
||||||
await Document.destroy({
|
await Document.destroy({
|
||||||
|
|||||||
@@ -459,16 +459,18 @@ class Document extends ParanoidModel {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { includeState, userId, ...rest } = options;
|
||||||
|
|
||||||
// allow default preloading of collection membership if `userId` is passed in find options
|
// allow default preloading of collection membership if `userId` is passed in find options
|
||||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||||
const scope = this.scope([
|
const scope = this.scope([
|
||||||
...(options.includeState ? [] : ["withoutState"]),
|
...(includeState ? [] : ["withoutState"]),
|
||||||
"withDrafts",
|
"withDrafts",
|
||||||
{
|
{
|
||||||
method: ["withCollectionPermissions", options.userId, options.paranoid],
|
method: ["withCollectionPermissions", userId, rest.paranoid],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: ["withViews", options.userId],
|
method: ["withViews", userId],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -477,7 +479,7 @@ class Document extends ParanoidModel {
|
|||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
...options,
|
...rest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +489,7 @@ class Document extends ParanoidModel {
|
|||||||
where: {
|
where: {
|
||||||
urlId: match[1],
|
urlId: match[1],
|
||||||
},
|
},
|
||||||
...options,
|
...rest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import FileStorage from "@server/storage/files";
|
|||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Team from "./Team";
|
import Team from "./Team";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import IdModel from "./base/IdModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
@DefaultScope(() => ({
|
@DefaultScope(() => ({
|
||||||
@@ -36,7 +36,7 @@ import Fix from "./decorators/Fix";
|
|||||||
}))
|
}))
|
||||||
@Table({ tableName: "file_operations", modelName: "file_operation" })
|
@Table({ tableName: "file_operations", modelName: "file_operation" })
|
||||||
@Fix
|
@Fix
|
||||||
class FileOperation extends IdModel {
|
class FileOperation extends ParanoidModel {
|
||||||
@Column(DataType.ENUM(...Object.values(FileOperationType)))
|
@Column(DataType.ENUM(...Object.values(FileOperationType)))
|
||||||
type: FileOperationType;
|
type: FileOperationType;
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class FileOperation extends IdModel {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.save();
|
return this.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||||
import { User, Team, FileOperation } from "@server/models";
|
import { User, Team, FileOperation } from "@server/models";
|
||||||
import { allow } from "./cancan";
|
import { allow } from "./cancan";
|
||||||
|
|
||||||
@@ -13,9 +14,22 @@ allow(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
allow(User, ["read", "delete"], FileOperation, (user, fileOperation) => {
|
allow(User, "read", FileOperation, (user, fileOperation) => {
|
||||||
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
|
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return user.isAdmin;
|
return user.isAdmin;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allow(User, "delete", FileOperation, (user, fileOperation) => {
|
||||||
|
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fileOperation.type === FileOperationType.Export &&
|
||||||
|
fileOperation.state !== FileOperationState.Complete
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.isAdmin;
|
||||||
|
});
|
||||||
|
|||||||
35
server/queues/processors/CollectionDeletedProcessor.ts
Normal file
35
server/queues/processors/CollectionDeletedProcessor.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import teamUpdater from "@server/commands/teamUpdater";
|
||||||
|
import { Team, User } from "@server/models";
|
||||||
|
import { sequelize } from "@server/storage/database";
|
||||||
|
import { Event as TEvent, CollectionEvent } from "@server/types";
|
||||||
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
|
export default class CollectionDeletedProcessor extends BaseProcessor {
|
||||||
|
static applicableEvents: TEvent["name"][] = ["collections.delete"];
|
||||||
|
|
||||||
|
async perform(event: CollectionEvent) {
|
||||||
|
await sequelize.transaction(async (transaction) => {
|
||||||
|
const team = await Team.findByPk(event.teamId, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
transaction,
|
||||||
|
lock: transaction.LOCK.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (team?.defaultCollectionId === event.collectionId) {
|
||||||
|
const user = await User.findByPk(event.actorId, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
paranoid: false,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await teamUpdater({
|
||||||
|
params: { defaultCollectionId: null },
|
||||||
|
user,
|
||||||
|
team,
|
||||||
|
transaction,
|
||||||
|
ip: event.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Notification } from "@server/models";
|
|||||||
import { Event, NotificationEvent } from "@server/types";
|
import { Event, NotificationEvent } from "@server/types";
|
||||||
import BaseProcessor from "./BaseProcessor";
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
export default class NotificationsProcessor extends BaseProcessor {
|
export default class EmailsProcessor extends BaseProcessor {
|
||||||
static applicableEvents: Event["name"][] = ["notifications.create"];
|
static applicableEvents: Event["name"][] = ["notifications.create"];
|
||||||
|
|
||||||
async perform(event: NotificationEvent) {
|
async perform(event: NotificationEvent) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import invariant from "invariant";
|
|
||||||
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
||||||
import { FileOperation } from "@server/models";
|
import { FileOperation } from "@server/models";
|
||||||
import { Event as TEvent, FileOperationEvent } from "@server/types";
|
import { Event as TEvent, FileOperationEvent } from "@server/types";
|
||||||
@@ -10,16 +9,13 @@ import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
|||||||
import ImportNotionTask from "../tasks/ImportNotionTask";
|
import ImportNotionTask from "../tasks/ImportNotionTask";
|
||||||
import BaseProcessor from "./BaseProcessor";
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
export default class FileOperationsProcessor extends BaseProcessor {
|
export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||||
static applicableEvents: TEvent["name"][] = ["fileOperations.create"];
|
static applicableEvents: TEvent["name"][] = ["fileOperations.create"];
|
||||||
|
|
||||||
async perform(event: FileOperationEvent) {
|
async perform(event: FileOperationEvent) {
|
||||||
if (event.name !== "fileOperations.create") {
|
const fileOperation = await FileOperation.findByPk(event.modelId, {
|
||||||
return;
|
rejectOnEmpty: true,
|
||||||
}
|
});
|
||||||
|
|
||||||
const fileOperation = await FileOperation.findByPk(event.modelId);
|
|
||||||
invariant(fileOperation, "fileOperation not found");
|
|
||||||
|
|
||||||
// map file operation type and format to the appropriate task
|
// map file operation type and format to the appropriate task
|
||||||
if (fileOperation.type === FileOperationType.Import) {
|
if (fileOperation.type === FileOperationType.Import) {
|
||||||
54
server/queues/processors/FileOperationDeletedProcessor.ts
Normal file
54
server/queues/processors/FileOperationDeletedProcessor.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||||
|
import collectionDestroyer from "@server/commands/collectionDestroyer";
|
||||||
|
import Logger from "@server/logging/Logger";
|
||||||
|
import { Collection, FileOperation, User } from "@server/models";
|
||||||
|
import { sequelize } from "@server/storage/database";
|
||||||
|
import { Event as TEvent, FileOperationEvent } from "@server/types";
|
||||||
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
|
export default class FileOperationDeletedProcessor extends BaseProcessor {
|
||||||
|
static applicableEvents: TEvent["name"][] = ["fileOperations.delete"];
|
||||||
|
|
||||||
|
async perform(event: FileOperationEvent) {
|
||||||
|
await sequelize.transaction(async (transaction) => {
|
||||||
|
const fileOperation = await FileOperation.findByPk(event.modelId, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
paranoid: false,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
fileOperation.type === FileOperationType.Export ||
|
||||||
|
fileOperation.state !== FileOperationState.Complete
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findByPk(event.actorId, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
paranoid: false,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collections = await Collection.findAll({
|
||||||
|
transaction,
|
||||||
|
lock: transaction.LOCK.UPDATE,
|
||||||
|
where: {
|
||||||
|
teamId: fileOperation.teamId,
|
||||||
|
importId: fileOperation.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
Logger.debug("processor", "Destroying collection created from import", {
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
await collectionDestroyer({
|
||||||
|
collection,
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
ip: event.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ describe("DetachDraftsFromCollectionTask", () => {
|
|||||||
createdById: collection.createdById,
|
createdById: collection.createdById,
|
||||||
teamId: collection.teamId,
|
teamId: collection.teamId,
|
||||||
});
|
});
|
||||||
await collection.destroy();
|
await collection.destroy({ hooks: false });
|
||||||
|
|
||||||
const task = new DetachDraftsFromCollectionTask();
|
const task = new DetachDraftsFromCollectionTask();
|
||||||
await task.perform({
|
await task.perform({
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
FileOperationState,
|
FileOperationState,
|
||||||
FileOperationType,
|
FileOperationType,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
|
import collectionDestroyer from "@server/commands/collectionDestroyer";
|
||||||
import collectionExporter from "@server/commands/collectionExporter";
|
import collectionExporter from "@server/commands/collectionExporter";
|
||||||
import teamUpdater from "@server/commands/teamUpdater";
|
import teamUpdater from "@server/commands/teamUpdater";
|
||||||
import { ValidationError } from "@server/errors";
|
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import { transaction } from "@server/middlewares/transaction";
|
import { transaction } from "@server/middlewares/transaction";
|
||||||
@@ -803,44 +803,15 @@ router.post(
|
|||||||
}).findByPk(id, {
|
}).findByPk(id, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
const team = await Team.findByPk(user.teamId);
|
|
||||||
|
|
||||||
authorize(user, "delete", collection);
|
authorize(user, "delete", collection);
|
||||||
|
|
||||||
const total = await Collection.count({
|
await collectionDestroyer({
|
||||||
where: {
|
collection,
|
||||||
teamId: user.teamId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (total === 1) {
|
|
||||||
throw ValidationError("Cannot delete last collection");
|
|
||||||
}
|
|
||||||
|
|
||||||
await collection.destroy({ transaction });
|
|
||||||
|
|
||||||
if (team && team.defaultCollectionId === collection.id) {
|
|
||||||
await teamUpdater({
|
|
||||||
params: { defaultCollectionId: null },
|
|
||||||
ip: ctx.request.ip,
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
transaction,
|
transaction,
|
||||||
});
|
user,
|
||||||
}
|
|
||||||
|
|
||||||
await Event.create(
|
|
||||||
{
|
|
||||||
name: "collections.delete",
|
|
||||||
collectionId: collection.id,
|
|
||||||
teamId: collection.teamId,
|
|
||||||
actorId: user.id,
|
|
||||||
data: {
|
|
||||||
name: collection.name,
|
|
||||||
},
|
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
},
|
});
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -2301,7 +2301,7 @@ describe("#documents.restore", () => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
await document.destroy();
|
await document.destroy();
|
||||||
await collection.destroy();
|
await collection.destroy({ hooks: false });
|
||||||
const res = await server.post("/api/documents.restore", {
|
const res = await server.post("/api/documents.restore", {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe("#fileOperations.list", () => {
|
|||||||
userId: admin.id,
|
userId: admin.id,
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
});
|
});
|
||||||
await collection.destroy();
|
await collection.destroy({ hooks: false });
|
||||||
const isCollectionPresent = await Collection.findByPk(collection.id);
|
const isCollectionPresent = await Collection.findByPk(collection.id);
|
||||||
expect(isCollectionPresent).toBe(null);
|
expect(isCollectionPresent).toBe(null);
|
||||||
const res = await server.post("/api/fileOperations.list", {
|
const res = await server.post("/api/fileOperations.list", {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WhereOptions } from "sequelize";
|
|||||||
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
|
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
|
||||||
import { ValidationError } from "@server/errors";
|
import { ValidationError } from "@server/errors";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import { transaction } from "@server/middlewares/transaction";
|
||||||
import validate from "@server/middlewares/validate";
|
import validate from "@server/middlewares/validate";
|
||||||
import { FileOperation, Team } from "@server/models";
|
import { FileOperation, Team } from "@server/models";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
@@ -105,16 +106,23 @@ router.post(
|
|||||||
"fileOperations.delete",
|
"fileOperations.delete",
|
||||||
auth({ admin: true }),
|
auth({ admin: true }),
|
||||||
validate(T.FileOperationsDeleteSchema),
|
validate(T.FileOperationsDeleteSchema),
|
||||||
|
transaction(),
|
||||||
async (ctx: APIContext<T.FileOperationsDeleteReq>) => {
|
async (ctx: APIContext<T.FileOperationsDeleteReq>) => {
|
||||||
const { id } = ctx.input.body;
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
const { transaction } = ctx.state;
|
||||||
|
|
||||||
const fileOperation = await FileOperation.unscoped().findByPk(id, {
|
const fileOperation = await FileOperation.unscoped().findByPk(id, {
|
||||||
rejectOnEmpty: true,
|
rejectOnEmpty: true,
|
||||||
});
|
});
|
||||||
authorize(user, "delete", fileOperation);
|
authorize(user, "delete", fileOperation);
|
||||||
|
|
||||||
await fileOperationDeleter(fileOperation, user, ctx.request.ip);
|
await fileOperationDeleter({
|
||||||
|
fileOperation,
|
||||||
|
user,
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -216,48 +216,6 @@ describe("#team.update", () => {
|
|||||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should default to home if default collection is deleted", async () => {
|
|
||||||
const team = await buildTeam();
|
|
||||||
const admin = await buildAdmin({ teamId: team.id });
|
|
||||||
const collection = await buildCollection({
|
|
||||||
teamId: team.id,
|
|
||||||
userId: admin.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await buildCollection({
|
|
||||||
teamId: team.id,
|
|
||||||
userId: admin.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await server.post("/api/team.update", {
|
|
||||||
body: {
|
|
||||||
token: admin.getJwtToken(),
|
|
||||||
defaultCollectionId: collection.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await res.json();
|
|
||||||
expect(res.status).toEqual(200);
|
|
||||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
|
||||||
|
|
||||||
const deleteRes = await server.post("/api/collections.delete", {
|
|
||||||
body: {
|
|
||||||
token: admin.getJwtToken(),
|
|
||||||
id: collection.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(deleteRes.status).toEqual(200);
|
|
||||||
|
|
||||||
const res3 = await server.post("/api/auth.info", {
|
|
||||||
body: {
|
|
||||||
token: admin.getJwtToken(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body3 = await res3.json();
|
|
||||||
expect(res3.status).toEqual(200);
|
|
||||||
expect(body3.data.team.defaultCollectionId).toEqual(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update default collection to null when collection is made private", async () => {
|
it("should update default collection to null when collection is made private", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const admin = await buildAdmin({ teamId: team.id });
|
const admin = await buildAdmin({ teamId: team.id });
|
||||||
|
|||||||
@@ -739,6 +739,11 @@
|
|||||||
"Completed": "Completed",
|
"Completed": "Completed",
|
||||||
"Failed": "Failed",
|
"Failed": "Failed",
|
||||||
"All collections": "All collections",
|
"All collections": "All collections",
|
||||||
|
"Import deleted": "Import deleted",
|
||||||
|
"Export deleted": "Export deleted",
|
||||||
|
"Are you sure you want to delete this import?": "Are you sure you want to delete this import?",
|
||||||
|
"I’m sure": "I’m sure",
|
||||||
|
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.",
|
||||||
"{{userName}} requested": "{{userName}} requested",
|
"{{userName}} requested": "{{userName}} requested",
|
||||||
"Upload": "Upload",
|
"Upload": "Upload",
|
||||||
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
|
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
|
||||||
@@ -782,7 +787,6 @@
|
|||||||
"Danger": "Danger",
|
"Danger": "Danger",
|
||||||
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
|
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
|
||||||
"Export data": "Export data",
|
"Export data": "Export data",
|
||||||
"Export deleted": "Export deleted",
|
|
||||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||||
"Recent exports": "Recent exports",
|
"Recent exports": "Recent exports",
|
||||||
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
|
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
|
||||||
@@ -851,7 +855,6 @@
|
|||||||
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
|
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
|
||||||
"This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.",
|
"This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.",
|
||||||
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
|
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
|
||||||
"I’m sure": "I’m sure",
|
|
||||||
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
|
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
|
||||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||||
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
|
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
|
||||||
|
|||||||
Reference in New Issue
Block a user