From e7b70322848e2cebc00df9c9483418d05b849844 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 1 Oct 2023 21:24:50 -0400 Subject: [PATCH] feat: Allow deletion of imports (#5907) --- app/components/List/Item.tsx | 1 + app/menus/FileOperationMenu.tsx | 14 +++- app/models/FileOperation.ts | 5 ++ app/scenes/Settings/Export.tsx | 22 +------ .../components/FileOperationListItem.tsx | 64 ++++++++++++++++--- server/commands/collectionDestroyer.ts | 36 +++++++++++ server/commands/fileOperationDeleter.test.ts | 23 ------- server/commands/fileOperationDeleter.ts | 52 ++++++++------- .../20231001032754-file-operation-paranoid.js | 14 ++++ server/models/Collection.ts | 14 ++++ server/models/Document.ts | 12 ++-- server/models/FileOperation.ts | 6 +- server/policies/fileOperation.ts | 16 ++++- .../processors/CollectionDeletedProcessor.ts | 35 ++++++++++ server/queues/processors/EmailsProcessor.ts | 2 +- ...or.ts => FileOperationCreatedProcessor.ts} | 12 ++-- .../FileOperationDeletedProcessor.ts | 54 ++++++++++++++++ .../DetachDraftsFromCollectionTask.test.ts | 2 +- server/routes/api/collections/collections.ts | 41 ++---------- server/routes/api/documents/documents.test.ts | 2 +- .../api/fileOperations/fileOperations.test.ts | 2 +- .../api/fileOperations/fileOperations.ts | 10 ++- server/routes/api/teams/teams.test.ts | 42 ------------ shared/i18n/locales/en_US/translation.json | 7 +- 24 files changed, 304 insertions(+), 184 deletions(-) create mode 100644 server/commands/collectionDestroyer.ts delete mode 100644 server/commands/fileOperationDeleter.test.ts create mode 100644 server/migrations/20231001032754-file-operation-paranoid.js create mode 100644 server/queues/processors/CollectionDeletedProcessor.ts rename server/queues/processors/{FileOperationsProcessor.ts => FileOperationCreatedProcessor.ts} (89%) create mode 100644 server/queues/processors/FileOperationDeletedProcessor.ts diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index 63676b234..e66e42d6f 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>` export const Actions = styled(Flex)<{ $selected?: boolean }>` align-self: center; justify-content: center; + flex-shrink: 0; color: ${(props) => props.$selected ? props.theme.white : props.theme.textSecondary}; `; diff --git a/app/menus/FileOperationMenu.tsx b/app/menus/FileOperationMenu.tsx index 7b9b7bcbd..86bc405eb 100644 --- a/app/menus/FileOperationMenu.tsx +++ b/app/menus/FileOperationMenu.tsx @@ -2,17 +2,21 @@ import { DownloadIcon, TrashIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useMenuState } from "reakit/Menu"; +import { FileOperationState, FileOperationType } from "@shared/types"; +import FileOperation from "~/models/FileOperation"; import ContextMenu from "~/components/ContextMenu"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import Template from "~/components/ContextMenu/Template"; +import usePolicy from "~/hooks/usePolicy"; type Props = { - id: string; + fileOperation: FileOperation; onDelete: (ev: React.SyntheticEvent) => Promise; }; -function FileOperationMenu({ id, onDelete }: Props) { +function FileOperationMenu({ fileOperation, onDelete }: Props) { const { t } = useTranslation(); + const can = usePolicy(fileOperation.id); const menu = useMenuState({ modal: true, }); @@ -28,7 +32,10 @@ function FileOperationMenu({ id, onDelete }: Props) { type: "link", title: t("Download"), icon: , - href: "/api/fileOperations.redirect?id=" + id, + visible: + fileOperation.type === FileOperationType.Export && + fileOperation.state === FileOperationState.Complete, + href: fileOperation.downloadUrl, }, { type: "separator", @@ -36,6 +43,7 @@ function FileOperationMenu({ id, onDelete }: Props) { { type: "button", title: t("Delete"), + visible: can.delete, icon: , dangerous: true, onClick: onDelete, diff --git a/app/models/FileOperation.ts b/app/models/FileOperation.ts index e8e471acf..8673a6f5a 100644 --- a/app/models/FileOperation.ts +++ b/app/models/FileOperation.ts @@ -29,6 +29,11 @@ class FileOperation extends Model { get sizeInMB(): string { return bytesToHumanReadable(this.size); } + + @computed + get downloadUrl(): string { + return `/api/fileOperations.redirect?id=${this.id}`; + } } export default FileOperation; diff --git a/app/scenes/Settings/Export.tsx b/app/scenes/Settings/Export.tsx index fc92c22f9..536353427 100644 --- a/app/scenes/Settings/Export.tsx +++ b/app/scenes/Settings/Export.tsx @@ -10,7 +10,6 @@ import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; -import useToasts from "~/hooks/useToasts"; import ExportDialog from "../../components/ExportDialog"; import FileOperationListItem from "./components/FileOperationListItem"; @@ -18,7 +17,6 @@ function Export() { const { t } = useTranslation(); const user = useCurrentUser(); const { fileOperations, dialogs } = useStores(); - const { showToast } = useToasts(); const handleOpenDialog = React.useCallback( async (ev: React.SyntheticEvent) => { @@ -33,20 +31,6 @@ function Export() { [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 ( }> {t("Export")} @@ -77,11 +61,7 @@ function Export() { } renderItem={(item: FileOperation) => ( - + )} /> diff --git a/app/scenes/Settings/components/FileOperationListItem.tsx b/app/scenes/Settings/components/FileOperationListItem.tsx index 070506804..65ab6a0b5 100644 --- a/app/scenes/Settings/components/FileOperationListItem.tsx +++ b/app/scenes/Settings/components/FileOperationListItem.tsx @@ -10,21 +10,26 @@ import { } from "@shared/types"; import FileOperation from "~/models/FileOperation"; import { Action } from "~/components/Actions"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import ListItem from "~/components/List/Item"; import Spinner from "~/components/Spinner"; import Time from "~/components/Time"; import useCurrentUser from "~/hooks/useCurrentUser"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; import FileOperationMenu from "~/menus/FileOperationMenu"; type Props = { fileOperation: FileOperation; - handleDelete?: (fileOperation: FileOperation) => Promise; }; -const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => { +const FileOperationListItem = ({ fileOperation }: Props) => { const { t } = useTranslation(); const user = useCurrentUser(); const theme = useTheme(); + const { dialogs, fileOperations } = useStores(); + const { showToast } = useToasts(); + const stateMapping = { [FileOperationState.Creating]: t("Processing"), [FileOperationState.Uploading]: t("Processing"), @@ -55,6 +60,46 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => { ? fileOperation.name : 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: ( + + {t( + "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone." + )} + + ), + }); + }, [dialogs, t, handleDelete]); + + const showMenu = + (fileOperation.type === FileOperationType.Export && + fileOperation.state === FileOperationState.Complete) || + fileOperation.type === FileOperationType.Import; + return ( { } actions={ - fileOperation.state === FileOperationState.Complete && handleDelete ? ( + showMenu && ( { - ev.preventDefault(); - await handleDelete(fileOperation); - }} + fileOperation={fileOperation} + onDelete={ + fileOperation.type === FileOperationType.Import + ? handleConfirmDelete + : handleDelete + } /> - ) : undefined + ) } /> ); diff --git a/server/commands/collectionDestroyer.ts b/server/commands/collectionDestroyer.ts new file mode 100644 index 000000000..69180c97c --- /dev/null +++ b/server/commands/collectionDestroyer.ts @@ -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 } + ); +} diff --git a/server/commands/fileOperationDeleter.test.ts b/server/commands/fileOperationDeleter.test.ts deleted file mode 100644 index d2349e1b6..000000000 --- a/server/commands/fileOperationDeleter.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/server/commands/fileOperationDeleter.ts b/server/commands/fileOperationDeleter.ts index c891af271..90b9a2902 100644 --- a/server/commands/fileOperationDeleter.ts +++ b/server/commands/fileOperationDeleter.ts @@ -1,32 +1,30 @@ +import { Transaction } from "sequelize"; import { FileOperation, Event, User } from "@server/models"; -import { sequelize } from "@server/storage/database"; -export default async function fileOperationDeleter( - fileOperation: FileOperation, - user: User, - ip: string -) { - const transaction = await sequelize.transaction(); +type Props = { + fileOperation: FileOperation; + user: User; + ip: string; + transaction: Transaction; +}; - try { - await fileOperation.destroy({ +export default async function fileOperationDeleter({ + fileOperation, + user, + ip, + transaction, +}: Props) { + await fileOperation.destroy({ transaction }); + await Event.create( + { + name: "fileOperations.delete", + teamId: user.teamId, + actorId: user.id, + modelId: fileOperation.id, + ip, + }, + { transaction, - }); - await Event.create( - { - name: "fileOperations.delete", - teamId: user.teamId, - actorId: user.id, - modelId: fileOperation.id, - ip, - }, - { - transaction, - } - ); - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } + } + ); } diff --git a/server/migrations/20231001032754-file-operation-paranoid.js b/server/migrations/20231001032754-file-operation-paranoid.js new file mode 100644 index 000000000..9099a3e7a --- /dev/null +++ b/server/migrations/20231001032754-file-operation-paranoid.js @@ -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"); + } +}; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 796ebafcb..9b884e9a8 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -29,6 +29,7 @@ import { Scopes, DataType, Length as SimpleLength, + BeforeDestroy, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; import type { CollectionSort } from "@shared/types"; @@ -37,6 +38,7 @@ import { sortNavigationNodes } from "@shared/utils/collections"; import slugify from "@shared/utils/slugify"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { CollectionValidation } from "@shared/validations"; +import { ValidationError } from "@server/errors"; import Document from "./Document"; import FileOperation from "./FileOperation"; 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 static async onAfterDestroy(model: Collection) { await Document.destroy({ diff --git a/server/models/Document.ts b/server/models/Document.ts index 4912a884f..af591674b 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -459,16 +459,18 @@ class Document extends ParanoidModel { return null; } + const { includeState, userId, ...rest } = 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. const scope = this.scope([ - ...(options.includeState ? [] : ["withoutState"]), + ...(includeState ? [] : ["withoutState"]), "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: { id, }, - ...options, + ...rest, }); } @@ -487,7 +489,7 @@ class Document extends ParanoidModel { where: { urlId: match[1], }, - ...options, + ...rest, }); } diff --git a/server/models/FileOperation.ts b/server/models/FileOperation.ts index 9ad8603b1..257159084 100644 --- a/server/models/FileOperation.ts +++ b/server/models/FileOperation.ts @@ -17,7 +17,7 @@ import FileStorage from "@server/storage/files"; import Collection from "./Collection"; import Team from "./Team"; import User from "./User"; -import IdModel from "./base/IdModel"; +import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; @DefaultScope(() => ({ @@ -36,7 +36,7 @@ import Fix from "./decorators/Fix"; })) @Table({ tableName: "file_operations", modelName: "file_operation" }) @Fix -class FileOperation extends IdModel { +class FileOperation extends ParanoidModel { @Column(DataType.ENUM(...Object.values(FileOperationType))) type: FileOperationType; @@ -73,7 +73,7 @@ class FileOperation extends IdModel { throw err; } } - await this.save(); + return this.save(); }; /** diff --git a/server/policies/fileOperation.ts b/server/policies/fileOperation.ts index d22cf73e0..189163849 100644 --- a/server/policies/fileOperation.ts +++ b/server/policies/fileOperation.ts @@ -1,3 +1,4 @@ +import { FileOperationState, FileOperationType } from "@shared/types"; import { User, Team, FileOperation } from "@server/models"; 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) { return false; } 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; +}); diff --git a/server/queues/processors/CollectionDeletedProcessor.ts b/server/queues/processors/CollectionDeletedProcessor.ts new file mode 100644 index 000000000..ca003a7c9 --- /dev/null +++ b/server/queues/processors/CollectionDeletedProcessor.ts @@ -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, + }); + } + }); + } +} diff --git a/server/queues/processors/EmailsProcessor.ts b/server/queues/processors/EmailsProcessor.ts index dffdce5f5..f5f2355ac 100644 --- a/server/queues/processors/EmailsProcessor.ts +++ b/server/queues/processors/EmailsProcessor.ts @@ -8,7 +8,7 @@ import { Notification } from "@server/models"; import { Event, NotificationEvent } from "@server/types"; import BaseProcessor from "./BaseProcessor"; -export default class NotificationsProcessor extends BaseProcessor { +export default class EmailsProcessor extends BaseProcessor { static applicableEvents: Event["name"][] = ["notifications.create"]; async perform(event: NotificationEvent) { diff --git a/server/queues/processors/FileOperationsProcessor.ts b/server/queues/processors/FileOperationCreatedProcessor.ts similarity index 89% rename from server/queues/processors/FileOperationsProcessor.ts rename to server/queues/processors/FileOperationCreatedProcessor.ts index bc4c46061..f88ed8011 100644 --- a/server/queues/processors/FileOperationsProcessor.ts +++ b/server/queues/processors/FileOperationCreatedProcessor.ts @@ -1,4 +1,3 @@ -import invariant from "invariant"; import { FileOperationFormat, FileOperationType } from "@shared/types"; import { FileOperation } from "@server/models"; import { Event as TEvent, FileOperationEvent } from "@server/types"; @@ -10,16 +9,13 @@ import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask"; import ImportNotionTask from "../tasks/ImportNotionTask"; import BaseProcessor from "./BaseProcessor"; -export default class FileOperationsProcessor extends BaseProcessor { +export default class FileOperationCreatedProcessor extends BaseProcessor { static applicableEvents: TEvent["name"][] = ["fileOperations.create"]; async perform(event: FileOperationEvent) { - if (event.name !== "fileOperations.create") { - return; - } - - const fileOperation = await FileOperation.findByPk(event.modelId); - invariant(fileOperation, "fileOperation not found"); + const fileOperation = await FileOperation.findByPk(event.modelId, { + rejectOnEmpty: true, + }); // map file operation type and format to the appropriate task if (fileOperation.type === FileOperationType.Import) { diff --git a/server/queues/processors/FileOperationDeletedProcessor.ts b/server/queues/processors/FileOperationDeletedProcessor.ts new file mode 100644 index 000000000..6543cae73 --- /dev/null +++ b/server/queues/processors/FileOperationDeletedProcessor.ts @@ -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, + }); + } + }); + } +} diff --git a/server/queues/tasks/DetachDraftsFromCollectionTask.test.ts b/server/queues/tasks/DetachDraftsFromCollectionTask.test.ts index e926708cf..9725e9b34 100644 --- a/server/queues/tasks/DetachDraftsFromCollectionTask.test.ts +++ b/server/queues/tasks/DetachDraftsFromCollectionTask.test.ts @@ -13,7 +13,7 @@ describe("DetachDraftsFromCollectionTask", () => { createdById: collection.createdById, teamId: collection.teamId, }); - await collection.destroy(); + await collection.destroy({ hooks: false }); const task = new DetachDraftsFromCollectionTask(); await task.perform({ diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index df0565648..eab190886 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -7,9 +7,9 @@ import { FileOperationState, FileOperationType, } from "@shared/types"; +import collectionDestroyer from "@server/commands/collectionDestroyer"; import collectionExporter from "@server/commands/collectionExporter"; import teamUpdater from "@server/commands/teamUpdater"; -import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { transaction } from "@server/middlewares/transaction"; @@ -803,44 +803,15 @@ router.post( }).findByPk(id, { transaction, }); - const team = await Team.findByPk(user.teamId); authorize(user, "delete", collection); - const total = await Collection.count({ - where: { - teamId: user.teamId, - }, + await collectionDestroyer({ + collection, + transaction, + user, + ip: ctx.request.ip, }); - 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, - }); - } - - await Event.create( - { - name: "collections.delete", - collectionId: collection.id, - teamId: collection.teamId, - actorId: user.id, - data: { - name: collection.name, - }, - ip: ctx.request.ip, - }, - { transaction } - ); ctx.body = { success: true, diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index e450e6bef..6a5c14e2d 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -2301,7 +2301,7 @@ describe("#documents.restore", () => { teamId: team.id, }); await document.destroy(); - await collection.destroy(); + await collection.destroy({ hooks: false }); const res = await server.post("/api/documents.restore", { body: { token: user.getJwtToken(), diff --git a/server/routes/api/fileOperations/fileOperations.test.ts b/server/routes/api/fileOperations/fileOperations.test.ts index 27d765416..69ee3507d 100644 --- a/server/routes/api/fileOperations/fileOperations.test.ts +++ b/server/routes/api/fileOperations/fileOperations.test.ts @@ -150,7 +150,7 @@ describe("#fileOperations.list", () => { userId: admin.id, collectionId: collection.id, }); - await collection.destroy(); + await collection.destroy({ hooks: false }); const isCollectionPresent = await Collection.findByPk(collection.id); expect(isCollectionPresent).toBe(null); const res = await server.post("/api/fileOperations.list", { diff --git a/server/routes/api/fileOperations/fileOperations.ts b/server/routes/api/fileOperations/fileOperations.ts index b344e716b..6815b3c5b 100644 --- a/server/routes/api/fileOperations/fileOperations.ts +++ b/server/routes/api/fileOperations/fileOperations.ts @@ -3,6 +3,7 @@ import { WhereOptions } from "sequelize"; import fileOperationDeleter from "@server/commands/fileOperationDeleter"; import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; +import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; import { FileOperation, Team } from "@server/models"; import { authorize } from "@server/policies"; @@ -105,16 +106,23 @@ router.post( "fileOperations.delete", auth({ admin: true }), validate(T.FileOperationsDeleteSchema), + transaction(), async (ctx: APIContext) => { const { id } = ctx.input.body; const { user } = ctx.state.auth; + const { transaction } = ctx.state; const fileOperation = await FileOperation.unscoped().findByPk(id, { rejectOnEmpty: true, }); authorize(user, "delete", fileOperation); - await fileOperationDeleter(fileOperation, user, ctx.request.ip); + await fileOperationDeleter({ + fileOperation, + user, + ip: ctx.request.ip, + transaction, + }); ctx.body = { success: true, diff --git a/server/routes/api/teams/teams.test.ts b/server/routes/api/teams/teams.test.ts index 7da747a81..5ee519f04 100644 --- a/server/routes/api/teams/teams.test.ts +++ b/server/routes/api/teams/teams.test.ts @@ -216,48 +216,6 @@ describe("#team.update", () => { 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 () => { const team = await buildTeam(); const admin = await buildAdmin({ teamId: team.id }); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 5c64b637f..9eec48c2c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -739,6 +739,11 @@ "Completed": "Completed", "Failed": "Failed", "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", "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", @@ -782,7 +787,6 @@ "Danger": "Danger", "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 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 {{ userEmail }} 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 {{ userEmail }} when it’s complete.", "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.", @@ -851,7 +855,6 @@ "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.", "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. Default role and Allowed domains will no longer apply.": "New users will first need to be invited to create an account. Default role and Allowed domains 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.", "Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",