diff --git a/config/components.js b/config/components.js index 6ac80082e3aa0195a1630c0fc98646e6b6f211d7..5b76703715ec6fc54ec6b74972399aaf627dd818 100644 --- a/config/components.js +++ b/config/components.js @@ -9,4 +9,5 @@ module.exports = [ './src/models/__tests__/helpers/fake', './src/models/serviceCredential', './src/services/chatGPT', + './src/models/activityLog', ] diff --git a/dev/config/components.js b/dev/config/components.js index bc4acd40d2a74a9b72de525fd5e6f695e7a1f828..9420b5dc0542fee65757eb63c1cbe8b06664565c 100644 --- a/dev/config/components.js +++ b/dev/config/components.js @@ -3,4 +3,5 @@ module.exports = [ './src/models/teamMember', './src/models/user', './src/models/identity', + './src/models/activityLog', ] diff --git a/src/index.js b/src/index.js index d40fc5eb9de1cab8b866bd5501aead1580f94305..e1c5124bf93f38fc5e59a8518e90fa665601ba94 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,8 @@ const { const WaxToDocxConverter = require('./services/docx/docx.service') +const activityLog = require('./services/activityLog') + // Do not expose connectToFileStorage const fileStorage = { healthCheck, @@ -62,7 +64,7 @@ module.exports = { deleteFiles, // serviceHandshake, sendEmail, - + activityLog, BaseModel, File, logger, diff --git a/src/models/activityLog/activityLog.model.js b/src/models/activityLog/activityLog.model.js new file mode 100644 index 0000000000000000000000000000000000000000..fa3f417f2e4fd3b3287c6a4a5d8af07466727e74 --- /dev/null +++ b/src/models/activityLog/activityLog.model.js @@ -0,0 +1,71 @@ +const BaseModel = require('../base.model') + +const { + id, + stringNotEmpty, + stringNullable, + object, + objectNullable, +} = require('../_helpers/types') + +const affectedObject = { + type: 'object', + additionalProperties: false, + required: ['id', 'objectType'], + properties: { + id, + objectType: stringNotEmpty, + }, +} + +const affectedObjects = { + type: 'array', + default: [], + additionalProperties: false, + items: affectedObject, +} + +class ActivityLog extends BaseModel { + constructor(properties) { + super(properties) + this.type = 'activityLog' + } + + static get tableName() { + return 'activityLogs' + } + + static get schema() { + return { + type: 'object', + required: ['actorId', 'actionType'], + properties: { + actorId: id, + actionType: stringNotEmpty, + message: stringNullable, + valueBefore: objectNullable, + valueAfter: objectNullable, + affectedObjects, + additionalData: object, + }, + } + } + + static get relationMappings() { + /* eslint-disable-next-line global-require */ + const User = require('../user/user.model') + + return { + user: { + relation: BaseModel.BelongsToOneRelation, + modelClass: User, + join: { + from: 'activityLogs.actorId', + to: 'users.id', + }, + }, + } + } +} + +module.exports = ActivityLog diff --git a/src/models/activityLog/constants.js b/src/models/activityLog/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..9fedce182240fbf078439076092f2f5077b6240b --- /dev/null +++ b/src/models/activityLog/constants.js @@ -0,0 +1,11 @@ +module.exports = { + labels: { + ACTIVITY_LOG_SERVICE: '[ACTIVITY_LOG_SERVICE] -', + }, + actionTypes: { + CREATE: 'CREATE', + UPDATE: 'UPDATE', + PATCH: 'PATCH', + DELETE: 'DELETE', + }, +} diff --git a/src/models/activityLog/index.js b/src/models/activityLog/index.js new file mode 100644 index 0000000000000000000000000000000000000000..21d8d1155c40307fcace74b7420aedd506e21f3a --- /dev/null +++ b/src/models/activityLog/index.js @@ -0,0 +1,6 @@ +const model = require('./activityLog.model') + +module.exports = { + model, + modelName: 'ActivityLog', +} diff --git a/src/models/activityLog/migrations/1696505906-activityLog-init.js b/src/models/activityLog/migrations/1696505906-activityLog-init.js new file mode 100644 index 0000000000000000000000000000000000000000..dd53ee52aa027503eda3ecaf9841ab86b7584475 --- /dev/null +++ b/src/models/activityLog/migrations/1696505906-activityLog-init.js @@ -0,0 +1,30 @@ +const logger = require('@pubsweet/logger') + +exports.up = knex => { + try { + return knex.schema.createTable('activity_logs', table => { + table.uuid('id').primary() + table + .timestamp('created', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()) + table.timestamp('updated', { useTz: true }) + + table.uuid('actor_id').references('users.id').notNullable() + table.text('action_type').notNullable() + + table.jsonb('value_before') + table.jsonb('value_after') + table.jsonb('affected_objects').defaultTo([]) + table.text('message') + table.jsonb('additional_data') + + table.text('type').notNullable() + }) + } catch (e) { + logger.error('Acitivity log: Initial: Migration failed!') + throw new Error(e) + } +} + +exports.down = knex => knex.schema.dropTable('activity_logs') diff --git a/src/models/index.js b/src/models/index.js index a227b14a0de8e1ace7cb14907f30981a8b3aeaab..def3682158d3bbd4ab11d76daf851cff9a8cb540 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -10,6 +10,7 @@ const User = require('./user/user.model') const Identity = require('./identity/identity.model') const File = require('./file/file.model') +const ActivityLog = require('./activityLog/activityLog.model') const useTransaction = require('./useTransaction') const ServiceCredential = require('./serviceCredential/serviceCredential.model') @@ -27,6 +28,7 @@ module.exports = { Identity, File, + ActivityLog, useTransaction, ServiceCredential, diff --git a/src/services/__tests__/activityLog.service.test.js b/src/services/__tests__/activityLog.service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..032f373e2e78a8ecaa0a761d1fb09daed1b5788c --- /dev/null +++ b/src/services/__tests__/activityLog.service.test.js @@ -0,0 +1,33 @@ +const activityLog = require('../activityLog') + +const Fake = require('../../models/__tests__/helpers/fake/fake.model') +const { createUser } = require('../../models/__tests__/helpers/users') +const clearDb = require('../../models/__tests__/_clearDb') +const ActivityLog = require('../../models/activityLog/activityLog.model') +const { actionTypes } = require('../../models/activityLog/constants') + +describe('Activity Log Service', () => { + beforeEach(() => clearDb()) + + afterAll(() => { + const knex = Fake.knex() + knex.destroy() + }) + + it('creates a log entry', async () => { + const actor = await createUser() + const dummyUser = await createUser() + + const log = await activityLog({ + actorId: actor.id, + actionType: actionTypes.CREATE, + message: 'create a new user', + valueAfter: dummyUser, + affectedObjects: [{ id: dummyUser.id, objectType: 'user' }], + }) + + const { result: activities } = await ActivityLog.find({}) + expect(log).toBeDefined() + expect(activities).toHaveLength(1) + }) +}) diff --git a/src/services/activityLog.js b/src/services/activityLog.js new file mode 100644 index 0000000000000000000000000000000000000000..24e0faaeb1013ec57381fb7f13f347954ad20829 --- /dev/null +++ b/src/services/activityLog.js @@ -0,0 +1,29 @@ +const logger = require('@pubsweet/logger') + +const useTransaction = require('../models/useTransaction') +const ActivityLog = require('../models/activityLog/activityLog.model') + +const { + labels: { ACTIVITY_LOG_SERVICE }, +} = require('../models/activityLog/constants') + +const activityLog = async (data, options = {}) => { + try { + const { trx } = options + + return useTransaction( + async tr => { + return ActivityLog.insert(data, { trx: tr }) + }, + { + trx, + passedTrxOnly: true, + }, + ) + } catch (e) { + logger.error(`${ACTIVITY_LOG_SERVICE} activityLog: ${e.message}`) + throw new Error(e) + } +} + +module.exports = activityLog