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:
Tom Moor
2019-11-18 18:51:32 -08:00
committed by GitHub
parent 14f6e6abad
commit e404955394
20 changed files with 346 additions and 30 deletions

View File

@@ -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

View File

@@ -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
View 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
View 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);
});
});