From 99a0109790a8f557df7bd959b91e30f124065e9a Mon Sep 17 00:00:00 2001 From: Artak Date: Sat, 17 Aug 2024 15:35:54 +0400 Subject: [PATCH] Added tests, env, helper methods --- .env | 23 ++ README.md | 338 ++++++++++++++++++++- configure.ts | 19 +- index.ts | 6 + package.json | 23 +- src/helpers.ts | 84 ++++++ src/logger.ts | 87 +++--- src/models/activity_log.ts | 24 +- src/types.ts | 33 ++- stubs/migrations/create_db.stub | 5 +- test-helpers/index.ts | 501 ++++++++++++++++++++++++++++++++ tests/activity_log.spec.ts | 364 +++++++++++++++++++++++ tests/example.spec.ts | 7 - 13 files changed, 1438 insertions(+), 76 deletions(-) create mode 100644 .env create mode 100644 src/helpers.ts create mode 100644 test-helpers/index.ts create mode 100644 tests/activity_log.spec.ts delete mode 100644 tests/example.spec.ts diff --git a/.env b/.env new file mode 100644 index 0000000..d0b695c --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +DB=pg +#DB=sqlite +#DB=mysql +#DB=mssql +#UUID_SUPPORT=true + +PG_HOST=localhost +PG_DATABASE=adonisjs +PG_USER=artak +PG_PASSWORD=secret +PG_PORT=5432 + +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_DATABASE=adonisjs +MYSQL_USER=root +MYSQL_PASSWORD=password + +MSSQL_HOST=localhost +MSSQL_PORT=1433 +MSSQL_DATABASE=master +MSSQL_USER=sa +MSSQL_PASSWORD=MyStrong@Passw0rd diff --git a/README.md b/README.md index 7748e7d..d558240 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,341 @@ -# AdonisJS activity log +# Log activity inside your AdonisJS app +Checkout other AdonisJS packages +- [AdonisJS permissions](https://github.com/holoyan/adonisjs-permissions) + + +## Beta version + +## Table of Contents + +
Click to expand

+ +- [Introduction](#introduction) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) + - [Writing logs](#writing-logs) + - [Automatic logging](#automatic-logging) + - [Specifying batch id (Group id)](#specifying-batch-id-group-id) + - [Full log example](#full-log-example) + - [Retrieving logs](#retrieving-logs) + - [Changes vs Diff](#changes-vs-diff) +- [Transactions](#transactions) +- [Test](#test) +- [License](#license) +

+ +## Introduction + +The `@holoyan/adonis-activitylog` package provides easy to use functions to log the activities of the models(not only) of your app. The Package stores all activity in the `activity_logs` table. + +Here's a demo of how you can use it: + +```typescript +import { activity } from '@holoyan/adonis-activitylog' + +const a = await activity().by(user).log('Look, I logged something') + +``` + +## Installation + + npm i @holoyan/adonis-activitylog + +Next, publish config file + + node ace configure @holoyan/adonis-activitylog + +this will create migration file in the `database/migrations` directory + +Next run migration + + node ace migration:run + + +## Configuration + +All models that will interact with `logs` MUST use the `@MorphMap('AliasForClass')` decorator and implement `LogModelInterface` interface decorator. + +Example. + +```typescript + +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { MorphMap } from '@holoyan/adonis-activitylog' +import { LogModelInterface } from '@holoyan/adonis-activitylog' + +@MorphMap('users') +export default class User extends BaseModel implements LogModelInterface { + getModelId(): string { + return String(this.id) + } + // other code goes here +} + +@MorphMap('posts') +export default class Post extends BaseModel implements LogModelInterface { + getModelId(): string { + return String(this.id) + } + // model code goes here +} + +``` +## Support + +### App version +Only AdonisJs v6+ app + +### Database Support + +Currently supported databases: `postgres`, `mysql`, `mssql`, `sqlite` + +### UUID support +By default package supports `UUID` models, just don't forget to implement `getModelId` method + +## Usage + +### Writing logs + +The simplest way to log something is to call the `log` method + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity().log('Look, I logged something') + +``` + +If you need to specify the user call `by` method + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity().by(user).log('Log by user') +// or you can manually pass user alias and id +// const myLog = await activity().by('users', 1).log('Log by user') + +``` +> Important! User model MUST use @MorphMap decorator. Check [configuration](#configuration) + +To specify event name call `making` method + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity() + .by(user) + .making('update') // you can specify anything you want + .log('Post successfully updated') + +``` + +To specify entity use `on` method + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const post = await Post.find(id) + +const myLog = await activity() + .by(user) + .making('update') + .on(post) // you can pass post model instance or model alias and id + .log('Post successfully updated') + +``` +> Important! Post model MUST use @MorphMap decorator. Check [configuration](#configuration) + +To specify attributes you need to call `havingCurrent` method + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity().by(user).making('update').on(post).havingCurrent({ + title: 'new title', +}).log('Post successfully updated') + +``` + +And of course you can save previous as well + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity().by(user).making('update').on(post).havingCurrent({ + title: 'new title', +}).previousState({ + title: 'old title', +}).log('Post successfully updated') + +``` + +### Automatic logging + +You can create `toLog` method inside the model in that case it will be automatically called + +```typescript + +import { MorphMap } from '@holoyan/adonis-activitylog' + + +@MorphMap('posts') +export default class Post extends BaseModel { + // attributes + + toLog(){ + return { + title: this.title, + body: this.body, + // and so on + } + } +} + +// PostsController.ts + +// toLogs will be called and it's value will be stored as currentState +const myLog = await activity() + .by(user) + .making('update') + .on(post) // behind the scenes it will call post.toLog() and store it as currentState + .log('Post successfully updated') + +``` + +### Specifying batch id (Group id) + +Sometimes you may want to group logs, or you need a way to log multiple entries `under current request`. To do so you can use `groupedBy` method and specify batch id + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const batchId = uuid4(); + +const myLog1 = await activity().groupedBy(batchId).by(user).log('Log 1') +const myLog2 = await activity().groupedBy(batchId).by(user).log('Log 2') +// and so on + +``` + +### Full log example + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const batchId = uuid4(); + +const myLog = await activity() + .named('new-car') + .by(user) + .making('update') + .on(product) + .groupedBy(batchId) + .havingCurrent({ + brand: 'Mercedes', + color: 'black' + }) + .previousState({ + brand: 'BMW', + color: 'black' + }) + .log('New car added') +// and so on + +console.log(myLog) + +``` + +### Retrieving logs + +To retrieve log simply use `ActivityLog` model and make `lucid` [queries](https://lucid.adonisjs.com/docs/model-query-builder) + +```typescript + +import { ActivityLog } from '@holoyan/adonis-activitylog' + +const log = await ActivityLog.query().where('model_type', 'users').where('model_id', user.id).first() +console.log(log) + + +``` + +### Changes vs Diff + +The package stores `previous` and `current` states of the model. You can retrieve changes by calling `changes` and `diff` methods, let's see an example + +```typescript + +import { activity } from '@holoyan/adonis-activitylog' + +const myLog = await activity().by(user).making('update').on('post').havingCurrent({ + title: 'new title', + description: 'new description', +}).previousState({ + title: 'old title', + description: 'old description', +}).log('Post successfully updated') + +console.log(myLog.changes()) +/** + { + title: { + oldValue: 'old title', + newValue: 'new title' + }, + description: { + oldValue: 'old description', + newValue: 'new description' + } + } + */ +console.log(myLog.diff()) +// { title: 'new title', description: 'new description' } + +``` + +You can do same on the model instance + +```typescript + +import { ActivityLog } from '@holoyan/adonis-activitylog' + +const log = await ActivityLog.find(id) +console.log(log.toString()) +console.log(log.diff()) + +``` + +### Transactions + +In case you want to use `activity` inside the transaction then you can pass `options` directly to query method. + +```typescript + +const trx = await db.transaction() + +const a = await activity().queryOptions({ client: trx }).log('bla bla') +// you other code + +await trx.commit() + +``` + +## Test + + npm run test + +## License -### License MIT diff --git a/configure.ts b/configure.ts index 4982733..05b45e1 100644 --- a/configure.ts +++ b/configure.ts @@ -13,5 +13,22 @@ */ import ConfigureCommand from '@adonisjs/core/commands/configure' +import { stubsRoot } from './stubs/main.js' -export async function configure(_command: ConfigureCommand) {} +export async function configure(_command: ConfigureCommand) { + const codemods = await _command.createCodemods() + + /** + * Publish migration file + */ + await codemods.makeUsingStub(stubsRoot, 'migrations/create_db.stub', { + prefix: new Date().getTime(), + }) + + /** + * Register provider + */ + await codemods.updateRcFile((rcFile) => { + rcFile.addProvider('@holoyan/adonis-activitylog/activity_log_provider') + }) +} diff --git a/index.ts b/index.ts index 5843ae1..acb327b 100644 --- a/index.ts +++ b/index.ts @@ -7,4 +7,10 @@ | */ +import activityLog from './src/models/activity_log.js' + +export const ActivityLog = activityLog export { configure } from './configure.js' +export { stubsRoot } from './stubs/main.js' +export { LogManager, activity, ActivityBuilder } from './src/logger.js' +export { MorphMap } from './src/decorators.js' diff --git a/package.json b/package.json index e203301..55e9c85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@holoyan/adonis-activitylog", "description": "AdonisJs activity log", - "version": "0.0.0", + "version": "0.1.0", "engines": { "node": ">=20.6.0" }, @@ -17,7 +17,8 @@ ], "exports": { ".": "./build/index.js", - "./types": "./build/src/types.js" + "./types": "./build/src/types.js", + "./activity_log_provider": "./build/providers/activity_log_provider.js" }, "scripts": { "clean": "del-cli build", @@ -50,20 +51,27 @@ "@swc/core": "^1.4.6", "@types/luxon": "^3.4.2", "@types/node": "^20.11.25", + "@types/uuid": "^9.0.8", "c8": "^9.1.0", "copyfiles": "^2.4.1", "del-cli": "^5.1.0", - "eslint": "^8.57.0", + "eslint": "^8.38.0", "luxon": "^3.4.4", + "mssql": "^10.0.2", + "mysql2": "^3.9.3", "np": "^10.0.0", "prettier": "^3.2.5", + "sqlite3": "^5.1.7", "ts-node": "^10.9.2", - "typescript": "^5.4.2" + "typescript": "^5.3.3", + "uuid": "^9.0.1" }, "peerDependencies": { "@adonisjs/core": "^6.2.0", "@adonisjs/lucid": "^21.0.0", - "luxon": "^3.4.4" + "@types/uuid": "^9.0.8", + "luxon": "^3.4.4", + "uuid": "^9.0.1" }, "publishConfig": { "access": "public", @@ -87,5 +95,8 @@ "eslintConfig": { "extends": "@adonisjs/eslint-config/package" }, - "prettier": "@adonisjs/prettier-config" + "prettier": "@adonisjs/prettier-config", + "dependencies": { + "pg": "^8.12.0" + } } diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..4370a56 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,84 @@ +import { JSONObject } from './types.js' + +export function changes(obj1: JSONObject, obj2: JSONObject): JSONObject { + obj1 = initObject(obj1) + obj2 = initObject(obj2) + const diffs: JSONObject = {} + const visited = new WeakSet() + + function compareObjects(o1: JSONObject, o2: JSONObject, result: JSONObject): void { + if (visited.has(o1) || visited.has(o2)) { + return + } + visited.add(o1) + visited.add(o2) + + const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]) + + keys.forEach((key) => { + const val1 = o1[key] + const val2 = o2[key] + + if ( + val1 && + typeof val1 === 'object' && + val2 && + typeof val2 === 'object' && + !Array.isArray(val1) && + !Array.isArray(val2) + ) { + result[key] = {} + compareObjects(val1 as JSONObject, val2 as JSONObject, result[key] as JSONObject) + if (Object.keys(result[key] as JSONObject).length === 0) { + delete result[key] + } + } else if (val1 !== val2) { + result[key] = { oldValue: val1, newValue: val2 } + } + }) + } + + compareObjects(obj1, obj2, diffs) + + return diffs +} + +export function diff(obj1: JSONObject, obj2: JSONObject): JSONObject { + const diffs: JSONObject = {} + obj1 = initObject(obj1) + obj2 = initObject(obj2) + + function compareObjects(o1: JSONObject, o2: JSONObject, result: JSONObject) { + const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]) + + keys.forEach((key) => { + const val1 = o1[key] + const val2 = o2[key] + + if ( + typeof val1 === 'object' && + val1 !== null && + typeof val2 === 'object' && + val2 !== null && + !Array.isArray(val1) && + !Array.isArray(val2) + ) { + const nestedDiffs: JSONObject = {} + compareObjects(val1 as JSONObject, val2 as JSONObject, nestedDiffs) + if (Object.keys(nestedDiffs).length > 0) { + result[key] = nestedDiffs + } + } else if (val1 !== val2) { + result[key] = val2 + } + }) + } + + compareObjects(obj1, obj2, diffs) + + return diffs +} + +function initObject(obj: JSONObject): JSONObject { + return obj || {} +} diff --git a/src/logger.ts b/src/logger.ts index 39f1da1..c6de93e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,17 +1,12 @@ -import { BaseModel } from '@adonisjs/lucid/orm' -import { - LucidModel, - ModelAdapterOptions, - ModelQueryBuilderContract, -} from '@adonisjs/lucid/types/model' -import { LogModel, MorphInterface } from './types.js' +import { ModelAssignOptions } from '@adonisjs/lucid/types/model' +import { ActivityLogInterface, LogModel, MorphInterface, MyType } from './types.js' export class LogManager { - static _modelClass: typeof BaseModel + static _modelClass: MyType private static _map: MorphInterface - static setModelClass(modelClass: typeof BaseModel) { + static setModelClass(modelClass: MyType) { this._modelClass = modelClass } @@ -25,10 +20,10 @@ export class LogManager { } export class ActivityBuilder { - private _adapterOptions?: ModelAdapterOptions - private _queryBuilder: ModelQueryBuilderContract | null = null + private _adapterOptions?: ModelAssignOptions - queryOptions(options?: ModelAdapterOptions) { + private _state = {} + queryOptions(options?: ModelAssignOptions) { this._adapterOptions = options return this } @@ -44,10 +39,12 @@ export class ActivityBuilder { private _entityId: string | number | null = null private _entityType: string | null = null - private _properties: Object | null = null + private _current: Object | null = null + private _previous: Object | null = null + private _batchId: string | null = null - name(name: string) { + named(name: string) { this._name = name return this } @@ -78,6 +75,11 @@ export class ActivityBuilder { if (typeof entity !== 'string') { this._entityId = entity.getModelId() this._entityType = LogManager.morphMap().getAlias(entity) + + if ('toLog' in entity && typeof entity.toLog === 'function') { + const toLog = entity.toLog() as Object + this.havingCurrent(toLog) + } } else if (typeof entityId === 'string' || typeof entityId === 'number') { this._entityId = entityId this._entityType = entity @@ -87,62 +89,55 @@ export class ActivityBuilder { return this } - havingProperties(state: Object) { - this._properties = state + havingCurrent(state: Object) { + this._current = state + return this + } + + previousState(state: Object) { + this._previous = state return this } - withBatch(batchId: string) { + groupedBy(batchId: string) { this._batchId = batchId return this } log(message: string) { - const state = this.state() - state.description = message - // Here you would typically save the log to the database or perform the logging operation - console.log(state) + this._description = message + + // @ts-ignore + return LogManager._modelClass.create( + this.state(), + this._adapterOptions + ) as unknown as Promise + } + + values(values: Object) { + this._state = { ...this._state, ...values } + return this } state() { - return { + this._state = { + ...this._state, name: this._name, model_id: this._modelId, model_type: this._modelType, event: this._event, entity_id: this._entityId, entity_type: this._entityType, - properties: this._properties, + current: this._current, + previous: this._previous, batch_id: this._batchId, description: this._description, } - } - customQuery(callback: (query: ModelQueryBuilderContract) => void) { - callback(this.getBuilder()) - return this - } - - private getBuilder() { - if (!this._queryBuilder) { - this._queryBuilder = LogManager._modelClass.query(this._adapterOptions) - } - return this._queryBuilder + return this._state } } export function activity() { return new ActivityBuilder() } - -// Example usage: -activity() - .by('User', 1) - .making('edit') - .on('Product', 2) - .havingProperties({}) - .customQuery((query) => { - query.where('status', 'active') - query.where('created_at', '>=', new Date('2023-01-01')) - }) - .log('Edited product') diff --git a/src/models/activity_log.ts b/src/models/activity_log.ts index a90a000..a9b5b05 100644 --- a/src/models/activity_log.ts +++ b/src/models/activity_log.ts @@ -1,7 +1,9 @@ import { DateTime } from 'luxon' -import { BaseModel, column, scope } from '@adonisjs/lucid/orm' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { ActivityLogInterface, JSONObject } from '../types.js' +import { changes, diff } from '../helpers.js' -export default class ActivityLog extends BaseModel { +export default class ActivityLog extends BaseModel implements ActivityLogInterface { @column({ isPrimary: true }) declare id: number @@ -27,7 +29,10 @@ export default class ActivityLog extends BaseModel { declare entity_id: string @column() - declare properties: Object + declare current: JSONObject + + @column() + declare previous: JSONObject @column() declare batch_id: string @@ -38,10 +43,11 @@ export default class ActivityLog extends BaseModel { @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime - static published = scope((query) => { - query.where('publishedOn', '<=', DateTime.utc().toSQLDate()) - }) -} + changes() { + return changes(this.previous, this.current) + } -const a = ActivityLog.query() -a.where('a', 4).withScopes((scopes) => scopes.published()) + diff() { + return diff(this.previous, this.current) + } +} diff --git a/src/types.ts b/src/types.ts index 7202d84..948a061 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,36 @@ import { LucidModel } from '@adonisjs/lucid/types/model' +import { DateTime } from 'luxon' +import { BaseModel } from '@adonisjs/lucid/orm' export interface LogModelInterface { - getModelId(): string | number + getModelId(): string } -// export type LogModel = InstanceType & LogModelInterface -export interface LogModel extends LucidModel, LogModelInterface {} +export interface ActivityLogInterface { + id: number + name: string + description: string + model_type: string + model_id: string + event: string + entity_type: string + entity_id: string + current: JSONObject + previous: JSONObject + batch_id: string + createdAt: DateTime + updatedAt: DateTime + changes(): JSONObject + diff(): JSONObject +} + +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray +export interface JSONObject { + [key: string]: JSONValue +} +export interface JSONArray extends Array {} + +export type LogModel = InstanceType & LogModelInterface export interface MorphMapInterface { [key: string]: any @@ -18,3 +43,5 @@ export interface MorphInterface { hasTarget(target: any): boolean getAlias(target: any): string } + +export type MyType = typeof BaseModel diff --git a/stubs/migrations/create_db.stub b/stubs/migrations/create_db.stub index ad24068..0b6b91f 100644 --- a/stubs/migrations/create_db.stub +++ b/stubs/migrations/create_db.stub @@ -1,6 +1,6 @@ {{{ exports({ - to: app.makePath('database', 'migrations', prefix + '_create_role_permissions_table.ts') + to: app.makePath('database', 'migrations', prefix + '_create_activity_logs_table.ts') }) }}} import { BaseSchema } from '@adonisjs/lucid/schema' @@ -19,7 +19,8 @@ export default class extends BaseSchema { table.string('event').nullable() table.string('entity_type').nullable() table.string('entity_id').nullable() - table.json('properties').nullable() + table.json('current').nullable() + table.json('previous').nullable() table.string('batch_id').nullable().index() table.index(['model_type', 'model_id']) diff --git a/test-helpers/index.ts b/test-helpers/index.ts new file mode 100644 index 0000000..c544af9 --- /dev/null +++ b/test-helpers/index.ts @@ -0,0 +1,501 @@ +import { configDotenv } from 'dotenv' +import { getActiveTest } from '@japa/runner' +import { Emitter } from '@adonisjs/core/events' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { Database } from '@adonisjs/lucid/database' +import { Encryption } from '@adonisjs/core/encryption' +import { AppFactory } from '@adonisjs/core/factories/app' +import { LoggerFactory } from '@adonisjs/core/factories/logger' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { join } from 'node:path' +import fs from 'node:fs' +import { DateTime } from 'luxon' +import { + ActivityLogInterface, + JSONObject, + LogModelInterface, + MorphInterface, + MorphMapInterface, +} from '../src/types.js' +import { ApplicationService } from '@adonisjs/core/types' +import { v4 as uuidv4 } from 'uuid' +import { changes, diff } from '../src/helpers.js' + +export const encryption: Encryption = new EncryptionFactory().create() +configDotenv() + +const BASE_URL = new URL('./tmp/', import.meta.url) + +const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService +await app.init() +await app.boot() + +const logger = new LoggerFactory().create() +const emitter = new Emitter(app) + +class MorphMap implements MorphInterface { + _map: MorphMapInterface = {} + + static _instance?: MorphMap + + static create() { + if (this._instance) { + return this._instance + } + + return new MorphMap() + } + + set(alias: string, target: any) { + this._map[alias] = target + } + + get(alias: string) { + if (!(alias in this._map)) { + throw new Error('morph map not found for ' + alias) + } + + return this._map[alias] || null + } + + has(alias: string) { + return alias in this._map + } + + hasTarget(target: any) { + const keys = Object.keys(this._map) + for (const key of keys) { + if (this._map[key] === target) { + return true + } + } + + return false + } + + getAlias(target: any) { + const keys = Object.keys(this._map) + for (const key of keys) { + if (target instanceof this._map[key] || target === this._map[key]) { + return key + } + } + + throw new Error('Target not found') + } +} + +export const morphMap = MorphMap.create() +export function MorphMapDecorator(param: string) { + return function (target: T) { + target.prototype.__morphMapName = param + morphMap.set(param, target) + } +} + +/** + * Creates an instance of the database class for making queries + */ +export async function createDatabase() { + const test = getActiveTest() + + if (!test) { + throw new Error('Cannot use "createDatabase" outside of a Japa test') + } + + var dir = '../tmp' + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir) + } + + const db = new Database( + { + connection: process.env.DB || 'sqlite', + connections: { + sqlite: { + client: 'sqlite3', + connection: { + filename: join('../tmp', 'db.sqlite3'), + }, + }, + pg: { + client: 'pg', + connection: { + host: process.env.PG_HOST as string, + port: Number(process.env.PG_PORT), + database: process.env.PG_DATABASE as string, + user: process.env.PG_USER as string, + password: process.env.PG_PASSWORD as string, + }, + }, + mssql: { + client: 'mssql', + connection: { + server: process.env.MSSQL_HOST as string, + port: Number(process.env.MSSQL_PORT! as string), + user: process.env.MSSQL_USER as string, + password: process.env.MSSQL_PASSWORD as string, + database: 'master', + options: { + enableArithAbort: true, + }, + }, + }, + mysql: { + client: 'mysql2', + connection: { + host: process.env.MYSQL_HOST as string, + port: Number(process.env.MYSQL_PORT), + database: process.env.MYSQL_DATABASE as string, + user: process.env.MYSQL_USER as string, + password: process.env.MYSQL_PASSWORD as string, + }, + }, + }, + }, + logger, + emitter + ) + + test.cleanup(() => db.manager.closeAll()) + BaseModel.useAdapter(db.modelAdapter()) + return db +} + +/** + * Creates needed database tables + */ +export async function createTables(db: Database) { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createTables" outside of a Japa test') + } + + test.cleanup(async () => { + await db.connection().schema.dropTableIfExists('activity_logs') + await db.connection().schema.dropTableIfExists('custom_activity_log') + await db.connection().schema.dropTableIfExists('users') + await db.connection().schema.dropTableIfExists('admins') + await db.connection().schema.dropTableIfExists('products') + await db.connection().schema.dropTableIfExists('posts') + await db.connection().schema.dropTableIfExists('auto_log_models') + }) + + await db.connection().schema.createTableIfNotExists('activity_logs', (table) => { + table.increments('id') + + table.string('name').nullable().index() + table.text('description') + table.string('model_type').nullable() + table.string('model_id').nullable() + table.string('event').nullable() + table.string('entity_type').nullable() + table.string('entity_id').nullable() + table.json('current').nullable() + table.json('previous').nullable() + table.string('batch_id').nullable().index() + + table.index(['model_type', 'model_id']) + table.index(['entity_type', 'entity_id']) + + table.timestamp('created_at') + table.timestamp('updated_at') + }) + await db.connection().schema.createTableIfNotExists('custom_activity_log', (table) => { + table.increments('id') + + table.string('name').nullable().index() + table.text('description') + table.string('model_type').nullable() + table.string('model_id').nullable() + table.string('event').nullable() + table.string('entity_type').nullable() + table.string('entity_id').nullable() + table.json('current').nullable() + table.json('previous').nullable() + table.string('batch_id').nullable().index() + table.string('email').nullable().index() + + table.index(['model_type', 'model_id']) + table.index(['entity_type', 'entity_id']) + + table.timestamp('created_at') + table.timestamp('updated_at') + }) + + await db.connection().schema.createTableIfNotExists('users', (table) => { + table.increments('id').notNullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + }) + await db.connection().schema.createTableIfNotExists('admins', (table) => { + table.increments('id').notNullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + }) + + await db.connection().schema.createTableIfNotExists('products', (table) => { + PrimaryKey(table, 'id') + + /** + * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL + */ + table.timestamp('created_at', { useTz: true }) + table.timestamp('updated_at', { useTz: true }) + }) + + await db.connection().schema.createTableIfNotExists('posts', (table) => { + PrimaryKey(table, 'id') + table.string('title').nullable() + table.string('body').nullable() + + /** + * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL + */ + table.timestamp('created_at', { useTz: true }) + table.timestamp('updated_at', { useTz: true }) + }) + await db.connection().schema.createTableIfNotExists('auto_log_models', (table) => { + PrimaryKey(table, 'id') + table.string('title').nullable() + + /** + * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL + */ + table.timestamp('created_at', { useTz: true }) + table.timestamp('updated_at', { useTz: true }) + }) +} +function PrimaryKey(table: any, columnName: string) { + return wantsUUID() ? table.string(columnName).primary() : table.bigIncrements(columnName) +} + +export function wantsUUID() { + return process.env.UUID_SUPPORT === 'true' +} + +export async function defineModels() { + class ActivityLog extends BaseModel implements ActivityLogInterface { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare description: string + + @column() + declare model_type: string + + @column() + declare model_id: string + + @column() + declare event: string + + @column() + declare entity_type: string + + @column() + declare entity_id: string + + @column() + declare current: JSONObject + + @column() + declare previous: JSONObject + + @column() + declare batch_id: string + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + changes() { + return changes(this.previous, this.current) + } + + diff() { + return diff(this.previous, this.current) + } + } + + class CustomActivityLog extends ActivityLog implements ActivityLogInterface { + static table = 'custom_activity_log' + + @column() + declare email: string + } + + @MorphMapDecorator('users') + class User extends BaseModel implements LogModelInterface { + @column({ isPrimary: true }) + declare id: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + + getModelId(): string { + return String(this.id) + } + } + + @MorphMapDecorator('admins') + class Admin extends BaseModel implements LogModelInterface { + @column({ isPrimary: true }) + declare id: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + + getModelId(): string { + return String(this.id) + } + } + + @MorphMapDecorator('products') + class Product extends BaseModel implements LogModelInterface { + @column({ isPrimary: true }) + declare id: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + getModelId(): string { + return String(this.id) + } + } + + @MorphMapDecorator('posts') + class Post extends BaseModel implements LogModelInterface { + @column({ isPrimary: true }) + declare id: string + + @column() + declare title: string | null + + @column() + declare body: string | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + getModelId(): string { + return String(this.id) + } + } + + @MorphMapDecorator('AutoLogModels') + class AutoLogModel extends BaseModel implements LogModelInterface { + static table = 'auto_log_models' + + @column({ isPrimary: true }) + declare id: string + + @column() + declare title: string | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + getModelId(): string { + return String(this.id) + } + + toLog() { + return { + id: this.id, + title: this.title, + } + } + } + + return { + User: User, + Product: Product, + Post: Post, + Admin: Admin, + ActivityLog: ActivityLog, + CustomActivityLog: CustomActivityLog, + AutoLogModel: AutoLogModel, + } +} + +export async function seedDb(models: any) { + await models.User.createMany(getUsers(100)) + if (models.Post) { + await models.Post.createMany(getPosts(20)) + } + if (models.Product) { + await models.Product.createMany(getProduts(20)) + } + if (models.AutoLogModel) { + await models.AutoLogModel.createMany(getAutoLogModels(20)) + } +} + +/** + * Returns an array of users filled with random data + */ +export function getUsers(count: number) { + // const chance = new Chance() + return [...new Array(count)].map(() => { + return {} + }) +} + +/** + * Returns an array of posts for a given user, filled with random data + */ +export function getPosts(count: number) { + return [...new Array(count)].map(() => { + if (wantsUUID()) { + return { + id: uuidv4(), + } + } + return {} + }) +} + +/** + * Returns an array of posts for a given user, filled with random data + */ +export function getProduts(count: number) { + return [...new Array(count)].map(() => { + return {} + }) +} + +export function getAutoLogModels(count: number) { + return [...new Array(count)].map(() => { + return { + id: Math.floor(Math.random() * 1000), + title: 'Auto log model' + Math.floor(Math.random() * 1000), + } + }) +} + +export function makeId() { + return uuidv4() +} diff --git a/tests/activity_log.spec.ts b/tests/activity_log.spec.ts new file mode 100644 index 0000000..b592fc4 --- /dev/null +++ b/tests/activity_log.spec.ts @@ -0,0 +1,364 @@ +import { test } from '@japa/runner' + +import { + createDatabase, + createTables, + defineModels, + makeId, + morphMap, + seedDb, +} from '../test-helpers/index.js' + +import { LogManager, activity } from '../src/logger.js' +import { BaseModel } from '@adonisjs/lucid/orm' + +test.group('Activity log | writing', (group) => { + group.setup(async () => {}) + + group.teardown(async () => {}) + + // group.each.setup(async () => {}) + group.each.disableTimeout() + + test('Creating first log', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { ActivityLog }: { ActivityLog: typeof BaseModel } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + const desc = 'First log data' + const log = await activity().log(desc) + + assert.equal(log.description, desc) + }) + + test('Create log for a user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const desc = 'User log data' + const log = await activity().by(user).log(desc) + + assert.equal(log.description, desc) + assert.equal(log.model_id, user.id) + }) + + test('Create full log model', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const post = await Post.first() + if (!post) { + throw new Error('Post not found') + } + + const desc = 'User log data' + const props = post.serialize() + + const log = await activity() + .named('info') + .by(user) + .making('update') + .on(post) + .havingCurrent(props) + .log(desc) + + assert.equal(log.description, desc) + assert.equal(log.model_id, user.id) + assert.equal(log.name, 'info') + assert.equal(log.event, 'update') + assert.equal(log.entity_id, post.id) + assert.equal(log.current, props) + }) + + test('Ensure multiple logs not conflicting', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const post = await Post.first() + if (!post) { + throw new Error('Post not found') + } + + const product = await Product.first() + if (!product) { + throw new Error('Product not found') + } + + const desc1 = 'User log data' + const desc2 = 'log2 desc' + const postData = post.serialize() + const productData = product.serialize() + + const log1 = await activity() + .named('info') + .by(user) + .making('update') + .on(post) + .havingCurrent(postData) + .log(desc1) + + const log2 = await activity() + .named('info') + .by(user) + .making('update') + .on(product) + .havingCurrent(productData) + .log(desc2) + + assert.notEqual(log1.id, log2.id) + }) + + test('Create multiple logs with same batch', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const post = await Post.first() + if (!post) { + throw new Error('Post not found') + } + + const product = await Product.first() + if (!product) { + throw new Error('Product not found') + } + + const batchId = makeId() + + const desc1 = 'User log data' + const desc2 = 'log2 desc' + const postData = post.serialize() + const productData = product.serialize() + + const log1 = await activity() + .named('info') + .by(user) + .making('update') + .on(post) + .havingCurrent(postData) + .groupedBy(batchId) + .log(desc1) + + const log2 = await activity() + .named('info') + .by(user) + .making('update') + .on(product) + .havingCurrent(productData) + .groupedBy(batchId) + .log(desc2) + + assert.equal(log1.batch_id, log2.batch_id) + }) + + test('get changes and diffs', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const post = await Post.create({ + title: 'Weather on Monday', + body: 'Today we have +23C', + }) + + const desc = 'Updating post data' + const oldState = post.serialize() + await post + .merge({ + title: 'Weather on Sunday', + }) + .save() + const currentData = post.serialize() + + const log1 = await activity() + .named('info') + .by(user) + .making('update') + .on(post) + .havingCurrent(currentData) + .previousState(oldState) + .log(desc) + + assert.deepEqual(log1.diff(), { + title: post.title, + updatedAt: post.updatedAt.toString(), + }) + + assert.deepEqual(log1.changes(), { + title: { oldValue: oldState.title, newValue: post.title }, + updatedAt: { oldValue: oldState.updatedAt, newValue: post.updatedAt.toString() }, + }) + }) + + test('Writing custom query with custom model', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, CustomActivityLog } = await defineModels() + + LogManager.setModelClass(CustomActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const log = await activity() + .named('info') + .by(user) + .making('update') + .values({ email: 'myemail@test.com' }) + .log('my log description') + + // @ts-ignore + assert.deepEqual(log.email, 'myemail@test.com') + }) + + test('Writing logs inside transaction', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, Product, Post, ActivityLog } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, Product, Post }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const post = await Post.first() + if (!post) { + throw new Error('Post not found') + } + + const desc1 = 'User log data' + const postData = post.serialize() + + const trx = await db.transaction() + + const log1 = await activity() + .queryOptions({ client: trx }) + .named('info') + .by(user) + .making('update') + .on(post) + .havingCurrent(postData) + .log(desc1) + + await trx.commit() + + const trx2 = await db.transaction() + + await activity().queryOptions({ client: trx2 }).named('outside-trx').log('outside transaction') + + trx2.rollback() + + assert.equal(log1.name, 'info') + + const rollbacked = await ActivityLog.query().where('name', 'outside-trx').first() + assert.isNull(rollbacked) + }) + + test('Test automatic log', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + const { User, ActivityLog, AutoLogModel } = await defineModels() + + LogManager.setModelClass(ActivityLog) + LogManager.setMorphMap(morphMap) + + await seedDb({ User, AutoLogModel }) + + const user = await User.first() + + if (!user) { + throw new Error('user not found') + } + + const autoLog = await AutoLogModel.first() + + if (!autoLog) { + throw new Error('AutoLogModel not found') + } + + const log = await activity() + .named('autolog') + .by(user) + .making('update') + .on(autoLog) + .log('my log description') + + assert.deepEqual(log.current, { + id: autoLog.id, + title: autoLog.title, + }) + }) +}) diff --git a/tests/example.spec.ts b/tests/example.spec.ts deleted file mode 100644 index 893ea1a..0000000 --- a/tests/example.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test } from '@japa/runner' - -test.group('Example', () => { - test('add two numbers', ({ assert }) => { - assert.equal(1 + 1, 2) - }) -})