feat: adds restore revisions to collections

This commit is contained in:
Dan Ribbens
2021-12-22 14:24:24 -05:00
parent a47977084f
commit 5eea398e43
16 changed files with 570 additions and 9 deletions

View File

@@ -106,6 +106,7 @@ export default function registerCollections(ctx: Payload): void {
findByID,
findRevisions,
findRevisionByID,
restoreRevision,
delete: deleteHandler,
} = ctx.requestHandlers.collections;
@@ -180,7 +181,8 @@ export default function registerCollections(ctx: Payload): void {
.get(findRevisions);
router.route(`/${slug}/revisions/:id`)
.get(findRevisionByID);
.get(findRevisionByID)
.post(restoreRevision);
}
router.route(`/${slug}`)

View File

@@ -1,8 +1,10 @@
/* eslint-disable no-underscore-dangle */
import httpStatus from 'http-status';
import { Payload } from '../../index';
import { PayloadRequest } from '../../express/types';
import { Collection } from '../config/types';
import { Collection, CollectionModel } from '../config/types';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import { Forbidden, NotFound } from '../../errors';
import { APIError, Forbidden, NotFound } from '../../errors';
import executeAccess from '../../auth/executeAccess';
import { Where } from '../../types';
import { hasWhereAccessResult } from '../../auth/types';
@@ -19,7 +21,7 @@ export type Arguments = {
depth?: number
}
async function findRevisionByID<T extends TypeWithRevision<T> = any>(args: Arguments): Promise<T> {
async function findRevisionByID<T extends TypeWithRevision<T> = any>(this: Payload, args: Arguments): Promise<T> {
const {
depth,
collection: {
@@ -36,7 +38,11 @@ async function findRevisionByID<T extends TypeWithRevision<T> = any>(args: Argum
showHiddenFields,
} = args;
const RevisionsModel = this.revisions[collectionConfig.slug];
if (!id) {
throw new APIError('Missing ID of revision.', httpStatus.BAD_REQUEST);
}
const RevisionsModel = (this.revisions[collectionConfig.slug]) as CollectionModel;
// /////////////////////////////////////
// Access

View File

@@ -8,6 +8,7 @@ import flattenWhereConstraints from '../../utilities/flattenWhereConstraints';
import { buildSortParam } from '../../mongoose/buildSortParam';
import { PaginatedDocs } from '../../mongoose/types';
import { TypeWithRevision } from '../../revisions/types';
import { Payload } from '../../index';
export type Arguments = {
collection: Collection
@@ -21,7 +22,7 @@ export type Arguments = {
showHiddenFields?: boolean
}
async function findRevisions<T extends TypeWithRevision<T> = any>(args: Arguments): Promise<PaginatedDocs<T>> {
async function findRevisions<T extends TypeWithRevision<T> = any>(this: Payload, args: Arguments): Promise<PaginatedDocs<T>> {
const {
where,
page,

View File

@@ -0,0 +1,48 @@
import { Document } from '../../../types';
import { PayloadRequest } from '../../../express/types';
import { TypeWithRevision } from '../../../revisions/types';
export type Options = {
collection: string
id: string
depth?: number
locale?: string
fallbackLocale?: string
user?: Document
overrideAccess?: boolean
showHiddenFields?: boolean
disableErrors?: boolean
req?: PayloadRequest
}
export default async function findRevisionByID<T extends TypeWithRevision<T> = any>(options: Options): Promise<T> {
const {
collection: collectionSlug,
depth,
id,
locale = this?.config?.localization?.defaultLocale,
fallbackLocale = null,
overrideAccess = true,
disableErrors = false,
showHiddenFields,
req,
} = options;
const collection = this.collections[collectionSlug];
return this.operations.collections.findRevisionByID({
depth,
id,
collection,
overrideAccess,
disableErrors,
showHiddenFields,
req: {
...req,
payloadAPI: 'local',
locale,
fallbackLocale,
payload: this,
},
});
}

View File

@@ -0,0 +1,53 @@
import { Document, Where } from '../../../types';
import { PaginatedDocs } from '../../../mongoose/types';
import { TypeWithRevision } from '../../../revisions/types';
export type Options = {
collection: string
depth?: number
page?: number
limit?: number
locale?: string
fallbackLocale?: string
user?: Document
overrideAccess?: boolean
showHiddenFields?: boolean
sort?: string
where?: Where
}
export default async function findRevisions<T extends TypeWithRevision<T> = any>(options: Options): Promise<PaginatedDocs<T>> {
const {
collection: collectionSlug,
depth,
page,
limit,
where,
locale = this?.config?.localization?.defaultLocale,
fallbackLocale = null,
user,
overrideAccess = true,
showHiddenFields,
sort,
} = options;
const collection = this.collections[collectionSlug];
return this.operations.collections.findRevisions({
where,
page,
limit,
depth,
collection,
sort,
overrideAccess,
showHiddenFields,
req: {
user,
payloadAPI: 'local',
locale,
fallbackLocale,
payload: this,
},
});
}

View File

@@ -4,6 +4,9 @@ import create from './create';
import update from './update';
import localDelete from './delete';
import auth from '../../../auth/operations/local';
import findRevisionByID from './findRevisionByID';
import findRevisions from './findRevisions';
import restoreRevision from './restoreRevision';
export default {
find,
@@ -12,4 +15,7 @@ export default {
update,
localDelete,
auth,
findRevisionByID,
findRevisions,
restoreRevision,
};

View File

@@ -0,0 +1,48 @@
import { Document } from '../../../types';
import { TypeWithRevision } from '../../../revisions/types';
export type Options = {
collection: string
id: string
data: Record<string, unknown>
depth?: number
locale?: string
fallbackLocale?: string
user?: Document
overrideAccess?: boolean
showHiddenFields?: boolean
}
export default async function restoreRevision<T extends TypeWithRevision<T> = any>(options: Options): Promise<T> {
const {
collection: collectionSlug,
depth,
locale = this?.config?.localization?.defaultLocale,
fallbackLocale = null,
data,
id,
user,
overrideAccess = true,
showHiddenFields,
} = options;
const collection = this.collections[collectionSlug];
const args = {
depth,
data,
collection,
overrideAccess,
id,
showHiddenFields,
req: {
user,
payloadAPI: 'local',
locale,
fallbackLocale,
payload: this,
},
};
return this.operations.collections.restoreRevision(args);
}

View File

@@ -0,0 +1,51 @@
/* eslint-disable no-underscore-dangle */
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { Collection } from '../config/types';
import { APIError } from '../../errors';
import { TypeWithRevision } from '../../revisions/types';
import { Payload } from '../../index';
export type Arguments = {
collection: Collection
id: string
req: PayloadRequest
disableErrors?: boolean
currentDepth?: number
overrideAccess?: boolean
showHiddenFields?: boolean
depth?: number
}
async function restoreRevision<T extends TypeWithRevision<T> = any>(this: Payload, args: Arguments): Promise<T> {
const {
collection,
id,
// overrideAccess = false,
} = args;
if (!id) {
throw new APIError('Missing ID of revision to restore.', httpStatus.BAD_REQUEST);
}
// /////////////////////////////////////
// Retrieve revision
// /////////////////////////////////////
const revision = await this.findRevisionByID({
...args,
collection: collection.config.slug,
});
const result = await this.update({
...args,
id: revision.parent,
collection: collection.config.slug,
data: revision.revision,
locale: args.req.locale,
});
return result;
}
export default restoreRevision;

View File

@@ -0,0 +1,29 @@
import { Response, NextFunction } from 'express';
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { Document } from '../../types';
import formatSuccessResponse from '../../express/responses/formatSuccess';
export type RestoreResult = {
message: string
doc: Document
};
export default async function restoreRevision(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<RestoreResult> | void> {
const options = {
req,
collection: req.collection,
id: req.params.id,
depth: req.query.depth,
};
try {
const doc = await this.operations.collections.restoreRevision(options);
return res.status(httpStatus.OK).json({
...formatSuccessResponse('Restored successfully.', 'message'),
doc,
});
} catch (error) {
return next(error);
}
}

View File

@@ -53,7 +53,8 @@ export default function initGlobals(ctx: Payload): void {
.get(ctx.requestHandlers.globals.findRevisions(global));
router.route(`/globals/${global.slug}/revisions/:id`)
.get(ctx.requestHandlers.globals.findRevisionByID(global));
.get(ctx.requestHandlers.globals.findRevisionByID(global))
.post(ctx.requestHandlers.globals.restoreRevision(global));
}
});

View File

@@ -0,0 +1,162 @@
import { Where } from '../../types';
import { PayloadRequest } from '../../express/types';
import executeAccess from '../../auth/executeAccess';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import { PaginatedDocs } from '../../mongoose/types';
import { hasWhereAccessResult } from '../../auth/types';
import flattenWhereConstraints from '../../utilities/flattenWhereConstraints';
import { buildSortParam } from '../../mongoose/buildSortParam';
import { TypeWithRevision } from '../../revisions/types';
import { SanitizedGlobalConfig } from '../config/types';
export type Arguments = {
globalConfig: SanitizedGlobalConfig
where?: Where
page?: number
limit?: number
sort?: string
depth?: number
req?: PayloadRequest
overrideAccess?: boolean
showHiddenFields?: boolean
}
// TODO: finish
async function restoreRevision<T extends TypeWithRevision<T> = any>(args: Arguments): Promise<PaginatedDocs<T>> {
const {
where,
page,
limit,
depth,
globalConfig,
req,
req: {
locale,
},
overrideAccess,
showHiddenFields,
} = args;
const RevisionsModel = this.revisions[globalConfig.slug];
// /////////////////////////////////////
// Access
// /////////////////////////////////////
const queryToBuild: { where?: Where} = {};
let useEstimatedCount = false;
if (where) {
let and = [];
if (Array.isArray(where.and)) and = where.and;
if (Array.isArray(where.AND)) and = where.AND;
queryToBuild.where = {
...where,
and: [
...and,
],
};
const constraints = flattenWhereConstraints(queryToBuild);
useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
}
if (!overrideAccess) {
const accessResults = await executeAccess({ req }, globalConfig.access.readRevisions);
if (hasWhereAccessResult(accessResults)) {
if (!where) {
queryToBuild.where = {
and: [
accessResults,
],
};
} else {
(queryToBuild.where.and as Where[]).push(accessResults);
}
}
}
const query = await RevisionsModel.buildQuery(queryToBuild, locale);
// /////////////////////////////////////
// Find
// /////////////////////////////////////
const [sortProperty, sortOrder] = buildSortParam(args.sort, true);
const optionsToExecute = {
page: page || 1,
limit: limit || 10,
sort: {
[sortProperty]: sortOrder,
},
lean: true,
leanWithId: true,
useEstimatedCount,
};
const paginatedDocs = await RevisionsModel.paginate(query, optionsToExecute);
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////
let result = {
...paginatedDocs,
docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({
...data,
revision: await this.performFieldOperations(
globalConfig,
{
depth,
data: data.revision,
req,
id: data.revision.id,
hook: 'afterRead',
operation: 'read',
overrideAccess,
flattenLocales: true,
showHiddenFields,
isRevision: true,
},
),
}))),
};
// /////////////////////////////////////
// afterRead - Collection
// /////////////////////////////////////
result = {
...result,
docs: await Promise.all(result.docs.map(async (doc) => {
const docRef = doc;
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook;
docRef.revision = await hook({ req, query, doc: doc.revision }) || doc.revision;
}, Promise.resolve());
return docRef;
})),
};
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
result = {
...result,
docs: result.docs.map((doc) => sanitizeInternalFields<T>(doc)),
};
return result;
}
export default restoreRevision;

View File

@@ -0,0 +1,25 @@
import { Response, NextFunction } from 'express';
import { PayloadRequest } from '../../express/types';
import { Document } from '../../types';
import { SanitizedGlobalConfig } from '../config/types';
export default function (globalConfig: SanitizedGlobalConfig) {
async function handler(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<Document> | void> {
const options = {
req,
globalConfig,
id: req.params.id,
depth: req.query.depth,
};
try {
const doc = await this.operations.globals.restoreRevision(options);
return res.json(doc);
} catch (error) {
return next(error);
}
}
const restoreRevisionHandler = handler.bind(this);
return restoreRevisionHandler;
}

View File

@@ -9,10 +9,11 @@ import {
EmailOptions,
InitOptions,
} from './config/types';
import { TypeWithRevision } from './revisions/types';
import { PaginatedDocs } from './mongoose/types';
import Logger from './utilities/logger';
import bindOperations from './init/bindOperations';
import bindOperations, { Operations } from './init/bindOperations';
import bindRequestHandlers, { RequestHandlers } from './init/bindRequestHandlers';
import loadConfig from './config/load';
import authenticate, { PayloadAuthenticate } from './express/middleware/authenticate';
@@ -45,6 +46,9 @@ import { Options as FindOptions } from './collections/operations/local/find';
import { Options as FindByIDOptions } from './collections/operations/local/findByID';
import { Options as UpdateOptions } from './collections/operations/local/update';
import { Options as DeleteOptions } from './collections/operations/local/delete';
import { Options as FindRevisionsOptions } from './collections/operations/local/findRevisions';
import { Options as FindRevisionByIDOptions } from './collections/operations/local/findRevisionByID';
import { Options as RestoreRevisionOptions } from './collections/operations/local/restoreRevision';
require('isomorphic-fetch');
@@ -94,7 +98,7 @@ export class Payload {
decrypt = decrypt;
operations: { [key: string]: any };
operations: Operations;
errorHandler: ErrorHandler;
@@ -269,6 +273,47 @@ export class Payload {
return deleteOperation<T>(options);
}
/**
* @description Find revisions with criteria
* @param options
* @returns revisions satisfying query
*/
findRevisions = async <T extends TypeWithRevision<T> = any>(options: FindRevisionsOptions): Promise<PaginatedDocs<T>> => {
let { findRevisions } = localOperations;
findRevisions = findRevisions.bind(this);
return findRevisions<T>(options);
}
/**
* @description Find revision by ID
* @param options
* @returns revision with specified ID
*/
findRevisionByID = async <T extends TypeWithRevision<T> = any>(options: FindRevisionByIDOptions): Promise<T> => {
let { findRevisionByID } = localOperations;
findRevisionByID = findRevisionByID.bind(this);
return findRevisionByID(options);
}
/**
* @description Restore revision by ID
* @param options
* @returns revision with specified ID
*/
restoreRevision = async <T extends TypeWithRevision<T> = any>(options: RestoreRevisionOptions): Promise<T> => {
let { restoreRevision } = localOperations;
restoreRevision = restoreRevision.bind(this);
return restoreRevision(options);
}
// TODO: globals
// findRevisionGlobal
// findRevisionByIDGlobal
// restoreRevisionGlobal
// TODO:
// graphql operations & request handlers, where
// tests
login = async (options): Promise<any> => {
let { login } = localOperations.auth;
login = login.bind(this);

View File

@@ -16,18 +16,58 @@ import find from '../collections/operations/find';
import findByID from '../collections/operations/findByID';
import findRevisions from '../collections/operations/findRevisions';
import findRevisionByID from '../collections/operations/findRevisionByID';
import restoreRevision from '../collections/operations/restoreRevision';
import update from '../collections/operations/update';
import deleteHandler from '../collections/operations/delete';
import findOne from '../globals/operations/findOne';
import findGlobalRevisions from '../globals/operations/findRevisions';
import findGlobalRevisionByID from '../globals/operations/findRevisionByID';
import restoreGlobalRevision from '../globals/operations/restoreRevision';
import globalUpdate from '../globals/operations/update';
import preferenceUpdate from '../preferences/operations/update';
import preferenceFindOne from '../preferences/operations/findOne';
import preferenceDelete from '../preferences/operations/delete';
export type Operations = {
collections: {
create: typeof create
find: typeof find
findByID: typeof findByID
findRevisions: typeof findRevisions
findRevisionByID: typeof findRevisionByID
restoreRevision: typeof restoreRevision
update: typeof update
delete: typeof deleteHandler
auth: {
access: typeof access
forgotPassword: typeof forgotPassword
init: typeof init
login: typeof login
logout: typeof logout
me: typeof me
refresh: typeof refresh
registerFirstUser: typeof registerFirstUser
resetPassword: typeof resetPassword
verifyEmail: typeof verifyEmail
unlock: typeof unlock
}
}
globals: {
findOne: typeof findOne
findRevisions: typeof findGlobalRevisions
findRevisionByID: typeof findGlobalRevisionByID
restoreRevision: typeof restoreGlobalRevision
update: typeof globalUpdate
}
preferences: {
update: typeof preferenceUpdate
findOne: typeof preferenceFindOne
delete: typeof preferenceDelete
}
}
function bindOperations(ctx: Payload): void {
ctx.operations = {
collections: {
@@ -36,6 +76,7 @@ function bindOperations(ctx: Payload): void {
findByID: findByID.bind(ctx),
findRevisions: findRevisions.bind(ctx),
findRevisionByID: findRevisionByID.bind(ctx),
restoreRevision: restoreRevision.bind(ctx),
update: update.bind(ctx),
delete: deleteHandler.bind(ctx),
auth: {
@@ -56,6 +97,7 @@ function bindOperations(ctx: Payload): void {
findOne: findOne.bind(ctx),
findRevisions: findGlobalRevisions.bind(ctx),
findRevisionByID: findGlobalRevisionByID.bind(ctx),
restoreRevision: restoreGlobalRevision.bind(ctx),
update: globalUpdate.bind(ctx),
},
preferences: {

View File

@@ -15,12 +15,14 @@ import find from '../collections/requestHandlers/find';
import findByID from '../collections/requestHandlers/findByID';
import findRevisions from '../collections/requestHandlers/findRevisions';
import findRevisionByID from '../collections/requestHandlers/findRevisionByID';
import restoreRevision from '../collections/requestHandlers/restoreRevision';
import update from '../collections/requestHandlers/update';
import deleteHandler from '../collections/requestHandlers/delete';
import findOne from '../globals/requestHandlers/findOne';
import findGlobalRevisions from '../globals/requestHandlers/findRevisions';
import findGlobalRevisionByID from '../globals/requestHandlers/findRevisionByID';
import restoreGlobalRevision from '../globals/requestHandlers/restoreRevision';
import globalUpdate from '../globals/requestHandlers/update';
import { Payload } from '../index';
import preferenceUpdate from '../preferences/requestHandlers/update';
@@ -34,6 +36,7 @@ export type RequestHandlers = {
findByID: typeof findByID,
findRevisions: typeof findRevisions,
findRevisionByID: typeof findRevisionByID,
restoreRevision: typeof restoreRevision,
update: typeof update,
delete: typeof deleteHandler,
auth: {
@@ -55,6 +58,7 @@ export type RequestHandlers = {
update: typeof globalUpdate,
findRevisions: typeof findGlobalRevisions
findRevisionByID: typeof findGlobalRevisionByID
restoreRevision: typeof restoreGlobalRevision
},
preferences: {
update: typeof preferenceUpdate,
@@ -71,6 +75,7 @@ function bindRequestHandlers(ctx: Payload): void {
findByID: findByID.bind(ctx),
findRevisions: findRevisions.bind(ctx),
findRevisionByID: findRevisionByID.bind(ctx),
restoreRevision: restoreRevision.bind(ctx),
update: update.bind(ctx),
delete: deleteHandler.bind(ctx),
auth: {
@@ -92,6 +97,7 @@ function bindRequestHandlers(ctx: Payload): void {
update: globalUpdate.bind(ctx),
findRevisions: findGlobalRevisions.bind(ctx),
findRevisionByID: findGlobalRevisionByID.bind(ctx),
restoreRevision: restoreGlobalRevision.bind(ctx),
},
preferences: {
update: preferenceUpdate.bind(ctx),

View File

@@ -116,4 +116,40 @@ describe('Revisions - REST', () => {
expect(revisions.docs[0].revision.title.es).toStrictEqual(spanishTitle);
});
});
describe('Restore', () => {
it('should allow a revision to be restored', async () => {
const title2 = 'Here is an updated post title in EN';
const updatedPost = await fetch(`${url}/api/localized-posts/${postID}`, {
body: JSON.stringify({
title: title2,
}),
headers,
method: 'put',
}).then((res) => res.json());
expect(updatedPost.doc.title).toBe(title2);
const revisions = await fetch(`${url}/api/localized-posts/revisions`, {
headers,
}).then((res) => res.json());
revisionID = revisions.docs[0].id;
const restore = await fetch(`${url}/api/localized-posts/revisions/${revisionID}`, {
headers,
method: 'post',
}).then((res) => res.json());
expect(restore.message).toBeDefined();
expect(restore.doc.title).toBeDefined();
const restoredPost = await fetch(`${url}/api/localized-posts/${postID}`, {
headers,
}).then((res) => res.json());
expect(restoredPost.title).toBe(restore.doc.title);
});
});
});