From 0aa338cccca7e83c93783a13a3277ce0a704ff9f Mon Sep 17 00:00:00 2001 From: Guilherme DIniz <64857365+guilherme-diniz@users.noreply.github.com> Date: Tue, 1 Sep 2020 00:03:05 -0300 Subject: [PATCH] feat: Allow unpublishing documents (#1467) * Allow unpublishing documents * add block unpublish files that has children * add api tests to new route --- app/menus/DocumentMenu.js | 17 ++++++++--- app/models/Document.js | 6 +++- app/stores/DocumentsStore.js | 16 +++++++++++ server/api/documents.js | 35 +++++++++++++++++++--- server/api/documents.test.js | 56 ++++++++++++++++++++++++++++++++++++ server/models/Document.js | 17 +++++++++-- server/policies/document.js | 24 ++++++++++++++++ 7 files changed, 159 insertions(+), 12 deletions(-) diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 0d99e948..2a13df7c 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -3,7 +3,6 @@ import { observable } from "mobx"; import { inject, observer } from "mobx-react"; import * as React from "react"; import { Redirect } from "react-router-dom"; - import AuthStore from "stores/AuthStore"; import CollectionStore from "stores/CollectionsStore"; import PoliciesStore from "stores/PoliciesStore"; @@ -15,10 +14,10 @@ import DocumentTemplatize from "scenes/DocumentTemplatize"; import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu"; import Modal from "components/Modal"; import { - documentUrl, - documentMoveUrl, - editDocumentUrl, documentHistoryUrl, + documentMoveUrl, + documentUrl, + editDocumentUrl, newDocumentUrl, } from "utils/routeHelpers"; @@ -106,6 +105,11 @@ class DocumentMenu extends React.Component { this.props.ui.showToast("Document restored"); }; + handleUnpublish = async (ev: SyntheticEvent<>) => { + await this.props.document.unpublish(); + this.props.ui.showToast("Document unpublished"); + }; + handlePin = (ev: SyntheticEvent<>) => { this.props.document.pin(); }; @@ -225,6 +229,11 @@ class DocumentMenu extends React.Component { New nested document )} + {can.unpublish && ( + + Unpublish + + )} {can.update && !document.isTemplate && ( Create template… diff --git a/app/models/Document.js b/app/models/Document.js index 6c6101a4..86d3fb71 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -1,7 +1,7 @@ // @flow import addDays from "date-fns/add_days"; import invariant from "invariant"; -import { action, set, observable, computed } from "mobx"; +import { action, computed, observable, set } from "mobx"; import parseTitle from "shared/utils/parseTitle"; import unescape from "shared/utils/unescape"; import DocumentsStore from "stores/DocumentsStore"; @@ -145,6 +145,10 @@ export default class Document extends BaseModel { return this.store.restore(this, revision); }; + unpublish = () => { + return this.store.unpublish(this); + }; + @action enableEmbeds = () => { this.embedsDisabled = false; diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index a21a69c3..41bdb1cf 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -535,6 +535,22 @@ export default class DocumentsStore extends BaseStore { if (collection) collection.refresh(); }; + @action + unpublish = async (document: Document) => { + const res = await client.post("/documents.unpublish", { + id: document.id, + }); + + runInAction("Document#unpublish", () => { + invariant(res && res.data, "Data should be available"); + document.updateFromJson(res.data); + this.addPolicies(res.policies); + }); + + const collection = this.getCollectionForDocument(document); + if (collection) collection.refresh(); + }; + pin = (document: Document) => { return client.post("/documents.pin", { id: document.id }); }; diff --git a/server/api/documents.js b/server/api/documents.js index 7135992d..96321c79 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -5,20 +5,20 @@ import documentMover from "../commands/documentMover"; import { InvalidRequestError } from "../errors"; import auth from "../middlewares/authentication"; import { + Backlink, Collection, Document, Event, + Revision, Share, Star, - View, - Revision, - Backlink, User, + View, } from "../models"; import policy from "../policies"; import { - presentDocument, presentCollection, + presentDocument, presentPolicies, } from "../presenters"; import { sequelize } from "../sequelize"; @@ -1018,4 +1018,31 @@ router.post("documents.delete", auth(), async (ctx) => { }; }); +router.post("documents.unpublish", auth(), async (ctx) => { + const { id } = ctx.body; + ctx.assertPresent(id, "id is required"); + + const user = ctx.state.user; + const document = await Document.findByPk(id, { userId: user.id }); + + authorize(user, "unpublish", document); + + await document.unpublish(); + + await Event.create({ + name: "documents.unpublish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }; +}); + export default router; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 83041f37..f7ee19e2 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -1736,3 +1736,59 @@ describe("#documents.delete", () => { expect(body).toMatchSnapshot(); }); }); + +describe("#documents.unpublish", () => { + it("should unpublish a document", async () => { + const { user, document } = await seed(); + const res = await server.post("/api/documents.unpublish", { + body: { token: user.getJwtToken(), id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(document.id); + expect(body.data.publishedAt).toBeNull(); + }); + + it("should fail to unpublish a draft document", async () => { + const { user, document } = await seed(); + document.publishedAt = null; + await document.save(); + + const res = await server.post("/api/documents.unpublish", { + body: { token: user.getJwtToken(), id: document.id }, + }); + + expect(res.status).toEqual(403); + }); + + it("should fail to unpublish a deleted document", async () => { + const { user, document } = await seed(); + await document.delete(); + + const res = await server.post("/api/documents.unpublish", { + body: { token: user.getJwtToken(), id: document.id }, + }); + + expect(res.status).toEqual(403); + }); + + it("should fail to unpublish a archived document", async () => { + const { user, document } = await seed(); + await document.archive(); + + const res = await server.post("/api/documents.unpublish", { + body: { token: user.getJwtToken(), id: document.id }, + }); + + expect(res.status).toEqual(403); + }); + + it("should require authentication", async () => { + const { document } = await seed(); + const res = await server.post("/api/documents.unpublish", { + body: { id: document.id }, + }); + expect(res.status).toEqual(401); + }); +}); diff --git a/server/models/Document.js b/server/models/Document.js index 4d08f077..2b0e5d49 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -1,10 +1,9 @@ // @flow import removeMarkdown from "@tommoor/remove-markdown"; -import { map, find, compact, uniq } from "lodash"; +import { compact, find, map, uniq } from "lodash"; import randomstring from "randomstring"; -import Sequelize, { type Transaction } from "sequelize"; +import Sequelize, { Transaction } from "sequelize"; import MarkdownSerializer from "slate-md-serializer"; - import isUUID from "validator/lib/isUUID"; import parseTitle from "../../shared/utils/parseTitle"; import unescape from "../../shared/utils/unescape"; @@ -556,6 +555,18 @@ Document.prototype.publish = async function (options) { return this; }; +Document.prototype.unpublish = async function (options) { + if (!this.publishedAt) return this; + + const collection = await this.getCollection(); + await collection.removeDocumentInStructure(this); + + this.publishedAt = null; + await this.save(options); + + return this; +}; + // Moves a document from being visible to the team within a collection // to the archived area, where it can be subsequently restored. Document.prototype.archive = async function (userId) { diff --git a/server/policies/document.js b/server/policies/document.js index 8ee7196b..29e4c041 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -157,3 +157,27 @@ allow( Revision, (document, revision) => document.id === revision.documentId ); + +allow(User, "unpublish", Document, (user, document) => { + invariant( + document.collection, + "collection is missing, did you forget to include in the query scope?" + ); + + if (!document.publishedAt || !!document.deletedAt || !!document.archivedAt) + return false; + + if (cannot(user, "update", document.collection)) return false; + + const documentID = document.id; + const hasChild = (documents) => + documents.some((doc) => { + if (doc.id === documentID) return doc.children.length > 0; + return hasChild(doc.children); + }); + + return ( + !hasChild(document.collection.documentStructure) && + user.teamId === document.teamId + ); +});