feat: Trash (#1082)
* wip: trash * Enable restoration of deleted documents * update Trash icon * Add endpoint to trigger garbage collection * fix: account for drafts * fix: Archived documents should be deletable * fix: Missing delete cascade * bump: upgrade rich-markdown-editor
This commit is contained in:
@@ -187,6 +187,46 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.deleted', auth(), pagination(), async ctx => {
|
||||
const { sort = 'deletedAt' } = ctx.body;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const collectionScope = { method: ['withCollection', user.id] };
|
||||
const documents = await Document.scope(collectionScope).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
deletedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{ model: User, as: 'createdBy', paranoid: false },
|
||||
{ model: User, as: 'updatedBy', paranoid: false },
|
||||
],
|
||||
paranoid: false,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
const policies = presentPolicies(user, documents);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||
let { sort = 'updatedAt', direction } = ctx.body;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
@@ -409,9 +449,27 @@ router.post('documents.restore', auth(), async ctx => {
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
if (document.archivedAt) {
|
||||
if (document.deletedAt) {
|
||||
authorize(user, 'restore', document);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.unarchive(user.id);
|
||||
|
||||
await Event.create({
|
||||
name: 'documents.restore',
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, 'unarchive', document);
|
||||
|
||||
// restore a previously archived document
|
||||
|
||||
@@ -15,6 +15,7 @@ import shares from './shares';
|
||||
import team from './team';
|
||||
import integrations from './integrations';
|
||||
import notificationSettings from './notificationSettings';
|
||||
import utils from './utils';
|
||||
|
||||
import { NotFoundError } from '../errors';
|
||||
import errorHandling from './middlewares/errorHandling';
|
||||
@@ -47,6 +48,7 @@ router.use('/', shares.routes());
|
||||
router.use('/', team.routes());
|
||||
router.use('/', integrations.routes());
|
||||
router.use('/', notificationSettings.routes());
|
||||
router.use('/', utils.routes());
|
||||
router.post('*', ctx => {
|
||||
ctx.throw(new NotFoundError('Endpoint not found'));
|
||||
});
|
||||
|
||||
31
server/api/utils.js
Normal file
31
server/api/utils.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import Router from 'koa-router';
|
||||
import subDays from 'date-fns/sub_days';
|
||||
import { AuthenticationError } from '../errors';
|
||||
import { Document } from '../models';
|
||||
import { Op } from '../sequelize';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post('utils.gc', async ctx => {
|
||||
const { token } = ctx.body;
|
||||
|
||||
if (process.env.UTILS_SECRET !== token) {
|
||||
throw new AuthenticationError('Invalid secret token');
|
||||
}
|
||||
|
||||
await Document.scope('withUnpublished').destroy({
|
||||
where: {
|
||||
deletedAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
},
|
||||
},
|
||||
force: true,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
74
server/api/utils.test.js
Normal file
74
server/api/utils.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import subDays from 'date-fns/sub_days';
|
||||
import app from '../app';
|
||||
import { Document } from '../models';
|
||||
import { sequelize } from '../sequelize';
|
||||
import { flushdb } from '../test/support';
|
||||
import { buildDocument } from '../test/factories';
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(flushdb);
|
||||
afterAll(server.close);
|
||||
|
||||
describe('#utils.gc', async () => {
|
||||
it('should destroy documents deleted more than 30 days ago', async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
`UPDATE documents SET "deletedAt" = '${subDays(
|
||||
new Date(),
|
||||
60
|
||||
).toISOString()}' WHERE id = '${document.id}'`
|
||||
);
|
||||
|
||||
const res = await server.post('/api/utils.gc', {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
const reloaded = await Document.scope().findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(reloaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should destroy draft documents deleted more than 30 days ago', async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: undefined,
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
`UPDATE documents SET "deletedAt" = '${subDays(
|
||||
new Date(),
|
||||
60
|
||||
).toISOString()}' WHERE id = '${document.id}'`
|
||||
);
|
||||
|
||||
const res = await server.post('/api/utils.gc', {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
const reloaded = await Document.scope().findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(reloaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/utils.gc');
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
22
server/migrations/20191118023010-cascade-delete.js
Normal file
22
server/migrations/20191118023010-cascade-delete.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const tableName = 'revisions';
|
||||
const constraintName = 'revisions_documentId_fkey';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}"
|
||||
add constraint "${constraintName}" foreign key("documentId") references "documents" ("id")
|
||||
on delete cascade`
|
||||
);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}"\
|
||||
add constraint "${constraintName}" foreign key("documentId") references "documents" ("id")
|
||||
on delete no action`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -147,9 +147,11 @@ Document.associate = models => {
|
||||
});
|
||||
Document.hasMany(models.Backlink, {
|
||||
as: 'backlinks',
|
||||
onDelete: 'cascade',
|
||||
});
|
||||
Document.hasMany(models.Star, {
|
||||
as: 'starred',
|
||||
onDelete: 'cascade',
|
||||
});
|
||||
Document.hasMany(models.View, {
|
||||
as: 'views',
|
||||
@@ -514,6 +516,10 @@ Document.prototype.unarchive = async function(userId) {
|
||||
await collection.addDocumentToStructure(this);
|
||||
this.collection = collection;
|
||||
|
||||
if (this.deletedAt) {
|
||||
await this.restore();
|
||||
}
|
||||
|
||||
this.archivedAt = null;
|
||||
this.lastModifiedById = userId;
|
||||
await this.save();
|
||||
|
||||
@@ -56,6 +56,7 @@ Event.ACTIVITY_EVENTS = [
|
||||
'documents.pin',
|
||||
'documents.unpin',
|
||||
'documents.delete',
|
||||
'documents.restore',
|
||||
'collections.create',
|
||||
'collections.delete',
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ allow(User, ['read', 'download'], Document, (user, document) => {
|
||||
|
||||
allow(User, ['share'], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
// existance of collection option is not required here to account for share tokens
|
||||
if (document.collection && cannot(user, 'read', document.collection)) {
|
||||
@@ -29,6 +30,7 @@ allow(User, ['share'], Document, (user, document) => {
|
||||
|
||||
allow(User, ['star', 'unstar'], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
invariant(
|
||||
@@ -47,6 +49,7 @@ allow(User, 'update', Document, (user, document) => {
|
||||
);
|
||||
if (cannot(user, 'update', document.collection)) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
@@ -58,6 +61,7 @@ allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
|
||||
);
|
||||
if (cannot(user, 'update', document.collection)) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
@@ -65,15 +69,26 @@ allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
|
||||
|
||||
allow(User, 'delete', Document, (user, document) => {
|
||||
// unpublished drafts can always be deleted
|
||||
if (!document.publishedAt && user.teamId === document.teamId) {
|
||||
if (
|
||||
!document.deletedAt &&
|
||||
!document.publishedAt &&
|
||||
user.teamId === document.teamId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// allow deleting document without a collection
|
||||
if (document.collection && cannot(user, 'update', document.collection))
|
||||
if (document.collection && cannot(user, 'update', document.collection)) {
|
||||
return false;
|
||||
if (document.archivedAt) return false;
|
||||
}
|
||||
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, 'restore', Document, (user, document) => {
|
||||
if (!document.deletedAt) return false;
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
@@ -86,6 +101,7 @@ allow(User, 'archive', Document, (user, document) => {
|
||||
|
||||
if (!document.publishedAt) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user