Track revisions and activity on the item level

This commit is contained in:
rijkvanzanten
2020-07-09 12:02:53 -04:00
parent 0b096bea45
commit d9ffd7f8de
11 changed files with 167 additions and 69 deletions

View File

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

View File

@@ -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();

View File

@@ -12,8 +12,7 @@ export enum Action {
}
export const createActivity = async (data: Record<string, any>, 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) => {

View File

@@ -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<string, any>, query: Query) => {
const primaryKey = await ItemsService.createItem('directus_collection_presets', data);
return await ItemsService.readItem('directus_collection_presets', primaryKey, query);

View File

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

View File

@@ -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<string, any>) => {
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<string, any>,
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<string, any>,
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<string, any>)
// 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<string, any>)
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 <T = any>(
export const updateItem = async (
collection: string,
pk: number | string,
data: Record<string, any>
data: Record<string, any>,
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 });

View File

@@ -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<string, any
const relationsToProcess = relations.filter((relation) => {
return (
payloadClone.hasOwnProperty(relation.field_many) &&
typeof payloadClone[relation.field_many] === 'object'
isObject(payloadClone[relation.field_many])
);
});

View File

@@ -1,6 +1,10 @@
import { Query } from '../types/query';
import * as ItemsService from './items';
export const createRevision = async (data: Record<string, any>) => {
return await ItemsService.createItem('directus_revisions', data);
};
export const readRevisions = async (query: Query) => {
return await ItemsService.readItems('directus_revisions', query);
};

View File

@@ -8,7 +8,7 @@ import { InvalidPayloadException } from '../exceptions';
export const createUser = async (data: Record<string, any>, 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<string, any>,
* 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) => {

View File

@@ -0,0 +1,7 @@
export type Accountability = {
ip?: string;
userAgent?: string;
user?: string;
parent?: number;
};

View File

@@ -2,4 +2,5 @@
export type File = {
id: string; // uuid
filename_disk: string;
storage: string;
};