mirror of
https://github.com/directus/directus.git
synced 2026-01-29 16:28:02 -05:00
Track revisions and activity on the item level
This commit is contained in:
@@ -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();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
7
src/types/accountability.ts
Normal file
7
src/types/accountability.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Accountability = {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
user?: string;
|
||||
|
||||
parent?: number;
|
||||
};
|
||||
@@ -2,4 +2,5 @@
|
||||
export type File = {
|
||||
id: string; // uuid
|
||||
filename_disk: string;
|
||||
storage: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user