diff --git a/package.json b/package.json index 33f83fd093..aff1549adc 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "cross-env": "^7.0.3", "date-fns": "^2.15.0", "debug": "^4.3.2", + "deep-diff": "^1.0.2", "express": "^4.18.2", "express-session": "^1.17.1", "express-slow-down": "^1.3.1", diff --git a/src/client/components/pages/entities/cb-review.js b/src/client/components/pages/entities/cb-review.js index 3225979e53..706783e868 100644 --- a/src/client/components/pages/entities/cb-review.js +++ b/src/client/components/pages/entities/cb-review.js @@ -71,8 +71,8 @@ class EntityReviews extends React.Component { super(props); this.handleClick = this.handleClick.bind(this); this.state = { - reviews: props.entityReviews.reviews, - successfullyFetched: props.entityReviews.successfullyFetched + reviews: props.entityReviews?.reviews, + successfullyFetched: props.entityReviews?.successfullyFetched }; this.entityType = props.entityType; this.entityBBID = props.entityBBID; @@ -84,7 +84,7 @@ class EntityReviews extends React.Component { const data = await request.get(`/${this.entityType}/${this.entityBBID}/reviews`); this.setState({ - reviews: data.body.reviews, + reviews: data?.body?.reviews, successfullyFetched: data.body.successfullyFetched }); } @@ -99,7 +99,7 @@ class EntityReviews extends React.Component { }; const cbEntityType = mapEntityType[this.entityType]; const entityLink = `https://critiquebrainz.org/${cbEntityType}/${this.entityBBID}`; - if (this.state.reviews.reviews?.length && !_.isEmpty(this.state.reviews)) { + if (this.state.reviews?.reviews?.length && !_.isEmpty(this.state.reviews)) { const {reviews: reviewsData} = this.state.reviews; reviewContent = ( diff --git a/src/client/components/pages/entity-revisions.js b/src/client/components/pages/entity-revisions.js index a21cf3f421..e6ecbf7957 100644 --- a/src/client/components/pages/entity-revisions.js +++ b/src/client/components/pages/entity-revisions.js @@ -22,6 +22,8 @@ import PagerElement from './parts/pager'; import PropTypes from 'prop-types'; import React from 'react'; import RevisionsTable from './parts/revisions-table'; +import {get} from 'lodash'; +import request from 'superagent'; /** @@ -43,6 +45,7 @@ class EntityRevisions extends React.Component { // React does not autobind non-React class methods this.renderHeader = this.renderHeader.bind(this); this.searchResultsCallback = this.searchResultsCallback.bind(this); + this.onChangeMasterRevisionId = this.onChangeMasterRevisionId.bind(this); this.paginationUrl = './revisions/revisions'; } @@ -71,6 +74,16 @@ class EntityRevisions extends React.Component { ); } + async onChangeMasterRevisionId(newMasterRevisionId) { + if (newMasterRevisionId === get(this.props.entity, 'revisionId', null)) { return; } + try { + await request.post('master').send({revisionId: newMasterRevisionId}); + window.location.reload(); + } + catch (err) { + // error handling + } + } /** * Renders the EntityRevisions page, which is a list of all the revisions @@ -79,10 +92,14 @@ class EntityRevisions extends React.Component { * @returns {ReactElement} a HTML document which displays the Revision page */ render() { + const revisionId = get(this.props.entity, 'revisionId', null); return (
void, + onCancel: () => void +}; +function ConfirmationModal(props:Props) { + return ( + + + {props.title} + + +

{props.message}

+
+ + + + +
+ ); +} + +export default ConfirmationModal; diff --git a/src/client/components/pages/parts/revisions-table.js b/src/client/components/pages/parts/revisions-table.js index 1b13f90a5d..8fd734fd12 100644 --- a/src/client/components/pages/parts/revisions-table.js +++ b/src/client/components/pages/parts/revisions-table.js @@ -18,20 +18,43 @@ import * as bootstrap from 'react-bootstrap'; import * as utilsHelper from '../../../helpers/utils'; +import {faCodeBranch, faEye, faUndo} from '@fortawesome/free-solid-svg-icons'; import {genEntityIconHTMLElement, getEntityLabel, getEntityUrl} from '../../../helpers/entity'; +import ConfirmationModal from './confirmation-modal'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import PropTypes from 'prop-types'; import React from 'react'; -import {faCodeBranch} from '@fortawesome/free-solid-svg-icons'; -const {Table} = bootstrap; +const {Table, OverlayTrigger, Tooltip, Badge} = bootstrap; const {formatDate, stringToHTMLWithLinks} = utilsHelper; function RevisionsTable(props) { - const {results, showEntities, showRevisionNote, showRevisionEditor, tableHeading} = props; + const {results, showEntities, showActions, showRevisionNote, showRevisionEditor, tableHeading, masterRevisionId, handleMasterChange} = props; + const [show, setShow] = React.useState(false); + const [revisionId, setRevisionId] = React.useState(null); + const showConfirmModal = React.useCallback((rid) => { + setRevisionId(rid); + setShow(true); + }, []); + const hideConfirmModal = React.useCallback(() => setShow(false), []); + const onConfirm = React.useCallback(() => { + handleMasterChange(revisionId); + hideConfirmModal(); + }, [revisionId]); + function makeClickHandler(rid) { + return () => showConfirmModal(rid); + } return (
+ +

{tableHeading}

@@ -59,6 +82,7 @@ function RevisionsTable(props) { Note : null } Date + {showActions && Actions} @@ -83,6 +107,10 @@ function RevisionsTable(props) { /> } + {showActions && revision.revisionId === masterRevisionId && + + Active + } { @@ -127,6 +155,42 @@ function RevisionsTable(props) { : null } {formatDate(new Date(revision.createdAt), true)} + {showActions && + +
+ + View entity at this revision + } + placement="right" + > + + + + + + Revert entity to this revision + } + placement="right" + > + + +
+ + } )) } @@ -144,13 +208,19 @@ function RevisionsTable(props) { } RevisionsTable.propTypes = { + handleMasterChange: PropTypes.func, + masterRevisionId: PropTypes.number, results: PropTypes.array.isRequired, + showActions: PropTypes.bool, showEntities: PropTypes.bool, showRevisionEditor: PropTypes.bool, showRevisionNote: PropTypes.bool, tableHeading: PropTypes.node }; RevisionsTable.defaultProps = { + handleMasterChange: null, + masterRevisionId: null, + showActions: false, showEntities: false, showRevisionEditor: false, showRevisionNote: false, diff --git a/src/client/stylesheets/style.scss b/src/client/stylesheets/style.scss index 55c980ca65..bca1b8da84 100644 --- a/src/client/stylesheets/style.scss +++ b/src/client/stylesheets/style.scss @@ -867,6 +867,10 @@ div[class~=collapsing]+div[class=card-header] .accordion-arrow { max-width: 200px; } +.cursor-pointer{ + cursor: pointer; +} + .search-results-heading{ color: #754e37; } diff --git a/src/server/helpers/middleware.ts b/src/server/helpers/middleware.ts index 6cc0a59071..797ddfa0ac 100644 --- a/src/server/helpers/middleware.ts +++ b/src/server/helpers/middleware.ts @@ -30,6 +30,7 @@ import {getWikipediaExtract, selectWikipediaPage} from './wikimedia'; import _ from 'lodash'; import {getAcceptedLanguageCodes} from './i18n'; import {getReviewsFromCB} from './critiquebrainz'; +import {recursivelyGetMergedEntitiesBBIDs} from './revisions'; import {getWikidataId} from '../../common/helpers/wikimedia'; import log from 'log'; @@ -271,7 +272,9 @@ export async function redirectedBbid(req: $Request, res: $Response, next: NextFu const redirectBbid = await orm.func.entity.recursivelyGetRedirectBBID(orm, bbid); if (redirectBbid !== bbid) { // res.location(`${req.baseUrl}/${redirectBbid}`); - return res.redirect(301, `${req.baseUrl}${req.path.replace(bbid, redirectBbid)}`); + // Prevent redirecting to the same page even after revert + // For more info on Cache headers see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + return res.set('Cache-control', 'no-cache').redirect(301, `${req.baseUrl}${req.path.replace(bbid, redirectBbid)}`); } } catch (err) { @@ -451,3 +454,51 @@ export function validateCollaboratorIdsForCollectionRemove(req, res, next) { return next(); } + +export function decodeUrlQueryParams(req:$Request, res:$Response, next:NextFunction) { + req.query = _.mapValues(req.query, decodeURIComponent); + return next(); +} + +export function makeRevisionLoader(modelName: string, additionalRels: Array, errMessage: string) { + const relations = [ + 'aliasSet.aliases.language', + 'annotation.lastRevision', + 'defaultAlias', + 'disambiguation', + 'identifierSet.identifiers.type', + 'relationshipSet.relationships.type', + 'revision.revision' + ].concat(additionalRels); + return async (req: $Request, res: $Response, next: NextFunction, revisionId: number) => { + const {orm}: any = req.app.locals; + const mainEntity:any = res.locals.entity; + const {bbid} = mainEntity; + const otherMergedBBIDs = await recursivelyGetMergedEntitiesBBIDs(orm, [bbid]); + try { + const Model = commonUtils.getEntityModelByType(orm, modelName); + const RevisionModel = orm[`${modelName}Revision`]; + // check if it is valid revision or not + await new RevisionModel() + .query((qb) => { + qb.distinct(`${RevisionModel.prototype.tableName}.id`, 'revision.created_at'); + qb.whereIn('bbid', [bbid, ...otherMergedBBIDs]); + qb.join('bookbrainz.revision', `${RevisionModel.prototype.tableName}.id`, '=', 'bookbrainz.revision.id'); + qb.where('bookbrainz.revision.id', '=', revisionId); + qb.orderBy('revision.created_at', 'DESC'); + }).fetch({debug: true, require: true}); + const entity = await Model.forge({revisionId}).fetch({ + require: true, + withRelated: relations + }); + if (!entity.dataId) { + entity.deleted = true; + } + res.locals.entity = entity.toJSON(); + return next(); + } + catch (err) { + return next(new error.NotFoundError(errMessage, req)); + } + }; +} diff --git a/src/server/helpers/revisions.ts b/src/server/helpers/revisions.ts index f1e8d7b620..0a160a4835 100644 --- a/src/server/helpers/revisions.ts +++ b/src/server/helpers/revisions.ts @@ -17,7 +17,9 @@ */ import * as error from '../../common/helpers/error'; -import {flatMap} from 'lodash'; +import {camelCase, flatMap, pick, upperFirst} from 'lodash'; +import {EntityType} from '../../client/entity-editor/relationship-editor/types'; +import diff from 'deep-diff'; function getRevisionModels(orm) { @@ -189,7 +191,7 @@ export async function getOrderedRevisionForEditorPage(from, size, req) { * In order to fetch the complete history tree containing all three entities, we need to recursively check * if a source_bbid appears as a target_bbid in other rows. */ -async function recursivelyGetMergedEntitiesBBIDs(orm: any, bbids: string[]) { +export async function recursivelyGetMergedEntitiesBBIDs(orm: any, bbids: string[]) { const returnValue: string[] = []; await Promise.all(bbids.map(async (bbid) => { let thisLevelBBIDs = await orm.bookshelf.knex @@ -209,7 +211,7 @@ export async function getOrderedRevisionsForEntityPage(orm: any, from: number, s const revisions = await new RevisionModel() .query((qb) => { - qb.distinct(`${RevisionModel.prototype.tableName}.id`, 'revision.created_at'); + qb.distinct(`${RevisionModel.prototype.tableName}.id`, 'revision.created_at', 'data_id'); qb.whereIn('bbid', [bbid, ...otherMergedBBIDs]); qb.join('bookbrainz.revision', `${RevisionModel.prototype.tableName}.id`, '=', 'bookbrainz.revision.id'); qb.orderBy('revision.created_at', 'DESC'); @@ -232,10 +234,241 @@ export async function getOrderedRevisionsForEntityPage(orm: any, from: number, s const {revision} = rev; const editor = revision.author; const revisionId = revision.id; + const {dataId} = rev; delete revision.author; delete revision.authorId; delete revision.id; - return {editor, revisionId, ...revision}; + return {dataId, editor, revisionId, ...revision}; }); } +const EntityTypes = [ + 'Author', + 'Edition', + 'EditionGroup', + 'Publisher', + 'Series', + 'Work' +]; + +function getEntityRevisionModel(type:EntityType, orm) { + const entityType = upperFirst(camelCase(type)); + if (!EntityTypes.includes(entityType)) { return null; } + return orm[`${entityType}Revision`]; +} +function getEntityHeaderModel(type:EntityType, orm) { + const entityType = upperFirst(camelCase(type)); + if (!EntityTypes.includes(entityType)) { return null; } + return orm[`${entityType}Header`]; +} +function getEntityDataModel(type:EntityType, orm) { + if (!EntityTypes.includes(type)) { return null; } + return orm[`${upperFirst(camelCase(type))}Data`]; +} + +/** + * Fetches all related entity revisions for a particular revision + * @param {number} revisionId - The revision ID to get the associated entities for + * @param {Object} orm - The BookshelfJS ORM object + * @param {Object} transacting - The BookshelfJS transaction object + * @returns {Promise>>} - Returns a promise resolving to an object containing the associated entities for each type + */ +async function getAllRevisionEntity(revisionId:number, orm:any, transacting:any) { + const revisions = {}; + const key = 'parents'; + for (const type of EntityTypes) { + const entityRevisionModel = getEntityRevisionModel(type, orm); + // eslint-disable-next-line no-await-in-loop + const entityRevision = await entityRevisionModel.forge().where('id', revisionId).fetchAll({ + merge: false, + remove: false, + require: false, + transacting + }); + const RevisionModal = getEntityRevisionModel(type, orm); + const typeRevisions = []; + for (const entity of entityRevision.models) { + // fetch parent of a revision + const revisionParent = await entity.related('revision').fetch({require: false, transacting}) + .then((revision) => revision.related(key).fetch({require: false, transacting})) + .then((entities) => entities.map((ety) => ety.get('id'))) + .then((entitiesId) => { + if (entitiesId.length === 0) { + return null; + } + return new RevisionModal() + .where('bbid', entity.get('bbid')) + .query('whereIn', 'id', entitiesId) + .orderBy('id', 'DESC') + .fetch({require: false, transacting, withRelated: ['data']}); + }); + const entityJSON = entity.toJSON(); + if (revisionParent) { + entityJSON.parentRevision = revisionParent; + } + typeRevisions.push(entityJSON); + } + revisions[type] = typeRevisions; + } + return revisions; +} + +/** + * This is responsible for reverting revisions given start and end revision id. + * + * @param {number} fromRevisionID - The revision ID to start from + * @param {number} toRevisionID - The revision ID to end at + * @param {string} bbid - The BBID of the entity + * @param {Object} revisionMap - Keeps track of last known revision + * @param {Object} orm - The BookBrainz ORM + * @param {Object} transacting - Bookshelf transaction object (must be in + * progress) + * @returns + */ + +export async function recursivelyDoRevision(fromRevisionID:number, toRevisionID:number, bbid:string, + revisionMap:Record, orm:any, transacting:any) { + if (fromRevisionID === toRevisionID) { + return; + } + if (fromRevisionID < toRevisionID) { + throw new Error('fromRevisionID should be greater than toRevisionID'); + } + const effectedEntities = await getAllRevisionEntity(fromRevisionID, orm, transacting); + let nextId:number; + for (const type of EntityTypes) { + const entityRevision = effectedEntities[type]; + let mergeToBBID = null; + const mergeEffectedEntities = []; + revisionMap[type] = revisionMap[type] || {}; + for (const revision of entityRevision) { + if (revision.parentRevision) { + revisionMap[type][revision.bbid] = revision.parentRevision; + } + if (revision.bbid === bbid) { + nextId = revision.parentRevision ? revision.parentRevision.get('id') : null; + } + // handle merged entities + if (revision.isMerge) { + if (revision.dataId !== null) { + mergeToBBID = revision.bbid; + } + else { + mergeEffectedEntities.push(revision); + } + } + } + // if merged revision, then update redirect table + if (mergeToBBID !== null) { + for (const revision of mergeEffectedEntities) { + const sourceBbid = revision.bbid; + await orm.bookshelf.knex('bookbrainz.entity_redirect') + .transacting(transacting) + .where('source_bbid', sourceBbid).where('target_bbid', mergeToBBID).del(); + } + } + } + if (nextId) { + await recursivelyDoRevision(nextId, toRevisionID, bbid, revisionMap, orm, transacting); + } +} + +/** + * This function is responsible diffing two revisions and returning the new id set + * @param {number} oldRelationshipSetId - The relationship set ID to start from + * @param {number} newRelationshipSetId - The relationship set ID to end at + * @param {string} excludeBBID - The BBID of the entity to be excluded + * @param {Object} orm - The BookBrainz ORM + * @param {Object} transacting - Bookshelf transaction object (must be in + * @returns {Promise} - Returns a promise resolving to an object containing the associated entities relationship set + */ +async function diffRelationships(oldRelationshipSetId, newRelationshipSetId, excludeBBID, orm, transacting) { + const {RelationshipSet} = orm; + const oldRelatonhips = !oldRelationshipSetId ? [] : (await new RelationshipSet({id: oldRelationshipSetId}) + .fetch({transacting, withRelated: ['relationships']})).toJSON().relationships; + const newRelationships = !newRelationshipSetId ? [] : (await new RelationshipSet({id: newRelationshipSetId}) + .fetch({transacting, withRelated: ['relationships']})).toJSON().relationships; + // diff relationships using deep diff + const appliedDiffRels = []; + // apply those diff to the old relationships + diff.observableDiff(oldRelatonhips, newRelationships, (differ) => { + // Apply all changes except to the name property... + // either deleted or added excluded bbid present in either sourceBbid or targetBbid + if (differ.kind === 'A') { + if ((differ.item.kind === 'N' && (differ.item.rhs.sourceBbid === excludeBBID || differ.item.rhs.targetBbid === excludeBBID)) || + (differ.item.kind === 'D' && (differ.item.lhs.sourceBbid === excludeBBID || differ.item.lhs.targetBbid === excludeBBID))) { + if (differ.item.kind === 'N') { + const addedRelationship = differ.item.rhs; + addedRelationship.isRemoved = true; + appliedDiffRels.push(addedRelationship); + } + else { + const deletedRelationship = differ.item.lhs; + deletedRelationship.isAdded = true; + appliedDiffRels.push(deletedRelationship); + } + } + } + }); + const cleanedRels = appliedDiffRels.map((rel) => pick(rel, ['typeId', 'sourceBbid', 'targetBbid', 'attributeSetId', 'isAdded', 'isRemoved'])); + const nextRelSet = await orm.func.relationship.updateRelationshipSets(orm, transacting, null, cleanedRels); + return nextRelSet; +} + +/** + * This function is responsible for reverting revisions(undo only) from master to given target revision. + * @param {number} targetRevisionId - The revision ID to end at + * @param {string} mainBBID - The BBID of the entity + * @param {number} authorId - The ID of the user who is reverting + * @param {string} type - The type of entity + * @param {Object} orm - The BookBrainz ORM + * @param {Object} transacting - Bookshelf transaction object (must be in + * @returns {Promise} - Returns a promise resolving to nothing + */ +export async function revertRevision(targetRevisionId:number, mainBBID:string, authorId:number, type:EntityType, orm, transacting) { + // find current revision id + const EntityHeader = getEntityHeaderModel(type, orm); + const {Revision} = orm; + const entityHeader = await new EntityHeader({bbid: mainBBID}).fetch({require: false, transacting}); + if (!entityHeader) { + throw new Error('Entity not found'); + } + const currentRevisionId = entityHeader.get('masterRevisionId'); + const revisionMap = {}; + await recursivelyDoRevision(currentRevisionId, targetRevisionId, mainBBID, revisionMap, orm, transacting); + const parentRevisionIDs = new Set(); + // create a revision for reverting + const revision = await new Revision({authorId}).save(null, {method: 'insert', transacting}); + for (const etype of EntityTypes) { + const revisions = revisionMap[etype]; + const RevisionModal = getEntityRevisionModel(etype, orm); + const EntityDataModel = getEntityDataModel(etype, orm); + const TypeEntityHeader = getEntityHeaderModel(etype, orm); + for (const bbid in revisions) { + if (Object.prototype.hasOwnProperty.call(revisions, bbid)) { + const revisionJSON = revisions[bbid].toJSON(); + let dataId = revisionJSON.data.id; + // get current revision id + const effectedHeader = await new TypeEntityHeader({bbid}).fetch({require: false, transacting}); + const mid = effectedHeader.get('masterRevisionId'); + parentRevisionIDs.add(mid); + const currentRevision = (await new RevisionModal({id: mid}).fetch({withRelated: ['data']})).toJSON(); + const oldRelSetID = revisionJSON.data.relationshipSetId; + const newRelSetID = currentRevision?.data?.relationshipSetId; + // if present relationship set doesn't matches with previous revision's relationship set then update it by creating new relationship set along with entity data + if (newRelSetID !== oldRelSetID) { + const diffRel = await diffRelationships(oldRelSetID, newRelSetID, mainBBID, orm, transacting); + const newRelId = diffRel[bbid]?.get('id'); + const data = {...revisionJSON.data, relationshipSetId: newRelId}; + delete data.id; + const newEntityData = await EntityDataModel.forge(data).save(null, {method: 'insert', transacting}); + dataId = newEntityData.get('id'); + } + const revertRev = await new RevisionModal({bbid, dataId, id: revision.get('id')}).save(null, {method: 'insert', transacting}); + await new TypeEntityHeader({bbid}).save({masterRevisionId: revertRev.get('id')}, {method: 'update', transacting}); + } + } + } + const parents = await revision.related('parents').fetch({transacting}); + await parents.attach([...parentRevisionIDs], {transacting}); +} diff --git a/src/server/routes/entity/author.ts b/src/server/routes/entity/author.ts index 3992613ddb..a6d3ee23cb 100644 --- a/src/server/routes/entity/author.ts +++ b/src/server/routes/entity/author.ts @@ -23,18 +23,19 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { entityEditorMarkup, generateEntityProps, makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import _ from 'lodash'; import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; @@ -202,6 +203,14 @@ router.param( ) ); +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'Author', + ['authorType', 'gender', 'beginArea', 'endArea'], + 'Author Revision not found' + ) +); function _setAuthorTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -215,6 +224,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadWikipedi entityRoutes.displayEntity(req, res); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setAuthorTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/delete', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR), (req, res, next) => { if (!res.locals.entity.dataId) { return next(new ConflictError('This entity has already been deleted')); @@ -359,4 +373,29 @@ router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthor router.post('/:bbid/merge/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), mergeHandler); + +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {AuthorRevision} = orm; + const userID = req.user.id; + try { + // check if author revision exist for this entity + const targetRevision = await new AuthorRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge') || !targetRevision.get('dataId')) { + return next(new BadRequestError('Invalid target revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'Author', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + export default router; diff --git a/src/server/routes/entity/edition-group.ts b/src/server/routes/entity/edition-group.ts index 59b4b2ab4a..477bb4d568 100644 --- a/src/server/routes/entity/edition-group.ts +++ b/src/server/routes/entity/edition-group.ts @@ -23,18 +23,19 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { entityEditorMarkup, generateEntityProps, makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import _ from 'lodash'; import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; @@ -194,6 +195,23 @@ router.param( ) ); +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'EditionGroup', + [ + 'authorCredit.names.author.defaultAlias', + 'editionGroupType', + 'editions.defaultAlias', + 'editions.disambiguation', + 'editions.releaseEventSet.releaseEvents', + 'editions.identifierSet.identifiers.type', + 'editions.editionFormat' + ], + 'Edition Group Revision not found' + ) +); + function _setEditionGroupTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -208,6 +226,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadWikipedi entityRoutes.displayEntity(req, res); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setEditionGroupTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/delete', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR), (req, res, next) => { if (!res.locals.entity.dataId) { return next(new ConflictError('This entity has already been deleted')); @@ -361,4 +384,28 @@ router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthor router.post('/:bbid/merge/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), mergeHandler); +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {EditionGroupRevision} = orm; + const userID = req.user.id; + try { + // check if EditionGroup revision exist for this entity + const targetRevision = await new EditionGroupRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge')) { + return next(new BadRequestError('Cannot revert to a merged revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'EditionGroup', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + export default router; diff --git a/src/server/routes/entity/edition.ts b/src/server/routes/entity/edition.ts index 3239810f48..1a8c2c2a5c 100644 --- a/src/server/routes/entity/edition.ts +++ b/src/server/routes/entity/edition.ts @@ -23,6 +23,7 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { addInitialRelationship, entityEditorMarkup, @@ -30,7 +31,6 @@ import { makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import {RelationshipTypes} from '../../../client/entity-editor/relationship-editor/types'; import _ from 'lodash'; @@ -38,6 +38,7 @@ import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; import {makePromiseFromObject} from '../../../common/helpers/utils'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; /** **************************** @@ -356,6 +357,23 @@ router.param( ) ); +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'Edition', + [ + 'authorCredit.names.author.defaultAlias', + 'editionGroup.defaultAlias', + 'languageSet.languages', + 'editionFormat', + 'editionStatus', + 'releaseEventSet.releaseEvents', + 'publisherSet.publishers.defaultAlias' + ], + 'Edition Revision not found' + ) +); + function _setEditionTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -370,6 +388,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadWorkTabl entityRoutes.displayEntity(req, res); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setEditionTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/revisions', (req, res, next) => { const {EditionRevision} = req.app.locals.orm; _setEditionTitle(res); @@ -557,4 +580,29 @@ router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthor router.post('/:bbid/merge/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), mergeHandler); +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {EditionRevision} = orm; + const userID = req.user.id; + try { + // check if Edition revision exist for this entity + const targetRevision = await new EditionRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge')) { + return next(new BadRequestError('Cannot revert to a merged revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'Edition', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + + export default router; diff --git a/src/server/routes/entity/entity.tsx b/src/server/routes/entity/entity.tsx index 445418dab9..a23fdaceaa 100644 --- a/src/server/routes/entity/entity.tsx +++ b/src/server/routes/entity/entity.tsx @@ -207,6 +207,7 @@ export async function displayRevisions( from, nextEnabled, revisions: newResultsArray, + showActions: true, showRevisionEditor: true, showRevisionNote: true, size diff --git a/src/server/routes/entity/publisher.ts b/src/server/routes/entity/publisher.ts index e874ce06bf..838c6a15bd 100644 --- a/src/server/routes/entity/publisher.ts +++ b/src/server/routes/entity/publisher.ts @@ -23,18 +23,19 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { entityEditorMarkup, generateEntityProps, makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import _ from 'lodash'; import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; @@ -187,6 +188,15 @@ router.param( ) ); +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'Publisher', + ['publisherType', 'area'], + 'Publisher Revision not found' + ) +); + function _setPublisherTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -221,6 +231,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadWikipedi .catch(next); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setPublisherTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/delete', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR), (req, res, next) => { if (!res.locals.entity.dataId) { return next(new ConflictError('This entity has already been deleted')); @@ -358,4 +373,29 @@ router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthor router.post('/:bbid/merge/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), mergeHandler); +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {PublisherRevision} = orm; + const userID = req.user.id; + try { + // check if Publisher revision exist for this entity + const targetRevision = await new PublisherRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge')) { + return next(new BadRequestError('Cannot revert to a merged revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'Publisher', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + + export default router; diff --git a/src/server/routes/entity/series.ts b/src/server/routes/entity/series.ts index 836450b4b0..b17ab3ed22 100644 --- a/src/server/routes/entity/series.ts +++ b/src/server/routes/entity/series.ts @@ -23,18 +23,19 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { entityEditorMarkup, generateEntityProps, makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import _ from 'lodash'; import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; /** **************************** @@ -196,6 +197,21 @@ router.param( ) ); +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'Series', + [ + 'defaultAlias', + 'disambiguation', + 'seriesOrderingType', + 'identifierSet.identifiers.type' + ], + 'Series Revision not found' + ) +); + + function _setSeriesTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -210,6 +226,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadSeriesIt entityRoutes.displayEntity(req, res); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setSeriesTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/delete', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR), (req, res, next) => { if (!res.locals.entity.dataId) { return next(new ConflictError('This entity has already been deleted')); @@ -346,4 +367,29 @@ router.get( router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), createOrEditHandler); +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {SeriesRevision} = orm; + const userID = req.user.id; + try { + // check if Series revision exist for this entity + const targetRevision = await new SeriesRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge')) { + return next(new BadRequestError('Cannot revert to a merged revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'Series', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + + export default router; diff --git a/src/server/routes/entity/work.ts b/src/server/routes/entity/work.ts index 9a0890a49f..22a9906524 100644 --- a/src/server/routes/entity/work.ts +++ b/src/server/routes/entity/work.ts @@ -23,6 +23,7 @@ import * as middleware from '../../helpers/middleware'; import * as search from '../../../common/helpers/search'; import * as utils from '../../helpers/utils'; +import {BadRequestError, ConflictError} from '../../../common/helpers/error'; import { addInitialRelationship, entityEditorMarkup, @@ -30,7 +31,6 @@ import { makeEntityCreateOrEditHandler } from '../../helpers/entityRouteUtils'; -import {ConflictError} from '../../../common/helpers/error'; import {PrivilegeType} from '../../../common/helpers/privileges-utils'; import {RelationshipTypes} from '../../../client/entity-editor/relationship-editor/types'; import _ from 'lodash'; @@ -38,6 +38,7 @@ import {escapeProps} from '../../helpers/props'; import express from 'express'; import log from 'log'; import {makePromiseFromObject} from '../../../common/helpers/utils'; +import {revertRevision} from '../../helpers/revisions'; import target from '../../templates/target'; /** **************************** @@ -220,7 +221,14 @@ router.param( 'Work not found' ) ); - +router.param( + 'revisionId', + middleware.makeRevisionLoader( + 'Work', + ['workType', 'languageSet.languages'], + 'Work Revision not found' + ) +); function _setWorkTitle(res) { res.locals.title = utils.createEntityPageTitle( res.locals.entity, @@ -234,6 +242,11 @@ router.get('/:bbid', middleware.loadEntityRelationships, middleware.loadWikipedi entityRoutes.displayEntity(req, res); }); +router.get('/:bbid/revision/:revisionId', middleware.loadEntityRelationships, (req, res) => { + _setWorkTitle(res); + entityRoutes.displayEntity(req, res); +}); + router.get('/:bbid/delete', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR), (req, res, next) => { if (!res.locals.entity.dataId) { return next(new ConflictError('This entity has already been deleted')); @@ -369,4 +382,29 @@ router.post('/:bbid/edit/handler', auth.isAuthenticatedForHandler, auth.isAuthor router.post('/:bbid/merge/handler', auth.isAuthenticatedForHandler, auth.isAuthorized(ENTITY_EDITOR), mergeHandler); +router.post('/:bbid/master', auth.isAuthenticatedForHandler, async (req, res, next) => { + const {orm} = req.app.locals; + const {bbid} = req.params; + const {revisionId} = req.body; + const {WorkRevision} = orm; + const userID = req.user.id; + try { + // check if Work revision exist for this entity + const targetRevision = await new WorkRevision({bbid, id: revisionId}).fetch({ + require: true + }); + if (targetRevision.get('isMerge')) { + return next(new BadRequestError('Cannot revert to a merged revision')); + } + await orm.bookshelf.transaction((transacting) => revertRevision(revisionId, bbid, userID, 'Work', orm, transacting)); + } + catch (err) { + return next(new BadRequestError(err.message)); + } + return res.status(200).send({ + changed: true + }); +}); + + export default router; diff --git a/test/src/server/helpers/revisions.js b/test/src/server/helpers/revisions.js index 568a92aa6f..47aabeedb8 100644 --- a/test/src/server/helpers/revisions.js +++ b/test/src/server/helpers/revisions.js @@ -8,6 +8,7 @@ import { createEditionGroup, createEditor, createPublisher, + createSeries, createWork, truncateEntities } from '../../../test-helpers/create-entities'; @@ -15,11 +16,13 @@ import { getAssociatedEntityRevisions, getOrderedRevisionForEditorPage, getOrderedRevisions, - getOrderedRevisionsForEntityPage + getOrderedRevisionsForEntityPage, + revertRevision } from '../../../../src/server/helpers/revisions'; import chai from 'chai'; import {date} from 'faker'; +import {forEach} from 'lodash'; import isSorted from 'chai-sorted'; import orm from '../../../bookbrainz-data'; @@ -30,7 +33,8 @@ chai.use(isSorted); const { AuthorData, AuthorRevision, Revision, AliasSet, EditionData, EditionRevision, EditionGroupData, EditionGroupRevision, - Note, PublisherData, PublisherRevision, WorkData, WorkRevision + Note, PublisherData, PublisherRevision, WorkData, WorkRevision, + RelationshipType } = orm; @@ -423,6 +427,7 @@ describe('getOrderedRevisionsForEntityPage', () => { orderedRevisions.forEach((revision) => { expect(revision).to.have.keys( 'createdAt', + 'dataId', 'editor', 'notes', 'revisionId', @@ -450,6 +455,7 @@ describe('getOrderedRevisionsForEntityPage', () => { orderedRevisions.forEach((revision) => { expect(revision).to.have.keys( 'createdAt', + 'dataId', 'editor', 'notes', 'revisionId', @@ -477,6 +483,7 @@ describe('getOrderedRevisionsForEntityPage', () => { orderedRevisions.forEach((revision) => { expect(revision).to.have.keys( 'createdAt', + 'dataId', 'editor', 'notes', 'revisionId', @@ -504,6 +511,7 @@ describe('getOrderedRevisionsForEntityPage', () => { orderedRevisions.forEach((revision) => { expect(revision).to.have.keys( 'createdAt', + 'dataId', 'editor', 'notes', 'revisionId', @@ -531,6 +539,7 @@ describe('getOrderedRevisionsForEntityPage', () => { orderedRevisions.forEach((revision) => { expect(revision).to.have.keys( 'createdAt', + 'dataId', 'editor', 'notes', 'revisionId', @@ -618,3 +627,237 @@ describe('getOrderedRevisionsForEntityPage', () => { expect(orderedRevision[2].revisionId).to.be.equal(revisionID1); }); }); +function getCreateEntityFunction(type) { + switch (type) { + case 'Author': + return createAuthor; + case 'Edition': + return createEdition; + case 'EditionGroup': + return createEditionGroup; + case 'Publisher': + return createPublisher; + case 'Series': + return createSeries; + case 'Work': + return createWork; + default: + throw new Error('Invalid entity type'); + } +} + +describe('revertRevision', () => { + const entityTypes = ['Author', 'Edition', 'EditionGroup', 'Publisher', 'Series', 'Work']; + let aliasSetId; let author; let editorJSON; + + before(async () => { + const editor = await createEditor(); + editorJSON = editor.toJSON(); + author = await createAuthor(); + // In each revision, we will change the alias of entity to Author alias. + aliasSetId = author.get('aliasSetId'); + }); + + after(truncateEntities); + + forEach(entityTypes, (type) => { + it(`should be able to revert simple revision to a previous revision (${type})`, async () => { + // create new revision for revert + const mainEntity = await getCreateEntityFunction(type)(); + const revision = await new Revision({authorId: editorJSON.id}) + .save(null, {method: 'insert'}); + const oldAliasSetId = mainEntity.get('aliasSetId'); + const oldRevisionId = mainEntity.get('revisionId'); + // set parent of new revision to last revision + const parents = + await revision.related('parents').fetch(); + parents.attach([oldRevisionId]); + await mainEntity.save({aliasSetId, revisionId: revision.get('id')}, {method: 'update'}); + // eslint-disable-next-line max-nested-callbacks + await orm.bookshelf.transaction((trx) => revertRevision(oldRevisionId, mainEntity.get('bbid'), editorJSON.id, type, orm, trx)); + const EntityModel = orm[type]; + const rEntity = await new EntityModel({bbid: mainEntity.get('bbid')}).fetch({withRelated: ['revision']}); + const currentAliasSetId = rEntity.get('aliasSetId'); + // check if revert was successful + expect(currentAliasSetId).to.be.equal(oldAliasSetId); + }); + }); + + forEach(entityTypes, (type) => { + it(`should be able to revert delete revision (${type})`, async () => { + const mainEntity = await getCreateEntityFunction(type)(); + const revision = await new Revision({authorId: editorJSON.id}).save(null, {method: 'insert'}); + const oldAliasSetId = mainEntity.get('aliasSetId'); + const oldRevisionId = mainEntity.get('revisionId'); + const RevisionModel = orm[`${type}Revision`]; + const HeaderModel = orm[`${type}Header`]; + // since no trigger for delete we have to do delete manually + await new RevisionModel({bbid: mainEntity.get('bbid'), dataId: null, id: revision.get('id')}).save(null, {method: 'insert'}); + // set master revision of work to new revision + await new HeaderModel({bbid: mainEntity.get('bbid'), masterRevisionId: revision.get('id')}).save(null, {method: 'update'}); + const parents = await revision.related('parents').fetch(); + parents.attach([oldRevisionId]); + await orm.bookshelf.transaction((trx) => revertRevision(oldRevisionId, mainEntity.get('bbid'), editorJSON.id, type, orm, trx)); + const EntityModel = orm[type]; + const rEntity = await new EntityModel({bbid: mainEntity.get('bbid')}).fetch({withRelated: ['revision']}); + // check if revert was successful + expect(rEntity.get('dataId')).to.be.not.null; + expect(rEntity.get('aliasSetId')).to.be.equal(oldAliasSetId); + }); + }); + + forEach(entityTypes, (type) => { + it(`should be able to revert merge revision involving two entities (${type})`, async () => { + const mainEntity = await getCreateEntityFunction(type)(); + const mainEntity2 = await getCreateEntityFunction(type)(); + const revision = await new Revision({authorId: editorJSON.id}).save(null, {method: 'insert'}); + const oldAliasSetId1 = mainEntity.get('aliasSetId'); + const oldAliasSetId2 = mainEntity2.get('aliasSetId'); + // will try to revert back to workEntity revision + const oldRevisionId = mainEntity.get('revisionId'); + // manually merge work2 into workEntity + const RevisionModel = orm[`${type}Revision`]; + const HeaderModel = orm[`${type}Header`]; + await new RevisionModel({bbid: mainEntity2.get('bbid'), dataId: null, id: revision.get('id')}).save(null, {method: 'insert'}); + await new HeaderModel({bbid: mainEntity2.get('bbid'), masterRevisionId: revision.get('id')}).save(null, {method: 'update'}); + await orm.bookshelf.knex('bookbrainz.entity_redirect') + .insert({source_bbid: mainEntity2.get('bbid'), target_bbid: mainEntity.get('bbid')}); + // update workEntity alias + await mainEntity.save({aliasSetId, revisionId: revision.get('id')}, {method: 'update'}); + const parents = await revision.related('parents').fetch(); + parents.attach([oldRevisionId, mainEntity2.get('revisionId')]); + + // undo merge + await orm.bookshelf.transaction((trx) => revertRevision(oldRevisionId, mainEntity.get('bbid'), editorJSON.id, type, orm, trx)); + const rEntity1 = await new orm[type]({bbid: mainEntity.get('bbid')}).fetch({withRelated: ['revision']}); + const rEntity2 = await new orm[type]({bbid: mainEntity2.get('bbid')}).fetch({withRelated: ['revision']}); + expect(rEntity1.get('aliasSetId')).to.be.equal(oldAliasSetId1); + expect(rEntity2.get('aliasSetId')).to.be.equal(oldAliasSetId2); + }); + }); + + forEach(entityTypes, (type) => { + it(`should be able to revert revision involving multiple(two) related entities of same type(${type})`, async () => { + let entity = await getCreateEntityFunction(type)(); + const revision = await new Revision({authorId: editorJSON.id}).save(null, {method: 'insert'}); + const relsAttrib = ['relationshipSet.relationships']; + const entity2 = await (await getCreateEntityFunction(type)()).load(relsAttrib); + entity = await entity.load(relsAttrib); + const entity1Rel = entity.toJSON().relationshipSet.relationships.at(0); + const entity2Rel = entity2.toJSON().relationshipSet.relationships.at(0); + const oldRevisionId = entity.get('revisionId'); + // creating relationship type for testing + const relationshipTypeData = { + description: 'test descryption', + label: 'test label', + linkPhrase: 'test phrase', + reverseLinkPhrase: 'test reverse link phrase', + sourceEntityType: type, + targetEntityType: type + }; + const relationshipType = await new RelationshipType(relationshipTypeData).save(null, {method: 'insert'}); + const relationshipData = { + attributeSetId: null, + isAdded: true, + sourceBbid: entity2.get('bbid'), + targetBbid: entity.get('bbid'), + typeId: relationshipType.get('id') + }; + const relsSet = await orm.func.relationship.updateRelationshipSets( + orm, null, null, [relationshipData] + ); + const parents = await revision.related('parents').fetch(); + parents.attach([entity.get('revisionId'), entity2.get('revisionId')]); + // update entity revision + await entity.save({relationshipSetId: relsSet[entity.get('bbid')] + .get('id'), revisionId: revision.get('id')}, {method: 'update'}); + await entity2.save({relationshipSetId: relsSet[entity2.get('bbid')].get('id'), revisionId: revision.get('id')}, {method: 'update'}); + // revert just before this revision + await orm.bookshelf.transaction((trx) => revertRevision(oldRevisionId, entity.get('bbid'), editorJSON.id, type, orm, trx)); + const rEntity1 = await new orm[type]({bbid: entity.get('bbid')}).fetch({withRelated: [...relsAttrib]}); + const rEntity2 = await new orm[type]({bbid: entity2.get('bbid')}).fetch({withRelated: [...relsAttrib]}); + // relationshipSetId should not be null since both had set id before revision + expect(rEntity1.get('relationshipSetId')).to.be.not.null; + expect(rEntity2.get('relationshipSetId')).to.be.not.null; + const actualEntity1Rel = rEntity1.toJSON().relationshipSet.relationships; + const actualEntity2Rel = rEntity2.toJSON().relationshipSet.relationships; + expect(actualEntity1Rel.length).to.be.equal(1); + expect(actualEntity2Rel.length).to.be.equal(1); + expect(actualEntity1Rel.at(0).typeId).to.be.equal(entity1Rel.typeId); + expect(actualEntity2Rel.at(0).typeId).to.be.equal(entity2Rel.typeId); + expect(actualEntity1Rel.at(0).sourceBbid).to.be.equal(entity1Rel.sourceBbid); + expect(actualEntity2Rel.at(0).sourceBbid).to.be.equal(entity2Rel.sourceBbid); + expect(actualEntity1Rel.at(0).targetBbid).to.be.equal(entity1Rel.targetBbid); + expect(actualEntity2Rel.at(0).targetBbid).to.be.equal(entity2Rel.targetBbid); + }); + }); + + // try all possible pairs of entity types + const allPairs = []; + for (let i = 0; i < entityTypes.length; i++) { + for (let jj = 0; jj < entityTypes.length; jj++) { + if (i !== jj) { + allPairs.push([entityTypes[i], entityTypes[jj]]); + } + } + } + forEach(allPairs, ([sourceEntityType, targetEntityType]) => { + it(`should be able to revert revision involving multiple(two) related entities of + different type(${sourceEntityType} and ${targetEntityType})`, + async () => { + const relsAttrib = ['relationshipSet.relationships']; + const sourceEntity = await (await getCreateEntityFunction(sourceEntityType)()).load(relsAttrib); + const targetEntity = await (await getCreateEntityFunction(targetEntityType)()).load(relsAttrib); + const sourceRel = sourceEntity.toJSON().relationshipSet.relationships.at(0); + const targetRel = targetEntity.toJSON().relationshipSet.relationships.at(0); + const revision = await new Revision({authorId: editorJSON.id}).save(null, {method: 'insert'}); + const oldRevisionId = sourceEntity.get('revisionId'); + // creating relationship type for testing + const relationshipTypeData = { + description: 'test descryption', + label: 'test label', + linkPhrase: 'test phrase', + reverseLinkPhrase: 'test reverse link phrase', + sourceEntityType, + targetEntityType + }; + const relationshipType = await new RelationshipType(relationshipTypeData).save(null, {method: 'insert'}); + const relationshipData = { + attributeSetId: null, + isAdded: true, + sourceBbid: targetEntity.get('bbid'), + targetBbid: sourceEntity.get('bbid'), + typeId: relationshipType.get('id') + }; + const relsSet = await orm.func.relationship.updateRelationshipSets( + orm, null, null, [relationshipData] + ); + const parents = await revision.related('parents').fetch(); + parents.attach([sourceEntity.get('revisionId'), targetEntity.get('revisionId')]); + // update entity revision + await sourceEntity.save({relationshipSetId: relsSet[sourceEntity.get('bbid')] + .get('id'), revisionId: revision.get('id')}, {method: 'update'}); + await targetEntity.save({relationshipSetId: relsSet[targetEntity.get('bbid')].get('id'), + revisionId: revision.get('id')}, {method: 'update'}); + // revert just before this revision + await orm.bookshelf.transaction((trx) => revertRevision(oldRevisionId, sourceEntity.get('bbid'), + editorJSON.id, sourceEntityType, orm, trx)); + const rSourceEntity = await new sourceEntity.constructor({bbid: sourceEntity.get('bbid')}).fetch({withRelated: [...relsAttrib]}); + const rTargetEntity = await new targetEntity.constructor({bbid: targetEntity.get('bbid')}).fetch({withRelated: [...relsAttrib]}); + // relationshipSetId should not be null since both had set id before revision + expect(rSourceEntity.get('relationshipSetId')).to.be.not.null; + expect(rTargetEntity.get('relationshipSetId')).to.be.not.null; + const actualSourceRel = rSourceEntity.toJSON().relationshipSet.relationships; + const actualTargetRel = rTargetEntity.toJSON().relationshipSet.relationships; + expect(actualSourceRel.length).to.be.equal(1); + expect(actualTargetRel.length).to.be.equal(1); + expect(actualSourceRel.at(0).typeId).to.be.equal(sourceRel.typeId); + expect(actualTargetRel.at(0).typeId).to.be.equal(targetRel.typeId); + expect(actualSourceRel.at(0).sourceBbid).to.be.equal(sourceRel.sourceBbid); + expect(actualTargetRel.at(0).sourceBbid).to.be.equal(targetRel.sourceBbid); + expect(actualSourceRel.at(0).targetBbid).to.be.equal(sourceRel.targetBbid); + expect(actualTargetRel.at(0).targetBbid).to.be.equal(targetRel.targetBbid); + }); + }); +}); + diff --git a/test/test-helpers/create-entities.js b/test/test-helpers/create-entities.js index 0790b5c588..83b6aa8168 100644 --- a/test/test-helpers/create-entities.js +++ b/test/test-helpers/create-entities.js @@ -457,7 +457,10 @@ async function fetchOrCreatePublisherType(PublisherTypeModel, optionalPublisherA const PublisherTypeAttribs = { label: faker.commerce.productAdjective() }; - const publisherType = await new PublisherTypeModel({...PublisherTypeAttribs, ...optionalPublisherAttribs}).save(null, {method: 'insert'}); + let publisherType = await new PublisherTypeModel({...PublisherTypeAttribs}).fetch({require: false}); + if (!publisherType) { + publisherType = await new PublisherTypeModel({...PublisherTypeAttribs, ...optionalPublisherAttribs}).save(null, {method: 'insert'}); + } return publisherType; }