Document Archive (#921)
* WIP: Archive * WIP * Finishing up archive endpoints * WIP * Update docs * Flow * Stash * Add toast message confirmations * Redirect handling, fixed publishhing info for archived docs * Redirect to collection instead of home, remove unused pub info * Account for deleted parent * Trash -> Archive Allow reading of archived docs * Dont overload deletedAt * Fixes * 💚 * ParentDocumentId wipe for unarchived sub docs * Fix: CMD+S exits editing Fix: Duplicate user name on published but unedited docs * Improve jank on paginated lists * Prevent editing when archived * 💚 Separate lint / flow steps
This commit is contained in:
@@ -100,6 +100,38 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.archived', auth(), pagination(), async ctx => {
|
||||
const { sort = 'updatedAt' } = ctx.body;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const documents = await Document.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
archivedAt: {
|
||||
// $FlowFixMe
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||
let { sort = 'updatedAt', direction } = ctx.body;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
@@ -235,7 +267,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!share) {
|
||||
if (!share || share.document.archivedAt) {
|
||||
throw new InvalidRequestError('Document could not be found for shareId');
|
||||
}
|
||||
document = share.document;
|
||||
@@ -300,18 +332,29 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||
router.post('documents.restore', auth(), async ctx => {
|
||||
const { id, revisionId } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findById(id);
|
||||
authorize(user, 'update', document);
|
||||
|
||||
const revision = await Revision.findById(revisionId);
|
||||
authorize(document, 'restore', revision);
|
||||
if (document.archivedAt) {
|
||||
authorize(user, 'unarchive', document);
|
||||
|
||||
document.text = revision.text;
|
||||
document.title = revision.title;
|
||||
await document.save();
|
||||
// restore a previously archived document
|
||||
await document.unarchive(user.id);
|
||||
|
||||
// restore a document to a specific revision
|
||||
} else if (revisionId) {
|
||||
authorize(user, 'update', document);
|
||||
|
||||
const revision = await Revision.findById(revisionId);
|
||||
authorize(document, 'restore', revision);
|
||||
|
||||
document.text = revision.text;
|
||||
document.title = revision.title;
|
||||
await document.save();
|
||||
} else {
|
||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
@@ -530,20 +573,30 @@ router.post('documents.move', auth(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.archive', auth(), async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findById(id);
|
||||
authorize(user, 'archive', document);
|
||||
|
||||
await document.archive(user.id);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.delete', auth(), async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findById(id);
|
||||
authorize(ctx.state.user, 'delete', document);
|
||||
authorize(user, 'delete', document);
|
||||
|
||||
const collection = document.collection;
|
||||
if (collection && collection.type === 'atlas') {
|
||||
// Delete document and all of its children
|
||||
await collection.removeDocument(document);
|
||||
}
|
||||
|
||||
await document.destroy();
|
||||
await document.delete();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -27,6 +27,18 @@ describe('#documents.info', async () => {
|
||||
expect(body.data.id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it('should return archived document', async () => {
|
||||
const { user, document } = await seed();
|
||||
await document.archive(user.id);
|
||||
const res = await server.post('/api/documents.info', {
|
||||
body: { token: user.getJwtToken(), id: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it('should not return published document in collection not a member of', async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
collection.private = true;
|
||||
@@ -86,6 +98,20 @@ describe('#documents.info', async () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should not return document from archived shareId', async () => {
|
||||
const { document, user } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.info', {
|
||||
body: { shareId: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should return document from shareId with token', async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const share = await buildShare({
|
||||
@@ -420,6 +446,24 @@ describe('#documents.search', async () => {
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not return archived documents', async () => {
|
||||
const { user } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: 'search term',
|
||||
text: 'search term',
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.search', {
|
||||
body: { token: user.getJwtToken(), query: 'search term' },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not return documents in private collections not a member of', async () => {
|
||||
const { user } = await seed();
|
||||
const collection = await buildCollection({ private: true });
|
||||
@@ -449,6 +493,66 @@ describe('#documents.search', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.archived', async () => {
|
||||
it('should return archived documents', async () => {
|
||||
const { user } = await seed();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.archived', {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should not return deleted documents', async () => {
|
||||
const { user } = await seed();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await document.delete();
|
||||
|
||||
const res = await server.post('/api/documents.archived', {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not return documents in private collections not a member of', async () => {
|
||||
const { user } = await seed();
|
||||
const collection = await buildCollection({ private: true });
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.archived', {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/documents.archived');
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.viewed', async () => {
|
||||
it('should return empty result if no views', async () => {
|
||||
const { user } = await seed();
|
||||
@@ -577,7 +681,37 @@ describe('#documents.pin', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.restore', async () => {
|
||||
describe('#documents.restore', () => {
|
||||
it('should allow restore of archived documents', async () => {
|
||||
const { user, document } = await seed();
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.restore', {
|
||||
body: { token: user.getJwtToken(), id: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.data.archivedAt).toEqual(null);
|
||||
});
|
||||
|
||||
it('should restore archived when previous parent is archived', async () => {
|
||||
const { user, document } = await seed();
|
||||
const childDocument = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
await childDocument.archive(user.id);
|
||||
await document.archive(user.id);
|
||||
|
||||
const res = await server.post('/api/documents.restore', {
|
||||
body: { token: user.getJwtToken(), id: childDocument.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.data.parentDocumentId).toEqual(undefined);
|
||||
expect(body.data.archivedAt).toEqual(null);
|
||||
});
|
||||
|
||||
it('should restore the document to a previous version', async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.findOne({
|
||||
@@ -855,6 +989,22 @@ describe('#documents.update', async () => {
|
||||
expect(body.data.collection.documents[0].title).toBe('Updated title');
|
||||
});
|
||||
|
||||
it('should not edit archived document', async () => {
|
||||
const { user, document } = await seed();
|
||||
await document.archive();
|
||||
|
||||
const res = await server.post('/api/documents.update', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
title: 'Updated title',
|
||||
text: 'Updated text',
|
||||
lastRevision: document.revision,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should not create new version when autosave=true', async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -974,6 +1124,24 @@ describe('#documents.update', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.archive', async () => {
|
||||
it('should allow archiving document', async () => {
|
||||
const { user, document } = await seed();
|
||||
const res = await server.post('/api/documents.archive', {
|
||||
body: { token: user.getJwtToken(), id: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { document } = await seed();
|
||||
const res = await server.post('/api/documents.archive', {
|
||||
body: { id: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.delete', async () => {
|
||||
it('should allow deleting document', async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
11
server/migrations/20190404035736-add-archive.js
Normal file
11
server/migrations/20190404035736-add-archive.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('documents', 'archivedAt', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
});
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('documents', 'archivedAt');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import _ from 'lodash';
|
||||
import { find, remove } from 'lodash';
|
||||
import slug from 'slug';
|
||||
import randomstring from 'randomstring';
|
||||
import { DataTypes, sequelize } from '../sequelize';
|
||||
@@ -7,14 +7,10 @@ import { asyncLock } from '../redis';
|
||||
import events from '../events';
|
||||
import Document from './Document';
|
||||
import CollectionUser from './CollectionUser';
|
||||
import Event from './Event';
|
||||
import { welcomeMessage } from '../utils/onboarding';
|
||||
|
||||
// $FlowIssue invalid flow-typed
|
||||
slug.defaults.mode = 'rfc3986';
|
||||
|
||||
const allowedCollectionTypes = [['atlas', 'journal']];
|
||||
|
||||
const Collection = sequelize.define(
|
||||
'collection',
|
||||
{
|
||||
@@ -30,7 +26,7 @@ const Collection = sequelize.define(
|
||||
private: DataTypes.BOOLEAN,
|
||||
type: {
|
||||
type: DataTypes.STRING,
|
||||
validate: { isIn: allowedCollectionTypes },
|
||||
validate: { isIn: [['atlas', 'journal']] },
|
||||
},
|
||||
|
||||
/* type: atlas */
|
||||
@@ -40,10 +36,10 @@ const Collection = sequelize.define(
|
||||
tableName: 'collections',
|
||||
paranoid: true,
|
||||
hooks: {
|
||||
beforeValidate: collection => {
|
||||
beforeValidate: (collection: Collection) => {
|
||||
collection.urlId = collection.urlId || randomstring.generate(10);
|
||||
},
|
||||
afterCreate: async collection => {
|
||||
afterCreate: async (collection: Collection) => {
|
||||
const team = await collection.getTeam();
|
||||
const collections = await team.getCollections();
|
||||
|
||||
@@ -115,7 +111,7 @@ Collection.associate = models => {
|
||||
);
|
||||
};
|
||||
|
||||
Collection.addHook('afterDestroy', async model => {
|
||||
Collection.addHook('afterDestroy', async (model: Collection) => {
|
||||
await Document.destroy({
|
||||
where: {
|
||||
collectionId: model.id,
|
||||
@@ -123,19 +119,19 @@ Collection.addHook('afterDestroy', async model => {
|
||||
});
|
||||
});
|
||||
|
||||
Collection.addHook('afterCreate', model =>
|
||||
Collection.addHook('afterCreate', (model: Collection) =>
|
||||
events.add({ name: 'collections.create', model })
|
||||
);
|
||||
|
||||
Collection.addHook('afterDestroy', model =>
|
||||
Collection.addHook('afterDestroy', (model: Collection) =>
|
||||
events.add({ name: 'collections.delete', model })
|
||||
);
|
||||
|
||||
Collection.addHook('afterUpdate', model =>
|
||||
Collection.addHook('afterUpdate', (model: Collection) =>
|
||||
events.add({ name: 'collections.update', model })
|
||||
);
|
||||
|
||||
Collection.addHook('afterCreate', (model, options) => {
|
||||
Collection.addHook('afterCreate', (model: Collection, options) => {
|
||||
if (model.private) {
|
||||
return CollectionUser.findOrCreate({
|
||||
where: {
|
||||
@@ -154,23 +150,16 @@ Collection.addHook('afterCreate', (model, options) => {
|
||||
// Instance methods
|
||||
|
||||
Collection.prototype.addDocumentToStructure = async function(
|
||||
document,
|
||||
index,
|
||||
document: Document,
|
||||
index: number,
|
||||
options = {}
|
||||
) {
|
||||
if (!this.documentStructure) return;
|
||||
const existingData = {
|
||||
old: this.documentStructure,
|
||||
documentId: document,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index,
|
||||
};
|
||||
|
||||
// documentStructure can only be updated by one request at the time
|
||||
// documentStructure can only be updated by one request at a time
|
||||
const unlock = await asyncLock(`collection-${this.id}`);
|
||||
|
||||
// If moving existing document with children, use existing structure to
|
||||
// keep everything in shape and not loose documents
|
||||
// If moving existing document with children, use existing structure
|
||||
const documentJson = {
|
||||
...document.toJSON(),
|
||||
...options.documentJson,
|
||||
@@ -206,18 +195,7 @@ Collection.prototype.addDocumentToStructure = async function(
|
||||
|
||||
// Sequelize doesn't seem to set the value with splice on JSONB field
|
||||
this.documentStructure = this.documentStructure;
|
||||
await this.save();
|
||||
|
||||
await Event.create({
|
||||
name: 'Collection#addDocumentToStructure',
|
||||
data: {
|
||||
...existingData,
|
||||
new: this.documentStructure,
|
||||
},
|
||||
collectionId: this.id,
|
||||
teamId: this.teamId,
|
||||
});
|
||||
|
||||
await this.save(options);
|
||||
unlock();
|
||||
|
||||
return this;
|
||||
@@ -226,7 +204,9 @@ Collection.prototype.addDocumentToStructure = async function(
|
||||
/**
|
||||
* Update document's title and url in the documentStructure
|
||||
*/
|
||||
Collection.prototype.updateDocument = async function(updatedDocument) {
|
||||
Collection.prototype.updateDocument = async function(
|
||||
updatedDocument: Document
|
||||
) {
|
||||
if (!this.documentStructure) return;
|
||||
|
||||
// documentStructure can only be updated by one request at the time
|
||||
@@ -261,98 +241,56 @@ Collection.prototype.updateDocument = async function(updatedDocument) {
|
||||
Collection.prototype.moveDocument = async function(document, index) {
|
||||
if (!this.documentStructure) return;
|
||||
|
||||
const documentJson = await this.removeDocument(document, {
|
||||
deleteDocument: false,
|
||||
});
|
||||
const documentJson = await this.removeDocumentInStructure(document);
|
||||
await this.addDocumentToStructure(document, index, { documentJson });
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
type DeleteDocumentOptions = {
|
||||
deleteDocument: boolean,
|
||||
Collection.prototype.deleteDocument = async function(document) {
|
||||
await this.removeDocumentInStructure(document, { save: true });
|
||||
await document.deleteWithChildren();
|
||||
};
|
||||
|
||||
/**
|
||||
* removeDocument is used for both deleting documents (deleteDocument: true)
|
||||
* and removing them temporarily from the structure while they are being moved
|
||||
* (deleteDocument: false).
|
||||
*/
|
||||
Collection.prototype.removeDocument = async function(
|
||||
Collection.prototype.removeDocumentInStructure = async function(
|
||||
document,
|
||||
options: DeleteDocumentOptions = { deleteDocument: true }
|
||||
options?: { save?: boolean }
|
||||
) {
|
||||
if (!this.documentStructure) return;
|
||||
|
||||
let returnValue;
|
||||
let unlock;
|
||||
|
||||
// documentStructure can only be updated by one request at the time
|
||||
const unlock = await asyncLock('testLock');
|
||||
if (options && options.save) {
|
||||
// documentStructure can only be updated by one request at the time
|
||||
unlock = await asyncLock(`collection-${this.id}`);
|
||||
}
|
||||
|
||||
const existingData = {
|
||||
old: this.documentStructure,
|
||||
documentId: document,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
options,
|
||||
};
|
||||
|
||||
// Helper to destroy all child documents for a document
|
||||
const deleteChildren = async documentId => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: { parentDocumentId: documentId },
|
||||
});
|
||||
childDocuments.forEach(async child => {
|
||||
await deleteChildren(child.id);
|
||||
await child.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
// Prune, and destroy if needed, from the document structure
|
||||
const deleteFromChildren = async (children, id) => {
|
||||
const removeFromChildren = async (children, id) => {
|
||||
children = await Promise.all(
|
||||
children.map(async childDocument => {
|
||||
return {
|
||||
...childDocument,
|
||||
children: await deleteFromChildren(childDocument.children, id),
|
||||
children: await removeFromChildren(childDocument.children, id),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const match = _.find(children, { id });
|
||||
const match = find(children, { id });
|
||||
if (match) {
|
||||
if (!options.deleteDocument && !returnValue) returnValue = match;
|
||||
_.remove(children, { id });
|
||||
|
||||
if (options.deleteDocument) {
|
||||
const childDocument = await Document.findById(id);
|
||||
// Delete the actual document
|
||||
if (childDocument) await childDocument.destroy();
|
||||
// Delete all child documents
|
||||
await deleteChildren(id);
|
||||
}
|
||||
if (!returnValue) returnValue = match;
|
||||
remove(children, { id });
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
this.documentStructure = await deleteFromChildren(
|
||||
this.documentStructure = await removeFromChildren(
|
||||
this.documentStructure,
|
||||
document.id
|
||||
);
|
||||
|
||||
if (options.deleteDocument) await this.save();
|
||||
|
||||
await Event.create({
|
||||
name: 'Collection#removeDocument',
|
||||
data: {
|
||||
...existingData,
|
||||
new: this.documentStructure,
|
||||
},
|
||||
collectionId: this.id,
|
||||
teamId: this.teamId,
|
||||
});
|
||||
|
||||
await unlock();
|
||||
if (options && options.save) {
|
||||
await this.save(options);
|
||||
if (unlock) await unlock();
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
@@ -156,8 +156,6 @@ describe('#moveDocument', () => {
|
||||
|
||||
test('should move a document with children', async () => {
|
||||
const { collection, document } = await seed();
|
||||
|
||||
// Add a child for testing
|
||||
const newDocument = await Document.create({
|
||||
parentDocumentId: document.id,
|
||||
collectionId: collection.id,
|
||||
@@ -182,14 +180,14 @@ describe('#removeDocument', () => {
|
||||
const { collection, document } = await seed();
|
||||
jest.spyOn(collection, 'save');
|
||||
|
||||
await collection.removeDocument(document);
|
||||
await collection.deleteDocument(document);
|
||||
expect(collection.save).toBeCalled();
|
||||
});
|
||||
|
||||
test('should remove documents from root', async () => {
|
||||
const { collection, document } = await seed();
|
||||
|
||||
await collection.removeDocument(document);
|
||||
await collection.deleteDocument(document);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
|
||||
// Verify that the document was removed
|
||||
@@ -219,7 +217,7 @@ describe('#removeDocument', () => {
|
||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
||||
|
||||
// Remove the document
|
||||
await collection.removeDocument(document);
|
||||
await collection.deleteDocument(document);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
where: {
|
||||
@@ -249,7 +247,7 @@ describe('#removeDocument', () => {
|
||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
||||
|
||||
// Remove the document
|
||||
await collection.removeDocument(newDocument);
|
||||
await collection.deleteDocument(newDocument);
|
||||
|
||||
expect(collection.documentStructure.length).toBe(2);
|
||||
expect(collection.documentStructure[0].children.length).toBe(0);
|
||||
@@ -268,9 +266,7 @@ describe('#removeDocument', () => {
|
||||
const { collection, document } = await seed();
|
||||
jest.spyOn(collection, 'save');
|
||||
|
||||
const removedNode = await collection.removeDocument(document, {
|
||||
deleteDocument: false,
|
||||
});
|
||||
const removedNode = await collection.removeDocumentInStructure(document);
|
||||
expect(collection.documentStructure.length).toBe(1);
|
||||
expect(destroyMock).not.toBeCalled();
|
||||
expect(collection.save).not.toBeCalled();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { map, find, compact, uniq } from 'lodash';
|
||||
import randomstring from 'randomstring';
|
||||
import MarkdownSerializer from 'slate-md-serializer';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import Sequelize from 'sequelize';
|
||||
import Sequelize, { type Transaction } from 'sequelize';
|
||||
import removeMarkdown from '@tommoor/remove-markdown';
|
||||
|
||||
import isUUID from 'validator/lib/isUUID';
|
||||
@@ -91,6 +91,7 @@ const Document = sequelize.define(
|
||||
},
|
||||
text: DataTypes.TEXT,
|
||||
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||
archivedAt: DataTypes.DATE,
|
||||
publishedAt: DataTypes.DATE,
|
||||
parentDocumentId: DataTypes.UUID,
|
||||
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
||||
@@ -183,18 +184,20 @@ Document.associate = models => {
|
||||
}));
|
||||
};
|
||||
|
||||
Document.findById = async id => {
|
||||
Document.findById = async (id, options) => {
|
||||
const scope = Document.scope('withUnpublished');
|
||||
|
||||
if (isUUID(id)) {
|
||||
return scope.findOne({
|
||||
where: { id },
|
||||
...options,
|
||||
});
|
||||
} else if (id.match(URL_REGEX)) {
|
||||
return scope.findOne({
|
||||
where: {
|
||||
urlId: id.match(URL_REGEX)[1],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -222,6 +225,7 @@ Document.searchForUser = async (
|
||||
FROM documents
|
||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||
"collectionId" IN(:collectionIds) AND
|
||||
"archivedAt" IS NULL AND
|
||||
"deletedAt" IS NULL AND
|
||||
("publishedAt" IS NOT NULL OR "createdById" = '${user.id}')
|
||||
ORDER BY
|
||||
@@ -271,7 +275,7 @@ Document.addHook('beforeSave', async model => {
|
||||
if (!model.publishedAt) return;
|
||||
|
||||
const collection = await Collection.findById(model.collectionId);
|
||||
if (collection.type !== 'atlas') return;
|
||||
if (!collection || collection.type !== 'atlas') return;
|
||||
|
||||
await collection.updateDocument(model);
|
||||
model.collection = collection;
|
||||
@@ -281,7 +285,7 @@ Document.addHook('afterCreate', async model => {
|
||||
if (!model.publishedAt) return;
|
||||
|
||||
const collection = await Collection.findById(model.collectionId);
|
||||
if (collection.type !== 'atlas') return;
|
||||
if (!collection || collection.type !== 'atlas') return;
|
||||
|
||||
await collection.addDocumentToStructure(model);
|
||||
model.collection = collection;
|
||||
@@ -296,6 +300,48 @@ Document.addHook('afterDestroy', model =>
|
||||
|
||||
// Instance methods
|
||||
|
||||
// Note: This method marks the document and it's children as deleted
|
||||
// in the database, it does not permanantly delete them OR remove
|
||||
// from the collection structure.
|
||||
Document.prototype.deleteWithChildren = async function(options) {
|
||||
// Helper to destroy all child documents for a document
|
||||
const loopChildren = async (documentId, opts) => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: { parentDocumentId: documentId },
|
||||
});
|
||||
childDocuments.forEach(async child => {
|
||||
await loopChildren(child.id, opts);
|
||||
await child.destroy(opts);
|
||||
});
|
||||
};
|
||||
|
||||
await loopChildren(this.id, options);
|
||||
await this.destroy(options);
|
||||
};
|
||||
|
||||
Document.prototype.archiveWithChildren = async function(userId, options) {
|
||||
const archivedAt = new Date();
|
||||
|
||||
// Helper to archive all child documents for a document
|
||||
const archiveChildren = async parentDocumentId => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: { parentDocumentId },
|
||||
});
|
||||
childDocuments.forEach(async child => {
|
||||
await archiveChildren(child.id);
|
||||
|
||||
child.archivedAt = archivedAt;
|
||||
child.lastModifiedById = userId;
|
||||
await child.save(options);
|
||||
});
|
||||
};
|
||||
|
||||
await archiveChildren(this.id);
|
||||
this.archivedAt = archivedAt;
|
||||
this.lastModifiedById = userId;
|
||||
return this.save(options);
|
||||
};
|
||||
|
||||
Document.prototype.publish = async function() {
|
||||
if (this.publishedAt) return this.save();
|
||||
|
||||
@@ -312,6 +358,74 @@ Document.prototype.publish = async function() {
|
||||
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) {
|
||||
// archive any children and remove from the document structure
|
||||
const collection = await this.getCollection();
|
||||
await collection.removeDocumentInStructure(this, { save: true });
|
||||
this.collection = collection;
|
||||
|
||||
this.archivedAt = new Date();
|
||||
this.lastModifiedById = userId;
|
||||
await this.save();
|
||||
await this.archiveWithChildren(userId);
|
||||
|
||||
events.add({ name: 'documents.archive', model: this });
|
||||
return this;
|
||||
};
|
||||
|
||||
// Restore an archived document back to being visible to the team
|
||||
Document.prototype.unarchive = async function(userId) {
|
||||
const collection = await this.getCollection();
|
||||
|
||||
// check to see if the documents parent hasn't been archived also
|
||||
// If it has then restore the document to the collection root.
|
||||
if (this.parentDocumentId) {
|
||||
const parent = await Document.findOne({
|
||||
where: {
|
||||
id: this.parentDocumentId,
|
||||
archivedAt: {
|
||||
// $FlowFixMe
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!parent) this.parentDocumentId = undefined;
|
||||
}
|
||||
|
||||
await collection.addDocumentToStructure(this);
|
||||
this.collection = collection;
|
||||
|
||||
this.archivedAt = null;
|
||||
this.lastModifiedById = userId;
|
||||
await this.save();
|
||||
|
||||
events.add({ name: 'documents.unarchive', model: this });
|
||||
return this;
|
||||
};
|
||||
|
||||
// Delete a document, archived or otherwise.
|
||||
Document.prototype.delete = function(options) {
|
||||
return sequelize.transaction(async (transaction: Transaction): Promise<*> => {
|
||||
if (!this.archivedAt) {
|
||||
// delete any children and remove from the document structure
|
||||
const collection = await this.getCollection();
|
||||
if (collection) await collection.deleteDocument(this, { transaction });
|
||||
}
|
||||
|
||||
await Revision.destroy({
|
||||
where: { documentId: this.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
await this.destroy({ transaction, ...options });
|
||||
|
||||
events.add({ name: 'documents.delete', model: this });
|
||||
return this;
|
||||
});
|
||||
};
|
||||
|
||||
Document.prototype.getTimestamp = function() {
|
||||
return Math.round(new Date(this.updatedAt).getTime() / 1000);
|
||||
};
|
||||
|
||||
@@ -370,9 +370,23 @@ export default function Pricing() {
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="documents.archive" label="Archive a document">
|
||||
<Description>
|
||||
Archive a document and all of its child documents, if any.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument
|
||||
id="id"
|
||||
description="Document ID or URI identifier"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="documents.delete" label="Delete a document">
|
||||
<Description>
|
||||
Delete a document and all of its child documents if any.
|
||||
Permanantly delete a document and all of its child documents, if
|
||||
any.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument
|
||||
@@ -403,7 +417,8 @@ export default function Pricing() {
|
||||
>
|
||||
<Description>
|
||||
Restores a document to a previous revision by creating a new
|
||||
revision with the contents of the given revisionId.
|
||||
revision with the contents of the given revisionId or restores an
|
||||
archived document if no revisionId is passed.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument
|
||||
@@ -414,7 +429,6 @@ export default function Pricing() {
|
||||
<Argument
|
||||
id="revisionId"
|
||||
description="Revision ID to restore to"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
@@ -6,18 +6,40 @@ const { allow, cannot } = policy;
|
||||
|
||||
allow(User, 'create', Document);
|
||||
|
||||
allow(
|
||||
User,
|
||||
['read', 'update', 'delete', 'share'],
|
||||
Document,
|
||||
(user, document) => {
|
||||
if (document.collection) {
|
||||
if (cannot(user, 'read', document.collection)) return false;
|
||||
}
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
allow(User, ['read', 'delete'], Document, (user, document) => {
|
||||
if (document.collection) {
|
||||
if (cannot(user, 'read', document.collection)) return false;
|
||||
}
|
||||
);
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, ['update', 'share'], Document, (user, document) => {
|
||||
if (document.collection) {
|
||||
if (cannot(user, 'read', document.collection)) return false;
|
||||
}
|
||||
if (document.archivedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, 'archive', Document, (user, document) => {
|
||||
if (document.collection) {
|
||||
if (cannot(user, 'read', document.collection)) return false;
|
||||
}
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, 'unarchive', Document, (user, document) => {
|
||||
if (document.collection) {
|
||||
if (cannot(user, 'read', document.collection)) return false;
|
||||
}
|
||||
if (!document.archivedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(
|
||||
Document,
|
||||
|
||||
@@ -32,6 +32,8 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
||||
updatedAt: document.updatedAt,
|
||||
updatedBy: undefined,
|
||||
publishedAt: document.publishedAt,
|
||||
archivedAt: document.archivedAt,
|
||||
deletedAt: document.deletedAt,
|
||||
team: document.teamId,
|
||||
collaborators: [],
|
||||
starred: !!(document.starred && document.starred.length),
|
||||
|
||||
@@ -18,7 +18,7 @@ export default (
|
||||
ctx: Object,
|
||||
user: User,
|
||||
options: Options = {}
|
||||
): UserPresentation => {
|
||||
): ?UserPresentation => {
|
||||
const userData = {};
|
||||
userData.id = user.id;
|
||||
userData.createdAt = user.createdAt;
|
||||
|
||||
Reference in New Issue
Block a user