Changed Collection documents to documentStructure and other WIP stuff

This commit is contained in:
Jori Lallo
2017-06-04 14:40:27 -07:00
parent 9631e58e65
commit c229369efd
6 changed files with 131 additions and 192 deletions

View File

@ -1,12 +1,11 @@
// @flow
import Router from 'koa-router'; import Router from 'koa-router';
import httpErrors from 'http-errors'; import httpErrors from 'http-errors';
import { lock } from '../redis';
import isUUID from 'validator/lib/isUUID'; import isUUID from 'validator/lib/isUUID';
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/; const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
import auth from './middlewares/authentication'; import auth from './middlewares/authentication';
// import pagination from './middlewares/pagination';
import { presentDocument } from '../presenters'; import { presentDocument } from '../presenters';
import { Document, Collection } from '../models'; import { Document, Collection } from '../models';
@ -96,10 +95,14 @@ router.post('documents.search', auth(), async ctx => {
}); });
router.post('documents.create', auth(), async ctx => { router.post('documents.create', auth(), async ctx => {
const { collection, title, text, parentDocument } = ctx.body; const { collection, title, text, parentDocument, index } = ctx.body;
ctx.assertPresent(collection, 'collection is required'); ctx.assertPresent(collection, 'collection is required');
ctx.assertUuid(collection, 'collection must be an uuid');
ctx.assertPresent(title, 'title is required'); ctx.assertPresent(title, 'title is required');
ctx.assertPresent(text, 'text is required'); ctx.assertPresent(text, 'text is required');
if (parentDocument)
ctx.assertUuid(parentDocument, 'parentDocument must be an uuid');
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
const user = ctx.state.user; const user = ctx.state.user;
const ownerCollection = await Collection.findOne({ const ownerCollection = await Collection.findOne({
@ -111,45 +114,40 @@ router.post('documents.create', auth(), async ctx => {
if (!ownerCollection) throw httpErrors.BadRequest(); if (!ownerCollection) throw httpErrors.BadRequest();
const document = await (() => { // FIXME: should we validate the existance of parentDocument?
return new Promise(resolve => { let parentDocumentObj = {};
lock(ownerCollection.id, 10000, async done => { if (parentDocument && ownerCollection.type === 'atlas') {
// FIXME: should we validate the existance of parentDocument? parentDocumentObj = await Document.findOne({
let parentDocumentObj = {}; where: {
if (parentDocument && ownerCollection.type === 'atlas') { id: parentDocument,
parentDocumentObj = await Document.findOne({ atlasId: ownerCollection.id,
where: { },
id: parentDocument,
atlasId: ownerCollection.id,
},
});
}
const newDocument = await Document.create({
parentDocumentId: parentDocumentObj.id,
atlasId: ownerCollection.id,
teamId: user.teamId,
userId: user.id,
lastModifiedById: user.id,
createdById: user.id,
title,
text,
});
// TODO: Move to afterSave hook if possible with imports
if (parentDocument && ownerCollection.type === 'atlas') {
await ownerCollection.reload();
ownerCollection.addNodeToNavigationTree(newDocument);
await ownerCollection.save();
}
done(resolve(newDocument));
});
}); });
})(); }
const newDocument = await Document.create({
parentDocumentId: parentDocumentObj.id,
atlasId: ownerCollection.id,
teamId: user.teamId,
userId: user.id,
lastModifiedById: user.id,
createdById: user.id,
title,
text,
});
// TODO: Move to afterSave hook if possible with imports
if (parentDocument && ownerCollection.type === 'atlas') {
ownerCollection.addDocument(
newDocument,
newDocument.parentDocumentId,
index || -1
);
await ownerCollection.save();
}
ctx.body = { ctx.body = {
data: await presentDocument(ctx, document, { data: await presentDocument(ctx, newDocument, {
includeCollection: true, includeCollection: true,
includeCollaborators: true, includeCollaborators: true,
}), }),
@ -174,11 +172,9 @@ router.post('documents.update', auth(), async ctx => {
document.lastModifiedById = user.id; document.lastModifiedById = user.id;
await document.save(); await document.save();
// Update
// TODO: Add locking
const collection = await Collection.findById(document.atlasId); const collection = await Collection.findById(document.atlasId);
if (collection.type === 'atlas') { if (collection.type === 'atlas') {
await collection.updateNavigationTree(); await collection.updateDocument(document);
} }
ctx.body = { ctx.body = {
@ -200,7 +196,6 @@ router.post('documents.delete', auth(), async ctx => {
if (!document || document.teamId !== user.teamId) if (!document || document.teamId !== user.teamId)
throw httpErrors.BadRequest(); throw httpErrors.BadRequest();
// TODO: Add locking
if (collection.type === 'atlas') { if (collection.type === 'atlas') {
// Don't allow deletion of root docs // Don't allow deletion of root docs
if (!document.parentDocumentId) { if (!document.parentDocumentId) {

View File

@ -1,3 +1,4 @@
// @flow
import apiError from '../../errors'; import apiError from '../../errors';
import validator from 'validator'; import validator from 'validator';
@ -9,18 +10,24 @@ export default function validation() {
} }
}; };
ctx.assertEmail = function assertEmail(value, message) { ctx.assertEmail = (value, message) => {
if (!validator.isEmail(value)) { if (!validator.isEmail(value)) {
throw apiError(400, 'validation_error', message); throw apiError(400, 'validation_error', message);
} }
}; };
ctx.assertUuid = function assertUuid(value, message) { ctx.assertUuid = (value, message) => {
if (!validator.isUUID(value)) { if (!validator.isUUID(value)) {
throw apiError(400, 'validation_error', message); throw apiError(400, 'validation_error', message);
} }
}; };
ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(value, { min: 0 })) {
throw apiError(400, 'validation_error', message);
}
};
return next(); return next();
}; };
} }

View File

@ -1,14 +1,14 @@
module.exports = { module.exports = {
up: (queryInterface, Sequelize) => { up: (queryInterface, Sequelize) => {
queryInterface.renameTable('atlases', 'collections'); queryInterface.renameTable('atlases', 'collections');
queryInterface.addColumn('collections', 'documents', { queryInterface.addColumn('collections', 'documentStructure', {
type: Sequelize.ARRAY(Sequelize.JSONB), type: Sequelize.JSONB,
allowNull: true, allowNull: true,
}); });
}, },
down: (queryInterface, _Sequelize) => { down: (queryInterface, _Sequelize) => {
queryInterface.renameTable('collections', 'atlases'); queryInterface.renameTable('collections', 'atlases');
queryInterface.removeColumn('atlases', 'documents'); queryInterface.removeColumn('atlases', 'documentStructure');
}, },
}; };

View File

@ -28,7 +28,7 @@ const Collection = sequelize.define(
/* type: atlas */ /* type: atlas */
navigationTree: DataTypes.JSONB, // legacy navigationTree: DataTypes.JSONB, // legacy
documents: DataTypes.ARRAY(DataTypes.JSONB), documentStructure: DataTypes.JSONB,
}, },
{ {
tableName: 'collections', tableName: 'collections',
@ -40,7 +40,7 @@ const Collection = sequelize.define(
afterCreate: async collection => { afterCreate: async collection => {
if (collection.type !== 'atlas') return; if (collection.type !== 'atlas') return;
await Document.create({ const document = await Document.create({
parentDocumentId: null, parentDocumentId: null,
atlasId: collection.id, atlasId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
@ -50,7 +50,12 @@ const Collection = sequelize.define(
title: 'Introduction', title: 'Introduction',
text: '# Introduction\n\nLets get started...', text: '# Introduction\n\nLets get started...',
}); });
await collection.buildStructure(); collection.documentStructure = [
{
...document.toJSON(),
children: [],
},
];
await collection.save(); await collection.save();
}, },
}, },
@ -60,157 +65,79 @@ const Collection = sequelize.define(
// return `/${slugifiedName}-c${this.urlId}`; // return `/${slugifiedName}-c${this.urlId}`;
return `/collections/${this.id}`; return `/collections/${this.id}`;
}, },
async buildStructure() {
if (this.navigationTree) return this.navigationTree;
const getNodeForDocument = async document => { async getDocumentsStructure() {
const children = await Document.findAll({ // Lazy fill this.documentStructure
where: { if (!this.documentStructure) {
parentDocumentId: document.id, this.documentStructure = this.navigationTree.children;
atlasId: this.id,
}, // Remove parent references from all root documents
await this.navigationTree.children.forEach(async ({ id }) => {
const document = await Document.findById(id);
document.parentDocumentId = null;
await document.save();
}); });
const childNodes = []; // Remove root document
await Promise.all( const rootDocument = await Document.findById(this.navigationTree.id);
children.map(async child => { await rootDocument.destroy();
return childNodes.push(await getNodeForDocument(child));
})
);
return { await this.save();
title: document.title,
id: document.id,
url: document.getUrl(),
children: childNodes,
};
};
const rootDocument = await Document.findOne({
where: {
parentDocumentId: null,
atlasId: this.id,
},
});
this.navigationTree = await getNodeForDocument(rootDocument);
return this.navigationTree;
},
async updateNavigationTree(tree = this.navigationTree) {
const nodeIds = [];
nodeIds.push(tree.id);
const rootDocument = await Document.findOne({
where: {
id: tree.id,
atlasId: this.id,
},
});
if (!rootDocument) throw new Error();
const newTree = {
id: tree.id,
title: rootDocument.title,
url: rootDocument.getUrl(),
children: [],
};
const getIdsForChildren = async children => {
const childNodes = [];
for (const child of children) {
const childDocument = await Document.findOne({
where: {
id: child.id,
atlasId: this.id,
},
});
if (childDocument) {
childNodes.push({
id: childDocument.id,
title: childDocument.title,
url: childDocument.getUrl(),
children: await getIdsForChildren(child.children),
});
nodeIds.push(child.id);
}
}
return childNodes;
};
newTree.children = await getIdsForChildren(tree.children);
const documents = await Document.findAll({
attributes: ['id'],
where: {
atlasId: this.id,
},
});
const documentIds = documents.map(doc => doc.id);
if (!_.isEqual(nodeIds.sort(), documentIds.sort())) {
throw new Error('Invalid navigation tree');
} }
this.navigationTree = newTree; return this.documentStructure;
await this.save();
return newTree;
}, },
async addNodeToNavigationTree(document) {
const newNode = {
id: document.id,
title: document.title,
url: document.getUrl(),
children: [],
};
const insertNode = node => { async addDocument(document, parentDocumentId, index) {
if (document.parentDocumentId === node.id) { if (!parentDocumentId) {
node.children.push(newNode); this.documentStructure.splice(index, 0, document.toJSON());
} else { } else {
node.children = node.children.map(childNode => { this.documentStructure = this.documentStructure.forEach(doc => {
return insertNode(childNode); if (parentDocumentId === document) {
}); return doc.children.splice(index, 0, document.toJSON());
} }
return node;
};
this.navigationTree = insertNode(this.navigationTree);
return this.navigationTree;
},
async deleteDocument(document) {
const deleteNodeAndDocument = async (
node,
documentId,
shouldDelete = false
) => {
// Delete node if id matches
if (document.id === node.id) shouldDelete = true;
const newChildren = [];
node.children.forEach(async childNode => {
const child = await deleteNodeAndDocument(
childNode,
documentId,
shouldDelete
);
if (child) newChildren.push(child);
}); });
node.children = newChildren; }
if (shouldDelete) { return this.documentStructure;
const doc = await Document.findById(node.id);
await doc.destroy();
}
return shouldDelete ? null : node;
};
this.navigationTree = await deleteNodeAndDocument(
this.navigationTree,
document.id
);
}, },
async updateDocument(document) {
// Update document info in this.documents
},
// async deleteDocument(document) {
// const deleteNodeAndDocument = async (
// node,
// documentId,
// shouldDelete = false
// ) => {
// // Delete node if id matches
// if (document.id === node.id) shouldDelete = true;
// const newChildren = [];
// node.children.forEach(async childNode => {
// const child = await deleteNodeAndDocument(
// childNode,
// documentId,
// shouldDelete
// );
// if (child) newChildren.push(child);
// });
// node.children = newChildren;
// if (shouldDelete) {
// const doc = await Document.findById(node.id);
// await doc.destroy();
// }
// return shouldDelete ? null : node;
// };
// this.navigationTree = await deleteNodeAndDocument(
// this.navigationTree,
// document.id
// );
// },
}, },
} }
); );

View File

@ -1,3 +1,4 @@
// @flow
import slug from 'slug'; import slug from 'slug';
import _ from 'lodash'; import _ from 'lodash';
import randomstring from 'randomstring'; import randomstring from 'randomstring';
@ -98,6 +99,13 @@ const Document = sequelize.define(
const slugifiedTitle = slugify(this.title); const slugifiedTitle = slugify(this.title);
return `/d/${slugifiedTitle}-${this.urlId}`; return `/d/${slugifiedTitle}-${this.urlId}`;
}, },
toJSON() {
return {
id: this.id,
title: this.title,
url: this.getUrl(),
};
},
}, },
} }
); );

View File

@ -92,8 +92,10 @@ export async function presentCollection(
updatedAt: collection.updatedAt, updatedAt: collection.updatedAt,
}; };
if (collection.type === 'atlas') if (collection.type === 'atlas') {
data.navigationTree = collection.navigationTree; data.navigationTree = collection.navigationTree;
data.documents = await collection.getDocumentsStructure();
}
if (includeRecentDocuments) { if (includeRecentDocuments) {
const documents = await Document.findAll({ const documents = await Document.findAll({