Deleting a collection should detach associated drafts from it (#5082)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -39,7 +39,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
|
||||
@@ -62,7 +62,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
|
||||
@@ -57,7 +57,9 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
@@ -152,7 +152,9 @@ function StarredLink({ star }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
: [];
|
||||
|
||||
@@ -175,7 +175,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
if (!existing && document.collectionId) {
|
||||
event.collectionIds.push({
|
||||
id: document.collectionId,
|
||||
});
|
||||
@@ -298,10 +298,14 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
action((event: WebsocketEntityDeletedEvent) => {
|
||||
const collectionId = event.modelId;
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
if (!doc.publishedAt) {
|
||||
// draft is to be detached from collection, not deleted
|
||||
doc.collectionId = null;
|
||||
} else {
|
||||
doc.deletedAt = deletedAt;
|
||||
}
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
|
||||
@@ -8,7 +8,7 @@ import useToasts from "~/hooks/useToasts";
|
||||
let importingLock = false;
|
||||
|
||||
export default function useImportDocument(
|
||||
collectionId?: string,
|
||||
collectionId?: string | null,
|
||||
documentId?: string
|
||||
): {
|
||||
handleFiles: (files: File[]) => Promise<void>;
|
||||
|
||||
@@ -9,7 +9,7 @@ import useStores from "./useStores";
|
||||
* @param entity The model or model id
|
||||
* @returns The policy for the model
|
||||
*/
|
||||
export default function usePolicy(entity: string | BaseModel | undefined) {
|
||||
export default function usePolicy(entity?: string | BaseModel | null) {
|
||||
const { policies } = useStores();
|
||||
const triggered = React.useRef(false);
|
||||
const entityId = entity
|
||||
|
||||
@@ -123,7 +123,9 @@ function DocumentMenu({
|
||||
[showToast, t, document]
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(document);
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
|
||||
@@ -19,7 +19,9 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
});
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ export default class Document extends ParanoidModel {
|
||||
|
||||
@Field
|
||||
@observable
|
||||
collectionId: string;
|
||||
collectionId?: string | null;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
@@ -261,7 +261,7 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
pin = (collectionId?: string) =>
|
||||
pin = (collectionId?: string | null) =>
|
||||
this.store.rootStore.pins.create({
|
||||
documentId: this.id,
|
||||
...(collectionId ? { collectionId } : {}),
|
||||
|
||||
@@ -23,7 +23,9 @@ function References({ document }: Props) {
|
||||
}, [documents, document.id]);
|
||||
|
||||
const backlinks = documents.getBacklinkedDocuments(document.id);
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const children = collection
|
||||
? collection.getDocumentChildren(document.id)
|
||||
: [];
|
||||
|
||||
@@ -23,7 +23,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = useToasts();
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
@@ -645,7 +645,11 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
|
||||
@action
|
||||
removeCollectionDocuments(collectionId: string) {
|
||||
const documents = this.inCollection(collectionId);
|
||||
// drafts are to be detached from collection rather than deleted, hence excluded here
|
||||
const documents = filter(
|
||||
this.inCollection(collectionId),
|
||||
(d) => !!d.publishedAt
|
||||
);
|
||||
const documentIds = documents.map((doc) => doc.id);
|
||||
documentIds.forEach((id) => this.remove(id));
|
||||
}
|
||||
@@ -795,6 +799,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
find(this.orderedData, (doc) => url.endsWith(doc.urlId));
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return this.rootStore.collections.data.get(document.collectionId);
|
||||
return document.collectionId
|
||||
? this.rootStore.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,9 @@ export default class SharesStore extends BaseStore<Share> {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = this.rootStore.collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? this.rootStore.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class UiStore {
|
||||
activeDocumentId: string | undefined;
|
||||
|
||||
@observable
|
||||
activeCollectionId: string | undefined;
|
||||
activeCollectionId?: string | null;
|
||||
|
||||
@observable
|
||||
observingUserId: string | undefined;
|
||||
|
||||
@@ -76,7 +76,7 @@ export type ActionContext = {
|
||||
isCommandBar: boolean;
|
||||
isButton: boolean;
|
||||
inStarredSection?: boolean;
|
||||
activeCollectionId: string | undefined;
|
||||
activeCollectionId?: string | null;
|
||||
activeDocumentId: string | undefined;
|
||||
currentUserId: string | undefined;
|
||||
currentTeamId: string | undefined;
|
||||
|
||||
@@ -101,7 +101,7 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
|
||||
}
|
||||
|
||||
export function newDocumentPath(
|
||||
collectionId?: string,
|
||||
collectionId?: string | null,
|
||||
params: {
|
||||
parentDocumentId?: string;
|
||||
templateId?: string;
|
||||
|
||||
@@ -109,7 +109,7 @@ export default async function documentCreator({
|
||||
);
|
||||
|
||||
if (publish) {
|
||||
await document.publish(user.id, { transaction });
|
||||
await document.publish(user.id, collectionId!, { transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.publish",
|
||||
|
||||
@@ -126,7 +126,9 @@ export default async function loadDocument({
|
||||
if (canReadDocument) {
|
||||
// Cannot use document.collection here as it does not include the
|
||||
// documentStructure by default through the relationship.
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
}
|
||||
if (!collection) {
|
||||
throw NotFoundError("Collection could not be found for document");
|
||||
}
|
||||
@@ -146,7 +148,9 @@ export default async function loadDocument({
|
||||
}
|
||||
|
||||
// It is possible to disable sharing at the collection so we must check
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
}
|
||||
invariant(collection, "collection not found");
|
||||
|
||||
if (!collection.sharing) {
|
||||
|
||||
@@ -153,4 +153,27 @@ describe("documentMover", () => {
|
||||
expect(response.documents[0].collection?.id).toEqual(newCollection.id);
|
||||
expect(response.documents[0].updatedBy.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should detach document from collection and move it to drafts", async () => {
|
||||
const { document, user, collection } = await seed();
|
||||
|
||||
const response = await sequelize.transaction(async (transaction) =>
|
||||
documentMover({
|
||||
user,
|
||||
document,
|
||||
collectionId: null,
|
||||
index: 0,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.collections[0].id).toBe(collection.id);
|
||||
expect(response.collections.length).toEqual(1);
|
||||
expect(response.documents.length).toEqual(1);
|
||||
|
||||
expect(response.documents[0].collection).toBeNull();
|
||||
expect(response.documents[0].updatedBy.id).toEqual(user.id);
|
||||
expect(response.documents[0].publishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,19 @@ import { User, Document, Collection, Pin, Event } from "@server/models";
|
||||
import pinDestroyer from "./pinDestroyer";
|
||||
|
||||
type Props = {
|
||||
/** User attempting to move the document */
|
||||
user: User;
|
||||
/** Document which is being moved */
|
||||
document: Document;
|
||||
collectionId: string;
|
||||
/** Destination collection to which the document is moved */
|
||||
collectionId: string | null;
|
||||
/** ID of parent under which the document is moved */
|
||||
parentDocumentId?: string | null;
|
||||
/** Position of moved document within document structure */
|
||||
index?: number;
|
||||
/** The IP address of the user moving the document */
|
||||
ip: string;
|
||||
/** The database transaction to run within */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
@@ -23,7 +30,7 @@ type Result = {
|
||||
async function documentMover({
|
||||
user,
|
||||
document,
|
||||
collectionId,
|
||||
collectionId = null,
|
||||
parentDocumentId = null,
|
||||
// convert undefined to null so parentId comparison treats them as equal
|
||||
index,
|
||||
@@ -43,6 +50,7 @@ async function documentMover({
|
||||
}
|
||||
|
||||
if (document.template) {
|
||||
invariant(collectionId, "collectionId should exist");
|
||||
document.collectionId = collectionId;
|
||||
document.parentDocumentId = null;
|
||||
document.lastModifiedById = user.id;
|
||||
@@ -51,20 +59,23 @@ async function documentMover({
|
||||
result.documents.push(document);
|
||||
} else {
|
||||
// Load the current and the next collection upfront and lock them
|
||||
const collection = await Collection.findByPk(document.collectionId, {
|
||||
const collection = await Collection.findByPk(document.collectionId!, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
let newCollection = collectionChanged
|
||||
? await Collection.findByPk(collectionId, {
|
||||
let newCollection = collection;
|
||||
if (collectionChanged) {
|
||||
if (collectionId) {
|
||||
newCollection = await Collection.findByPk(collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
})
|
||||
: collection;
|
||||
|
||||
invariant(newCollection, "collection should exist");
|
||||
});
|
||||
} else {
|
||||
newCollection = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.publishedAt) {
|
||||
// Remove the document from the current collection
|
||||
@@ -99,11 +110,13 @@ async function documentMover({
|
||||
document.lastModifiedById = user.id;
|
||||
document.updatedBy = user;
|
||||
|
||||
if (newCollection) {
|
||||
// Add the document and it's tree to the new collection
|
||||
await newCollection.addDocumentToStructure(document, toIndex, {
|
||||
documentJson,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
document.collectionId = collectionId;
|
||||
document.parentDocumentId = parentDocumentId;
|
||||
@@ -118,6 +131,11 @@ async function documentMover({
|
||||
// If the collection has changed then we also need to update the properties
|
||||
// on all of the documents children to reflect the new collectionId
|
||||
if (collectionChanged) {
|
||||
// Efficiently find the ID's of all the documents that are children of
|
||||
// the moved document and update in one query
|
||||
const childDocumentIds = await document.getChildDocumentIds();
|
||||
|
||||
if (collectionId) {
|
||||
// Reload the collection to get relationship data
|
||||
newCollection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
@@ -125,12 +143,8 @@ async function documentMover({
|
||||
transaction,
|
||||
});
|
||||
invariant(newCollection, "collection should exist");
|
||||
|
||||
result.collections.push(newCollection);
|
||||
|
||||
// Efficiently find the ID's of all the documents that are children of
|
||||
// the moved document and update in one query
|
||||
const childDocumentIds = await document.getChildDocumentIds();
|
||||
await Document.update(
|
||||
{
|
||||
collectionId: newCollection.id,
|
||||
@@ -142,6 +156,23 @@ async function documentMover({
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// document will be moved to drafts
|
||||
document.publishedAt = null;
|
||||
|
||||
// point children's parent to moved document's parent
|
||||
await Document.update(
|
||||
{
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
where: {
|
||||
id: childDocumentIds,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// We must reload from the database to get the relationship data
|
||||
const documents = await Document.findAll({
|
||||
|
||||
@@ -74,7 +74,7 @@ export default async function documentUpdater({
|
||||
if (!document.collectionId) {
|
||||
document.collectionId = collectionId as string;
|
||||
}
|
||||
await document.publish(user.id, { transaction });
|
||||
await document.publish(user.id, collectionId!, { transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
/** The document to pin */
|
||||
documentId: string;
|
||||
/** The collection to pin the document in. If no collection is provided then it will be pinned to home */
|
||||
collectionId?: string | undefined;
|
||||
collectionId?: string | null;
|
||||
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
|
||||
@@ -267,7 +267,8 @@ class Document extends ParanoidModel {
|
||||
model.archivedAt ||
|
||||
model.template ||
|
||||
!model.publishedAt ||
|
||||
!model.changed("title")
|
||||
!model.changed("title") ||
|
||||
!model.collectionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -286,12 +287,17 @@ class Document extends ParanoidModel {
|
||||
|
||||
@AfterCreate
|
||||
static async addDocumentToCollectionStructure(model: Document) {
|
||||
if (model.archivedAt || model.template || !model.publishedAt) {
|
||||
if (
|
||||
model.archivedAt ||
|
||||
model.template ||
|
||||
!model.publishedAt ||
|
||||
!model.collectionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sequelize!.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.findByPk(model.collectionId, {
|
||||
const collection = await Collection.findByPk(model.collectionId!, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -399,7 +405,7 @@ class Document extends ParanoidModel {
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string;
|
||||
collectionId?: string | null;
|
||||
|
||||
@HasMany(() => Revision)
|
||||
revisions: Revision[];
|
||||
@@ -555,13 +561,21 @@ class Document extends ParanoidModel {
|
||||
return this.save(options);
|
||||
};
|
||||
|
||||
publish = async (userId: string, { transaction }: SaveOptions<Document>) => {
|
||||
publish = async (
|
||||
userId: string,
|
||||
collectionId: string,
|
||||
{ transaction }: SaveOptions<Document>
|
||||
) => {
|
||||
// If the document is already published then calling publish should act like
|
||||
// a regular save
|
||||
if (this.publishedAt) {
|
||||
return this.save({ transaction });
|
||||
}
|
||||
|
||||
if (!this.collectionId) {
|
||||
this.collectionId = collectionId;
|
||||
}
|
||||
|
||||
if (!this.template) {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
@@ -587,10 +601,12 @@ class Document extends ParanoidModel {
|
||||
}
|
||||
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collection) {
|
||||
await collection.removeDocumentInStructure(this, { transaction });
|
||||
@@ -610,10 +626,12 @@ class Document extends ParanoidModel {
|
||||
// to the archived area, where it can be subsequently restored.
|
||||
archive = async (userId: string) => {
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collection) {
|
||||
await collection.removeDocumentInStructure(this, { transaction });
|
||||
@@ -628,10 +646,12 @@ class Document extends ParanoidModel {
|
||||
// Restore an archived document back to being visible to the team
|
||||
unarchive = async (userId: string) => {
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
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.
|
||||
@@ -675,6 +695,7 @@ class Document extends ParanoidModel {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
});
|
||||
await collection?.deleteDocument(this, { transaction });
|
||||
} else {
|
||||
|
||||
@@ -252,7 +252,9 @@ class Team extends ParanoidModel {
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await document.publish(collection.createdById, { transaction });
|
||||
await document.publish(collection.createdById, collection.id, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -164,11 +164,12 @@ export default class NotificationHelper {
|
||||
const collectionIds = await recipient.collectionIds();
|
||||
|
||||
// Check the recipient has access to the collection this document is in. Just
|
||||
// because they are subscribed doesn't meant they "still have access to read
|
||||
// because they are subscribed doesn't mean they still have access to read
|
||||
// the document.
|
||||
if (
|
||||
recipient.email &&
|
||||
!recipient.isSuspended &&
|
||||
document.collectionId &&
|
||||
collectionIds.includes(document.collectionId)
|
||||
) {
|
||||
filtered.push(recipient);
|
||||
|
||||
@@ -31,7 +31,7 @@ type SearchOptions = {
|
||||
/** The query offset for pagination */
|
||||
offset?: number;
|
||||
/** Limit results to a collection. Authorization is presumed to have been done before passing to this helper. */
|
||||
collectionId?: string;
|
||||
collectionId?: string | null;
|
||||
/** Limit results to a shared document. */
|
||||
share?: Share;
|
||||
/** Limit results to a date range. */
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: { title: document.title },
|
||||
@@ -46,7 +46,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: { title: document.title },
|
||||
@@ -72,7 +72,7 @@ describe("documents.update", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -100,7 +100,7 @@ describe("documents.update", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -125,7 +125,7 @@ describe("documents.update", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -153,7 +153,7 @@ describe("documents.update", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: { title: document.title },
|
||||
@@ -167,7 +167,7 @@ describe("documents.update", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -195,7 +195,7 @@ describe("documents.delete", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -206,7 +206,7 @@ describe("documents.delete", () => {
|
||||
await processor.perform({
|
||||
name: "documents.delete",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: { title: document.title },
|
||||
|
||||
23
server/queues/processors/CollectionsProcessor.ts
Normal file
23
server/queues/processors/CollectionsProcessor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CollectionEvent, Event } from "@server/types";
|
||||
import DetachDraftsFromCollectionTask from "../tasks/DetachDraftsFromCollectionTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class CollectionsProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["collections.delete"];
|
||||
|
||||
async perform(event: CollectionEvent) {
|
||||
switch (event.name) {
|
||||
case "collections.delete":
|
||||
return this.collectionDeleted(event);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async collectionDeleted(event: CollectionEvent) {
|
||||
await DetachDraftsFromCollectionTask.schedule({
|
||||
collectionId: event.collectionId,
|
||||
actorId: event.actorId,
|
||||
ip: event.ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ describe("documents.update.debounced", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update.debounced",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -38,7 +38,7 @@ describe("documents.update.debounced", () => {
|
||||
await processor.perform({
|
||||
name: "documents.update.debounced",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
33
server/queues/tasks/DetachDraftsFromCollectionTask.test.ts
Normal file
33
server/queues/tasks/DetachDraftsFromCollectionTask.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Document } from "@server/models";
|
||||
import { buildCollection, buildDocument } from "@server/test/factories";
|
||||
import { setupTestDatabase } from "@server/test/support";
|
||||
import DetachDraftsFromCollectionTask from "./DetachDraftsFromCollectionTask";
|
||||
|
||||
setupTestDatabase();
|
||||
|
||||
describe("DetachDraftsFromCollectionTask", () => {
|
||||
const ip = "127.0.0.1";
|
||||
it("should detach drafts from deleted collection", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({
|
||||
title: "test",
|
||||
collectionId: collection.id,
|
||||
publishedAt: null,
|
||||
createdById: collection.createdById,
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
await collection.destroy();
|
||||
|
||||
const task = new DetachDraftsFromCollectionTask();
|
||||
await task.perform({
|
||||
collectionId: collection.id,
|
||||
ip,
|
||||
actorId: collection.createdById,
|
||||
});
|
||||
|
||||
const draft = await Document.findByPk(document.id);
|
||||
expect(draft).not.toBe(null);
|
||||
expect(draft?.deletedAt).toBe(null);
|
||||
expect(draft?.collectionId).toBe(null);
|
||||
});
|
||||
});
|
||||
48
server/queues/tasks/DetachDraftsFromCollectionTask.ts
Normal file
48
server/queues/tasks/DetachDraftsFromCollectionTask.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Op } from "sequelize";
|
||||
import documentMover from "@server/commands/documentMover";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Collection, Document, User } from "@server/models";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
|
||||
async perform(props: Props) {
|
||||
const [collection, actor] = await Promise.all([
|
||||
Collection.findByPk(props.collectionId, {
|
||||
paranoid: false,
|
||||
}),
|
||||
User.findByPk(props.actorId),
|
||||
]);
|
||||
|
||||
if (!actor || !collection || !collection.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documents = await Document.scope("withDrafts").findAll({
|
||||
where: {
|
||||
collectionId: props.collectionId,
|
||||
publishedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
for (const document of documents) {
|
||||
await documentMover({
|
||||
document,
|
||||
user: actor,
|
||||
ip: props.ip,
|
||||
collectionId: null,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: {
|
||||
@@ -55,7 +55,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: {
|
||||
@@ -95,7 +95,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: {
|
||||
@@ -124,7 +124,7 @@ describe("documents.publish", () => {
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
data: {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -63,7 +63,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -88,7 +88,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
modelId: revision.id,
|
||||
@@ -123,7 +123,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -152,7 +152,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator0.id,
|
||||
modelId: revision.id,
|
||||
@@ -202,7 +202,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator0.id,
|
||||
modelId: revision.id,
|
||||
@@ -247,7 +247,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator0.id,
|
||||
modelId: revision.id,
|
||||
@@ -303,7 +303,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -345,7 +345,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -391,7 +391,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -421,7 +421,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: collaborator.id,
|
||||
modelId: revision.id,
|
||||
@@ -444,7 +444,7 @@ describe("revisions.create", () => {
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
modelId: revision.id,
|
||||
|
||||
@@ -2928,6 +2928,35 @@ describe("#documents.delete", () => {
|
||||
expect(deletedDoc?.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should delete a draft under deleted collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const document = await buildDocument({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/documents.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const deletedDoc = await Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
});
|
||||
expect(deletedDoc).not.toBe(null);
|
||||
expect(deletedDoc?.deletedAt).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should allow permanently deleting a document", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -582,9 +582,11 @@ router.post(
|
||||
document.collectionId = collectionId;
|
||||
}
|
||||
|
||||
const collection = await Collection.scope({
|
||||
const collection = document.collectionId
|
||||
? await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(document.collectionId);
|
||||
}).findByPk(document.collectionId)
|
||||
: 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
|
||||
@@ -938,6 +940,10 @@ router.post(
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
if (!document.collectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(document.collectionId, { transaction });
|
||||
|
||||
@@ -87,7 +87,9 @@ export const seed = async () =>
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await document.publish(collection.createdById, { transaction });
|
||||
await document.publish(collection.createdById, collection.id, {
|
||||
transaction,
|
||||
});
|
||||
await collection.reload({ transaction });
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"Viewers": "Viewers",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"Deleting": "Deleting",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
|
||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
|
||||
Reference in New Issue
Block a user