From d9ffd7f8de110ebd03fa95c9e2c0faf7a434a033 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 9 Jul 2020 12:02:53 -0400 Subject: [PATCH] Track revisions and activity on the item level --- src/routes/collections.ts | 14 +++- src/routes/items.ts | 41 ++++------- src/services/activity.ts | 3 +- src/services/collection-presets.ts | 2 + src/services/collections.ts | 33 +++++---- src/services/items.ts | 112 ++++++++++++++++++++++++----- src/services/payload.ts | 15 ++-- src/services/revisions.ts | 4 ++ src/services/users.ts | 4 +- src/types/accountability.ts | 7 ++ src/types/files.ts | 1 + 11 files changed, 167 insertions(+), 69 deletions(-) create mode 100644 src/types/accountability.ts diff --git a/src/routes/collections.ts b/src/routes/collections.ts index 41dabb66df..0185e0d2ca 100644 --- a/src/routes/collections.ts +++ b/src/routes/collections.ts @@ -29,7 +29,12 @@ router.post( const { error } = collectionSchema.validate(req.body); if (error) throw new InvalidPayloadException(error.message); - const createdCollection = await CollectionsService.create(req.body); + const createdCollection = await CollectionsService.create(req.body, { + ip: req.ip, + userAgent: req.get('user-agent'), + user: req.user, + }); + res.json({ data: createdCollection }); }) ); @@ -65,7 +70,12 @@ router.delete( throw new CollectionNotFoundException(req.params.collection); } - await CollectionsService.deleteCollection(req.params.collection); + await CollectionsService.deleteCollection(req.params.collection, { + ip: req.ip, + userAgent: req.get('user-agent'), + user: req.user, + }); + res.end(); }) ); diff --git a/src/routes/items.ts b/src/routes/items.ts index fd03af3694..c9716e1f90 100644 --- a/src/routes/items.ts +++ b/src/routes/items.ts @@ -6,7 +6,6 @@ import validateCollection from '../middleware/validate-collection'; import validateSingleton from '../middleware/validate-singleton'; import validateQuery from '../middleware/validate-query'; import * as MetaService from '../services/meta'; -import * as ActivityService from '../services/activity'; const router = express.Router(); @@ -17,19 +16,14 @@ router.post( sanitizeQuery, validateQuery, asyncHandler(async (req, res) => { - const primaryKey = await ItemsService.createItem(req.collection, req.body); - const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery); - - ActivityService.createActivity({ - action: ActivityService.Action.CREATE, - collection: req.collection, - /** @TODO don't forget to use real primary key here */ - item: item.id, + const primaryKey = await ItemsService.createItem(req.collection, req.body, { ip: req.ip, - user_agent: req.get('user-agent'), - action_by: req.user, + userAgent: req.get('user-agent'), + user: req.user, }); + const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery); + res.json({ data: item }); }) ); @@ -76,18 +70,14 @@ router.patch( sanitizeQuery, validateQuery, asyncHandler(async (req, res) => { - const primaryKey = await ItemsService.updateItem(req.collection, req.params.pk, req.body); - const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery); - - ActivityService.createActivity({ - action: ActivityService.Action.UPDATE, - collection: req.collection, - item: item.id, + const primaryKey = await ItemsService.updateItem(req.collection, req.params.pk, req.body, { ip: req.ip, - user_agent: req.get('user-agent'), - action_by: req.user, + userAgent: req.get('user-agent'), + user: req.user, }); + const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery); + return res.json({ data: item }); }) ); @@ -96,15 +86,10 @@ router.delete( '/:collection/:pk', validateCollection, asyncHandler(async (req, res) => { - await ItemsService.deleteItem(req.collection, req.params.pk); - - ActivityService.createActivity({ - action: ActivityService.Action.DELETE, - collection: req.collection, - item: req.params.pk, + await ItemsService.deleteItem(req.collection, req.params.pk, { ip: req.ip, - user_agent: req.get('user-agent'), - action_by: req.user, + userAgent: req.get('user-agent'), + user: req.user, }); return res.status(200).end(); diff --git a/src/services/activity.ts b/src/services/activity.ts index aded494c81..552f006b8f 100644 --- a/src/services/activity.ts +++ b/src/services/activity.ts @@ -12,8 +12,7 @@ export enum Action { } export const createActivity = async (data: Record, query?: Query) => { - const primaryKey = await ItemsService.createItem('directus_activity', data); - return await ItemsService.readItem('directus_activity', primaryKey, query); + return await ItemsService.createItem('directus_activity', data); }; export const readActivities = async (query?: Query) => { diff --git a/src/services/collection-presets.ts b/src/services/collection-presets.ts index 81ea03b4d2..a3ade0dc93 100644 --- a/src/services/collection-presets.ts +++ b/src/services/collection-presets.ts @@ -1,6 +1,8 @@ import { Query } from '../types/query'; import * as ItemsService from './items'; +/** @todo check if we want to save activity for collection presets */ + export const createCollectionPreset = async (data: Record, query: Query) => { const primaryKey = await ItemsService.createItem('directus_collection_presets', data); return await ItemsService.readItem('directus_collection_presets', primaryKey, query); diff --git a/src/services/collections.ts b/src/services/collections.ts index 009080ebe2..3bba16f8bb 100644 --- a/src/services/collections.ts +++ b/src/services/collections.ts @@ -3,9 +3,10 @@ import * as ItemsService from '../services/items'; import { Collection } from '../types/collection'; import { Query } from '../types/query'; import { ColumnBuilder } from 'knex'; +import { Accountability } from '../types/accountability'; /** @Todo properly type this */ -export const create = async (payload: any) => { +export const create = async (payload: any, accountability: Accountability) => { await database.schema.createTable(payload.collection, (table) => { if (payload.note) { table.comment(payload.note); @@ -13,6 +14,8 @@ export const create = async (payload: any) => { /** @todo move this into fields service */ + /** @todo adhere to new nested structure system vs database */ + payload.fields?.forEach((field: any) => { let column: ColumnBuilder; @@ -36,16 +39,18 @@ export const create = async (payload: any) => { }); }); - const primaryKey = await ItemsService.createItem('directus_collections', { - collection: payload.collection, - hidden: payload.hidden || false, - single: payload.single || false, - icon: payload.icon || null, - note: payload.note || null, - translation: payload.translation || null, - }); - - const collection = await ItemsService.createItem('directus_collections', primaryKey); + const primaryKey = await ItemsService.createItem( + 'directus_collections', + { + collection: payload.collection, + hidden: payload.hidden || false, + single: payload.single || false, + icon: payload.icon || null, + note: payload.note || null, + translation: payload.translation || null, + }, + accountability + ); /** * @TODO make this flexible and based on payload @@ -62,7 +67,7 @@ export const create = async (payload: any) => { })) ); - return collection; + return await ItemsService.readItem('directus_collections', primaryKey); }; export const readAll = async (query?: Query) => { @@ -105,10 +110,10 @@ export const readOne = async (collection: string, query?: Query) => { }; }; -export const deleteCollection = async (collection: string) => { +export const deleteCollection = async (collection: string, accountability: Accountability) => { await Promise.all([ database.schema.dropTable(collection), - ItemsService.deleteItem('directus_collections', collection), + ItemsService.deleteItem('directus_collections', collection, accountability), database.delete().from('directus_fields').where({ collection }), database .delete() diff --git a/src/services/items.ts b/src/services/items.ts index bcce640fa7..420452be48 100644 --- a/src/services/items.ts +++ b/src/services/items.ts @@ -3,9 +3,46 @@ import { Query } from '../types/query'; import runAST from '../database/run-ast'; import getAST from '../utils/get-ast'; import * as PayloadService from './payload'; -import { pick } from 'lodash'; +import { Accountability } from '../types/accountability'; -export const createItem = async (collection: string, data: Record) => { +import * as ActivityService from './activity'; +import * as RevisionsService from './revisions'; + +import { pick } from 'lodash'; +import logger from '../logger'; + +async function saveActivityAndRevision( + action: ActivityService.Action, + collection: string, + item: string, + payload: Record, + accountability: Accountability +) { + const activityID = await ActivityService.createActivity({ + action, + collection, + item, + ip: accountability.ip, + user_agent: accountability.userAgent, + action_by: accountability.user, + }); + + await RevisionsService.createRevision({ + activity: activityID, + collection, + item, + delta: payload, + /** @todo make this configurable */ + data: await readItem(collection, item, { fields: ['*'] }), + parent: accountability.parent, + }); +} + +export const createItem = async ( + collection: string, + data: Record, + accountability?: Accountability +) => { let payload = await PayloadService.processValues('create', collection, data); payload = await PayloadService.processM2O(collection, payload); @@ -14,13 +51,14 @@ export const createItem = async (collection: string, data: Record) // Only insert the values that actually save to an existing column. This ensures we ignore aliases etc const columns = await schemaInspector.columns(collection); + + const payloadWithoutAlias = pick( + payload, + columns.map(({ column }) => column) + ); + const primaryKeys = await database(collection) - .insert( - pick( - payload, - columns.map(({ column }) => column) - ) - ) + .insert(payloadWithoutAlias) .returning(primaryKeyField); // This allows the o2m values to be populated correctly @@ -28,6 +66,17 @@ export const createItem = async (collection: string, data: Record) await PayloadService.processO2M(collection, payload); + if (accountability) { + // Don't await this. It can run async in the background + saveActivityAndRevision( + ActivityService.Action.CREATE, + collection, + primaryKeys[0], + payloadWithoutAlias, + accountability + ).catch((err) => logger.error(err)); + } + return primaryKeys[0]; }; @@ -67,9 +116,10 @@ export const readItem = async ( export const updateItem = async ( collection: string, pk: number | string, - data: Record + data: Record, + accountability?: Accountability ) => { - let payload = await PayloadService.processValues('create', collection, data); + let payload = await PayloadService.processValues('update', collection, data); payload = await PayloadService.processM2O(collection, payload); @@ -77,21 +127,49 @@ export const updateItem = async ( // Only insert the values that actually save to an existing column. This ensures we ignore aliases etc const columns = await schemaInspector.columns(collection); + + const payloadWithoutAlias = pick( + payload, + columns.map(({ column }) => column) + ); + const primaryKeys = await database(collection) - .update( - pick( - payload, - columns.map(({ column }) => column) - ) - ) + .update(payloadWithoutAlias) .where({ [primaryKeyField]: pk }) .returning(primaryKeyField); + if (accountability) { + // Don't await this. It can run async in the background + saveActivityAndRevision( + ActivityService.Action.UPDATE, + collection, + primaryKeys[0], + payloadWithoutAlias, + accountability + ).catch((err) => logger.error(err)); + } + return primaryKeys[0]; }; -export const deleteItem = async (collection: string, pk: number | string) => { +export const deleteItem = async ( + collection: string, + pk: number | string, + accountability?: Accountability +) => { const primaryKeyField = await schemaInspector.primary(collection); + + if (accountability) { + // Don't await this. It can run async in the background + saveActivityAndRevision( + ActivityService.Action.DELETE, + collection, + String(pk), + {}, + accountability + ).catch((err) => logger.error(err)); + } + return await database(collection) .delete() .where({ [primaryKeyField]: pk }); diff --git a/src/services/payload.ts b/src/services/payload.ts index addf3d3743..cbd6336d9c 100644 --- a/src/services/payload.ts +++ b/src/services/payload.ts @@ -7,7 +7,7 @@ import { System } from '../types/field'; import argon2 from 'argon2'; import { v4 as uuidv4 } from 'uuid'; import database from '../database'; -import { clone } from 'lodash'; +import { clone, isObject } from 'lodash'; import { File } from '../types/files'; import { Relation } from '../types/relation'; import * as ItemsService from './items'; @@ -24,6 +24,10 @@ type Transformers = { /** * @todo allow this to be extended + * + * @todo allow these extended special types to have "field dependencies"? + * f.e. the file-links transformer needs the id and filename_download to be fetched from the DB + * in order to work */ const transformers: Transformers = { async hash(operation, value) { @@ -42,10 +46,13 @@ const transformers: Transformers = { return value; }, - async 'file-info'(operation, value, payload: File) { - if (operation === 'read' && payload) { + async 'file-links'(operation, value, payload: File) { + if (operation === 'read' && payload && payload.storage && payload.filename_disk) { + const publicKey = `STORAGE_${payload.storage.toUpperCase()}_PUBLIC_URL`; + return { asset_url: new URL(`/assets/${payload.id}`, process.env.PUBLIC_URL), + public_url: new URL(payload.filename_disk, process.env[publicKey]), }; } @@ -131,7 +138,7 @@ export const processM2O = async (collection: string, payload: Record { return ( payloadClone.hasOwnProperty(relation.field_many) && - typeof payloadClone[relation.field_many] === 'object' + isObject(payloadClone[relation.field_many]) ); }); diff --git a/src/services/revisions.ts b/src/services/revisions.ts index 0062a1c312..29041cee0e 100644 --- a/src/services/revisions.ts +++ b/src/services/revisions.ts @@ -1,6 +1,10 @@ import { Query } from '../types/query'; import * as ItemsService from './items'; +export const createRevision = async (data: Record) => { + return await ItemsService.createItem('directus_revisions', data); +}; + export const readRevisions = async (query: Query) => { return await ItemsService.readItems('directus_revisions', query); }; diff --git a/src/services/users.ts b/src/services/users.ts index e7ff44d4a9..2c01b39711 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -8,7 +8,7 @@ import { InvalidPayloadException } from '../exceptions'; export const createUser = async (data: Record, query?: Query) => { const primaryKey = await ItemsService.createItem('directus_users', data); - return await ItemsService.readItem('directus_user', primaryKey, query); + return await ItemsService.readItem('directus_users', primaryKey, query); }; export const readUsers = async (query?: Query) => { @@ -27,7 +27,7 @@ export const updateUser = async (pk: string | number, data: Record, * Maybe make this an option? */ const primaryKey = await ItemsService.updateItem('directus_users', pk, data); - return await ItemsService.readItem('directus_user', primaryKey, query); + return await ItemsService.readItem('directus_users', primaryKey, query); }; export const deleteUser = async (pk: string | number) => { diff --git a/src/types/accountability.ts b/src/types/accountability.ts new file mode 100644 index 0000000000..8bdebbea82 --- /dev/null +++ b/src/types/accountability.ts @@ -0,0 +1,7 @@ +export type Accountability = { + ip?: string; + userAgent?: string; + user?: string; + + parent?: number; +}; diff --git a/src/types/files.ts b/src/types/files.ts index e3dec251a9..b64d831191 100644 --- a/src/types/files.ts +++ b/src/types/files.ts @@ -2,4 +2,5 @@ export type File = { id: string; // uuid filename_disk: string; + storage: string; };