Compare commits
1 Commits
fe51355d29
...
approvals
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cddbd09f0f |
@@ -0,0 +1,190 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
await knex.schema.createTable('approval_definitions', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.string('triggerType', 191).notNullable();
|
||||||
|
table.string('targetObjectType', 191);
|
||||||
|
table.json('entryCriteria');
|
||||||
|
table.json('steps');
|
||||||
|
table.json('votingPolicy');
|
||||||
|
table.string('rejectionRule', 191);
|
||||||
|
table.string('materialChangePolicy', 191);
|
||||||
|
table.integer('version').notNullable().defaultTo(1);
|
||||||
|
table.boolean('isActive').notNullable().defaultTo(true);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_requests', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('definitionId', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.string('targetObjectType', 191).notNullable();
|
||||||
|
table.string('targetObjectId', 191).notNullable();
|
||||||
|
table.string('action', 191);
|
||||||
|
table.string('stateFrom', 191);
|
||||||
|
table.string('stateTo', 191);
|
||||||
|
table.json('fieldChanges');
|
||||||
|
table.json('snapshot');
|
||||||
|
table.string('versionHash', 191);
|
||||||
|
table.string('submittedById', 191);
|
||||||
|
table.timestamp('submittedAt');
|
||||||
|
table.string('currentStepKey', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['definitionId']);
|
||||||
|
table.index(['targetObjectType', 'targetObjectId']);
|
||||||
|
table.index(['submittedById']);
|
||||||
|
table
|
||||||
|
.foreign('definitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_definitions')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('submittedById')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_steps', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('requestId', 191).notNullable();
|
||||||
|
table.string('stepKey', 191).notNullable();
|
||||||
|
table.string('name', 191).notNullable();
|
||||||
|
table.integer('stepOrder').notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.json('routing');
|
||||||
|
table.json('voting');
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.timestamp('completedAt');
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['requestId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table
|
||||||
|
.foreign('requestId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_requests')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_assignments', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('stepId', 191).notNullable();
|
||||||
|
table.string('assigneeId', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.text('response');
|
||||||
|
table.timestamp('respondedAt');
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.string('reassignedFromId', 191);
|
||||||
|
table.string('delegatedById', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['stepId']);
|
||||||
|
table.index(['assigneeId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table
|
||||||
|
.foreign('stepId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_steps')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('assigneeId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('reassignedFromId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('delegatedById')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_effect_logs', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('requestId', 191).notNullable();
|
||||||
|
table.string('effectKey', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('success');
|
||||||
|
table.json('response');
|
||||||
|
table.timestamp('executedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.unique(['requestId', 'effectKey']);
|
||||||
|
table.index(['requestId']);
|
||||||
|
table
|
||||||
|
.foreign('requestId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_requests')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('tasks', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('title', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.string('status', 191).notNullable().defaultTo('open');
|
||||||
|
table.string('priority', 191);
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.string('assignedToId', 191);
|
||||||
|
table.string('relatedObjectType', 191);
|
||||||
|
table.string('relatedObjectId', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['assignedToId']);
|
||||||
|
table.index(['relatedObjectType', 'relatedObjectId']);
|
||||||
|
table
|
||||||
|
.foreign('assignedToId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('activity_logs', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('action', 191).notNullable();
|
||||||
|
table.string('subjectType', 191).notNullable();
|
||||||
|
table.string('subjectId', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.json('properties');
|
||||||
|
table.string('causerId', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['subjectType', 'subjectId']);
|
||||||
|
table.index(['causerId']);
|
||||||
|
table
|
||||||
|
.foreign('causerId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('activity_logs');
|
||||||
|
await knex.schema.dropTableIfExists('tasks');
|
||||||
|
await knex.schema.dropTableIfExists('approval_effect_logs');
|
||||||
|
await knex.schema.dropTableIfExists('approval_assignments');
|
||||||
|
await knex.schema.dropTableIfExists('approval_steps');
|
||||||
|
await knex.schema.dropTableIfExists('approval_requests');
|
||||||
|
await knex.schema.dropTableIfExists('approval_definitions');
|
||||||
|
};
|
||||||
136
backend/package-lock.json
generated
136
backend/package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.5",
|
"@casl/ability": "^6.7.5",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
"@langchain/core": "^1.1.15",
|
"@langchain/core": "^1.1.12",
|
||||||
"@langchain/langgraph": "^1.0.15",
|
"@langchain/langgraph": "^1.0.15",
|
||||||
"@langchain/openai": "^1.2.1",
|
"@langchain/openai": "^1.2.1",
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
@@ -29,10 +29,9 @@
|
|||||||
"bullmq": "^5.1.0",
|
"bullmq": "^5.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"deepagents": "^1.5.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"langchain": "^1.2.10",
|
"langchain": "^1.2.7",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
@@ -229,26 +228,6 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
|
||||||
"version": "0.71.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
|
|
||||||
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"json-schema-to-ts": "^3.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"anthropic-ai-sdk": "bin/cli"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"zod": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -710,15 +689,6 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.28.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@@ -1719,26 +1689,10 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@langchain/anthropic": {
|
|
||||||
"version": "1.3.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz",
|
|
||||||
"integrity": "sha512-VXq5fsEJ4FB5XGrnoG+bfm0I7OlmYLI4jZ6cX9RasyqhGo9wcDyKw1+uEQ1H7Og7jWrTa1bfXCun76wttewJnw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/sdk": "^0.71.0",
|
|
||||||
"zod": "^3.25.76 || ^4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@langchain/core": "1.1.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@langchain/core": {
|
"node_modules/@langchain/core": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz",
|
||||||
"integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==",
|
"integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.0.2",
|
"@cfworker/json-schema": "^4.0.2",
|
||||||
@@ -2533,6 +2487,7 @@
|
|||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "2.0.5",
|
"@nodelib/fs.stat": "2.0.5",
|
||||||
@@ -2546,6 +2501,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
@@ -2555,6 +2511,7 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.scandir": "2.1.5",
|
"@nodelib/fs.scandir": "2.1.5",
|
||||||
@@ -4072,6 +4029,7 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -4796,22 +4754,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/deepagents": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deepagents/-/deepagents-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-tjZLOISPMpqfk+k/iE1uIZavXW9j4NrhopUmH5ARqzmk95EEtGDyN++tgnY+tdVOOZTjE2LHjOVV7or58dtx8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@langchain/anthropic": "^1.3.7",
|
|
||||||
"@langchain/core": "^1.1.12",
|
|
||||||
"@langchain/langgraph": "^1.0.14",
|
|
||||||
"fast-glob": "^3.3.3",
|
|
||||||
"langchain": "^1.2.7",
|
|
||||||
"micromatch": "^4.0.8",
|
|
||||||
"yaml": "^2.8.2",
|
|
||||||
"zod": "^4.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -5572,6 +5514,7 @@
|
|||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
@@ -5797,6 +5740,7 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -6201,6 +6145,7 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
@@ -6649,6 +6594,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6677,6 +6623,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -6711,6 +6658,7 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
@@ -7661,19 +7609,6 @@
|
|||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-to-ts": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.18.3",
|
|
||||||
"ts-algebra": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -7876,9 +7811,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/langchain": {
|
"node_modules/langchain": {
|
||||||
"version": "1.2.10",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz",
|
||||||
"integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==",
|
"integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/langgraph": "^1.0.0",
|
"@langchain/langgraph": "^1.0.0",
|
||||||
@@ -7891,7 +7826,7 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@langchain/core": "1.1.15"
|
"@langchain/core": "1.1.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/langchain/node_modules/uuid": {
|
"node_modules/langchain/node_modules/uuid": {
|
||||||
@@ -8266,6 +8201,7 @@
|
|||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
@@ -8275,6 +8211,7 @@
|
|||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.3",
|
"braces": "^3.0.3",
|
||||||
@@ -8288,6 +8225,7 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -9450,6 +9388,7 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9788,6 +9727,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -10717,6 +10657,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -10768,12 +10709,6 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-algebra": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||||
@@ -11520,21 +11455,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
@@ -11588,9 +11508,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.5",
|
"@casl/ability": "^6.7.5",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
"@langchain/core": "^1.1.15",
|
"@langchain/core": "^1.1.12",
|
||||||
"@langchain/langgraph": "^1.0.15",
|
"@langchain/langgraph": "^1.0.15",
|
||||||
"@langchain/openai": "^1.2.1",
|
"@langchain/openai": "^1.2.1",
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
@@ -46,10 +46,9 @@
|
|||||||
"bullmq": "^5.1.0",
|
"bullmq": "^5.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"deepagents": "^1.5.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"langchain": "^1.2.10",
|
"langchain": "^1.2.7",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
|
|||||||
42
backend/src/activity-log/activity-log.controller.ts
Normal file
42
backend/src/activity-log/activity-log.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ActivityLogService } from './activity-log.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('activity-log')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ActivityLogController {
|
||||||
|
constructor(private activityLogService: ActivityLogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listActivities(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Query('subjectType') subjectType?: string,
|
||||||
|
@Query('subjectId') subjectId?: string,
|
||||||
|
@Query('causerId') causerId?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
) {
|
||||||
|
return this.activityLogService.listActivities(tenantId, {
|
||||||
|
subjectType,
|
||||||
|
subjectId,
|
||||||
|
causerId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createActivity(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
action: string;
|
||||||
|
subjectType: string;
|
||||||
|
subjectId: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.activityLogService.logActivity(tenantId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/activity-log/activity-log.module.ts
Normal file
12
backend/src/activity-log/activity-log.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ActivityLogController } from './activity-log.controller';
|
||||||
|
import { ActivityLogService } from './activity-log.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [ActivityLogController],
|
||||||
|
providers: [ActivityLogService],
|
||||||
|
exports: [ActivityLogService],
|
||||||
|
})
|
||||||
|
export class ActivityLogModule {}
|
||||||
54
backend/src/activity-log/activity-log.service.ts
Normal file
54
backend/src/activity-log/activity-log.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { ActivityLog } from '../models/activity-log.model';
|
||||||
|
|
||||||
|
export interface ActivityLogInput {
|
||||||
|
action: string;
|
||||||
|
subjectType: string;
|
||||||
|
subjectId: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityLogService {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logActivity(tenantId: string, input: ActivityLogInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ActivityLog.query(knex).insert({
|
||||||
|
action: input.action,
|
||||||
|
subjectType: input.subjectType,
|
||||||
|
subjectId: input.subjectId,
|
||||||
|
description: input.description,
|
||||||
|
properties: input.properties ?? null,
|
||||||
|
causerId: input.causerId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActivities(
|
||||||
|
tenantId: string,
|
||||||
|
filters: {
|
||||||
|
subjectType?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
causerId?: string;
|
||||||
|
action?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ActivityLog.query(knex)
|
||||||
|
.modify((qb) => {
|
||||||
|
if (filters.subjectType) qb.where('subjectType', filters.subjectType);
|
||||||
|
if (filters.subjectId) qb.where('subjectId', filters.subjectId);
|
||||||
|
if (filters.causerId) qb.where('causerId', filters.causerId);
|
||||||
|
if (filters.action) qb.where('action', filters.action);
|
||||||
|
})
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,94 +12,15 @@ export interface AiChatContext {
|
|||||||
|
|
||||||
export interface AiAssistantReply {
|
export interface AiAssistantReply {
|
||||||
reply: string;
|
reply: string;
|
||||||
action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending';
|
action?: 'create_record' | 'collect_fields' | 'clarify';
|
||||||
missingFields?: string[];
|
missingFields?: string[];
|
||||||
record?: any;
|
record?: any;
|
||||||
records?: any[]; // Multiple records when plan execution completes
|
|
||||||
plan?: RecordCreationPlan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Entity Discovery Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface EntityFieldInfo {
|
|
||||||
apiName: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
isRequired: boolean;
|
|
||||||
isSystem: boolean;
|
|
||||||
referenceObject?: string; // For LOOKUP fields, the target entity
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EntityRelationship {
|
|
||||||
fieldApiName: string;
|
|
||||||
fieldLabel: string;
|
|
||||||
targetEntity: string;
|
|
||||||
relationshipType: 'lookup' | 'master-detail' | 'polymorphic';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EntityInfo {
|
|
||||||
apiName: string;
|
|
||||||
label: string;
|
|
||||||
pluralLabel?: string;
|
|
||||||
description?: string;
|
|
||||||
fields: EntityFieldInfo[];
|
|
||||||
requiredFields: string[]; // Field apiNames that are required
|
|
||||||
relationships: EntityRelationship[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemEntities {
|
|
||||||
entities: EntityInfo[];
|
|
||||||
entityByApiName: Record<string, EntityInfo>; // Changed from Map for state serialization
|
|
||||||
loadedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Planning Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface PlannedRecord {
|
|
||||||
id: string; // Temporary ID for planning (e.g., "temp_account_1")
|
|
||||||
entityApiName: string;
|
|
||||||
entityLabel: string;
|
|
||||||
fields: Record<string, any>;
|
|
||||||
resolvedFields?: Record<string, any>; // Fields after dependency resolution
|
|
||||||
missingRequiredFields: string[];
|
|
||||||
dependsOn: string[]; // IDs of other planned records this depends on
|
|
||||||
status: 'pending' | 'ready' | 'created' | 'failed';
|
|
||||||
createdRecordId?: string; // Actual ID after creation
|
|
||||||
wasExisting?: boolean; // True if record already existed in database
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordCreationPlan {
|
|
||||||
id: string;
|
|
||||||
records: PlannedRecord[];
|
|
||||||
executionOrder: string[]; // Ordered list of planned record IDs
|
|
||||||
status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed';
|
|
||||||
createdRecords: any[];
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// State Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface AiAssistantState {
|
export interface AiAssistantState {
|
||||||
message: string;
|
message: string;
|
||||||
messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent
|
|
||||||
history?: AiChatMessage[];
|
history?: AiChatMessage[];
|
||||||
context: AiChatContext;
|
context: AiChatContext;
|
||||||
|
|
||||||
// Entity discovery
|
|
||||||
systemEntities?: SystemEntities;
|
|
||||||
|
|
||||||
// Planning
|
|
||||||
plan?: RecordCreationPlan;
|
|
||||||
|
|
||||||
// Legacy fields (kept for compatibility during transition)
|
|
||||||
objectDefinition?: any;
|
objectDefinition?: any;
|
||||||
pageLayout?: any;
|
pageLayout?: any;
|
||||||
extractedFields?: Record<string, any>;
|
extractedFields?: Record<string, any>;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
|
|||||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||||
import { VoiceModule } from './voice/voice.module';
|
import { VoiceModule } from './voice/voice.module';
|
||||||
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
||||||
|
import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from './task/task.module';
|
||||||
|
import { ApprovalModule } from './approval/approval.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +27,9 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
|||||||
PageLayoutModule,
|
PageLayoutModule,
|
||||||
VoiceModule,
|
VoiceModule,
|
||||||
AiAssistantModule,
|
AiAssistantModule,
|
||||||
|
ActivityLogModule,
|
||||||
|
TaskModule,
|
||||||
|
ApprovalModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
54
backend/src/approval/approval-request.controller.ts
Normal file
54
backend/src/approval/approval-request.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalRequestController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listRequests(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listRequests(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRequest(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any>;
|
||||||
|
snapshot?: Record<string, any>;
|
||||||
|
submittedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createRequest(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('assignments/:assignmentId')
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('assignmentId') assignmentId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
status: 'approved' | 'rejected';
|
||||||
|
response?: string;
|
||||||
|
actedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateAssignmentStatus(
|
||||||
|
tenantId,
|
||||||
|
assignmentId,
|
||||||
|
body.status,
|
||||||
|
body.response,
|
||||||
|
body.actedById,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/approval/approval-setup.controller.ts
Normal file
56
backend/src/approval/approval-setup.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('setup/approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalSetupController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listDefinitions(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listDefinitions(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createDefinition(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':definitionId')
|
||||||
|
async updateDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('definitionId') definitionId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType?: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateDefinition(tenantId, definitionId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/approval/approval.module.ts
Normal file
14
backend/src/approval/approval.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { ApprovalSetupController } from './approval-setup.controller';
|
||||||
|
import { ApprovalRequestController } from './approval-request.controller';
|
||||||
|
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from '../task/task.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule, ActivityLogModule, TaskModule],
|
||||||
|
controllers: [ApprovalSetupController, ApprovalRequestController],
|
||||||
|
providers: [ApprovalService],
|
||||||
|
})
|
||||||
|
export class ApprovalModule {}
|
||||||
402
backend/src/approval/approval.service.ts
Normal file
402
backend/src/approval/approval.service.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||||
|
import { TaskService } from '../task/task.service';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { ApprovalDefinition } from '../models/approval-definition.model';
|
||||||
|
import { ApprovalRequest } from '../models/approval-request.model';
|
||||||
|
import { ApprovalStep } from '../models/approval-step.model';
|
||||||
|
import { ApprovalAssignment } from '../models/approval-assignment.model';
|
||||||
|
import { ApprovalEffectLog } from '../models/approval-effect-log.model';
|
||||||
|
|
||||||
|
interface ApprovalDefinitionInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: Array<Record<string, any>> | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequestInput {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApprovalService {
|
||||||
|
constructor(
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
|
private activityLogService: ActivityLogService,
|
||||||
|
private taskService: TaskService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDefinitions(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalDefinition.query(knex).orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDefinition(tenantId: string, input: ApprovalDefinitionInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).insert({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? null,
|
||||||
|
steps: input.steps ?? null,
|
||||||
|
votingPolicy: input.votingPolicy ?? null,
|
||||||
|
rejectionRule: input.rejectionRule ?? null,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? null,
|
||||||
|
isActive: input.isActive ?? true,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.created',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Created approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDefinition(
|
||||||
|
tenantId: string,
|
||||||
|
definitionId: string,
|
||||||
|
input: Partial<ApprovalDefinitionInput>,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalDefinition.query(knex).findById(definitionId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsVersionBump =
|
||||||
|
input.entryCriteria !== undefined ||
|
||||||
|
input.steps !== undefined ||
|
||||||
|
input.votingPolicy !== undefined ||
|
||||||
|
input.rejectionRule !== undefined ||
|
||||||
|
input.materialChangePolicy !== undefined;
|
||||||
|
|
||||||
|
const definition = await ApprovalDefinition.query(knex).patchAndFetchById(definitionId, {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? undefined,
|
||||||
|
steps: input.steps ?? undefined,
|
||||||
|
votingPolicy: input.votingPolicy ?? undefined,
|
||||||
|
rejectionRule: input.rejectionRule ?? undefined,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? undefined,
|
||||||
|
isActive: input.isActive,
|
||||||
|
version: needsVersionBump ? existing.version + 1 : existing.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.updated',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Updated approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRequests(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalRequest.query(knex)
|
||||||
|
.withGraphFetched('[steps.assignments,definition]')
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(tenantId: string, input: ApprovalRequestInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).findById(input.definitionId);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionHash = this.createVersionHash({
|
||||||
|
snapshot: input.snapshot ?? {},
|
||||||
|
fieldChanges: input.fieldChanges ?? {},
|
||||||
|
definitionVersion: definition.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await ApprovalRequest.query(knex).insertAndFetch({
|
||||||
|
definitionId: definition.id,
|
||||||
|
status: 'pending',
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
action: input.action,
|
||||||
|
stateFrom: input.stateFrom,
|
||||||
|
stateTo: input.stateTo,
|
||||||
|
fieldChanges: input.fieldChanges ?? null,
|
||||||
|
snapshot: input.snapshot ?? null,
|
||||||
|
versionHash,
|
||||||
|
submittedById: input.submittedById ?? null,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepConfigs = Array.isArray(definition.steps) ? definition.steps : [];
|
||||||
|
for (const [index, stepConfig] of stepConfigs.entries()) {
|
||||||
|
const stepKey = stepConfig.key || `step-${index + 1}`;
|
||||||
|
const step = await ApprovalStep.query(knex).insertAndFetch({
|
||||||
|
requestId: request.id,
|
||||||
|
stepKey,
|
||||||
|
name: stepConfig.name || `Step ${index + 1}`,
|
||||||
|
stepOrder: stepConfig.order ?? index + 1,
|
||||||
|
status: 'pending',
|
||||||
|
routing: stepConfig.routing ?? null,
|
||||||
|
voting: stepConfig.voting ?? null,
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignees: string[] = Array.isArray(stepConfig.assignees)
|
||||||
|
? stepConfig.assignees
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const assigneeId of assignees) {
|
||||||
|
const assignment = await ApprovalAssignment.query(knex).insertAndFetch({
|
||||||
|
stepId: step.id,
|
||||||
|
assigneeId,
|
||||||
|
status: 'pending',
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.taskService.createTask(tenantId, {
|
||||||
|
title: `Approval needed: ${definition.name}`,
|
||||||
|
description: `Approval step ${step.name} requires your review.`,
|
||||||
|
dueAt: stepConfig.dueAt ?? undefined,
|
||||||
|
assignedToId: assigneeId,
|
||||||
|
relatedObjectType: 'ApprovalAssignment',
|
||||||
|
relatedObjectId: assignment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_assignment.created',
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignment.id,
|
||||||
|
description: `Assignment created for approval step ${step.name}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
requestId: request.id,
|
||||||
|
stepId: step.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_request.created',
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: request.id,
|
||||||
|
description: `Created approval request for ${input.targetObjectType}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
definitionId: definition.id,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
tenantId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
response?: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const assignment = await ApprovalAssignment.query(knex)
|
||||||
|
.findById(assignmentId)
|
||||||
|
.withGraphFetched('step.request');
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new NotFoundException('Approval assignment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAssignment = await ApprovalAssignment.query(knex).patchAndFetchById(
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
response,
|
||||||
|
respondedAt: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.taskService.updateTaskByRelated(
|
||||||
|
tenantId,
|
||||||
|
'ApprovalAssignment',
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_assignment.${status}`,
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignmentId,
|
||||||
|
description: `Assignment ${status}`,
|
||||||
|
causerId: actedById ?? assignment.assigneeId,
|
||||||
|
properties: {
|
||||||
|
stepId: assignment.stepId,
|
||||||
|
requestId: assignment.step?.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.evaluateStepCompletion(
|
||||||
|
tenantId,
|
||||||
|
assignment.stepId,
|
||||||
|
actedById ?? assignment.assigneeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateStepCompletion(tenantId: string, stepId: string, actedById?: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const step = await ApprovalStep.query(knex)
|
||||||
|
.findById(stepId)
|
||||||
|
.withGraphFetched('[assignments, request.[definition,steps]]');
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voting = (step.voting as Record<string, any>) || {};
|
||||||
|
const rule = voting.type || 'unanimous';
|
||||||
|
const rejectionRule = voting.rejectionRule || step.request?.definition?.rejectionRule || 'any';
|
||||||
|
|
||||||
|
const approvals = (step.assignments || []).filter((assignment) => assignment.status === 'approved');
|
||||||
|
const rejections = (step.assignments || []).filter((assignment) => assignment.status === 'rejected');
|
||||||
|
const totalAssignments = step.assignments?.length || 1;
|
||||||
|
|
||||||
|
if (rejections.length > 0 && rejectionRule === 'any') {
|
||||||
|
await this.completeStep(tenantId, step, 'rejected', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'rejected', actedById);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = this.checkApprovalRule(rule, approvals.length, totalAssignments, voting.threshold);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
await this.completeStep(tenantId, step, 'approved', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'approved', actedById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkApprovalRule(rule: string, approvals: number, total: number, threshold?: number) {
|
||||||
|
if (rule === 'majority') {
|
||||||
|
return approvals > total / 2;
|
||||||
|
}
|
||||||
|
if (rule === 'k-of-n') {
|
||||||
|
const required = threshold ?? total;
|
||||||
|
return approvals >= required;
|
||||||
|
}
|
||||||
|
return approvals === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeStep(
|
||||||
|
tenantId: string,
|
||||||
|
step: { id: string; status: string; requestId: string; name: string },
|
||||||
|
status: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
if (step.status === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApprovalStep.query(knex).patchAndFetchById(step.id, {
|
||||||
|
status,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_step.${status}`,
|
||||||
|
subjectType: 'ApprovalStep',
|
||||||
|
subjectId: step.id,
|
||||||
|
description: `Step ${step.name} marked ${status}`,
|
||||||
|
causerId: actedById,
|
||||||
|
properties: { requestId: step.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeRequestIfNeeded(
|
||||||
|
tenantId: string,
|
||||||
|
requestId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const request = await ApprovalRequest.query(knex)
|
||||||
|
.findById(requestId)
|
||||||
|
.withGraphFetched('steps');
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApproved = (request.steps || []).every((step) => step.status === 'approved');
|
||||||
|
if (status === 'rejected' || allApproved) {
|
||||||
|
const finalStatus = status === 'rejected' ? 'rejected' : 'approved';
|
||||||
|
await ApprovalRequest.query(knex).patchAndFetchById(requestId, {
|
||||||
|
status: finalStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_request.${finalStatus}`,
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: requestId,
|
||||||
|
description: `Request ${finalStatus}`,
|
||||||
|
causerId: actedById,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.logEffectExecution(tenantId, requestId, `on_${finalStatus}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logEffectExecution(tenantId: string, requestId: string, effectKey: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalEffectLog.query(knex)
|
||||||
|
.where({ requestId, effectKey })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApprovalEffectLog.query(knex).insert({
|
||||||
|
requestId,
|
||||||
|
effectKey,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVersionHash(payload: Record<string, any>) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/models/activity-log.model.ts
Normal file
14
backend/src/models/activity-log.model.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ActivityLog extends BaseModel {
|
||||||
|
static tableName = 'activity_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
action!: string;
|
||||||
|
subjectType!: string;
|
||||||
|
subjectId!: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
30
backend/src/models/approval-assignment.model.ts
Normal file
30
backend/src/models/approval-assignment.model.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
|
||||||
|
export class ApprovalAssignment extends BaseModel {
|
||||||
|
static tableName = 'approval_assignments';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
stepId!: string;
|
||||||
|
assigneeId!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: string | null;
|
||||||
|
respondedAt?: Date | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
reassignedFromId?: string | null;
|
||||||
|
delegatedById?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
step?: ApprovalStep;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
step: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_assignments.stepId',
|
||||||
|
to: 'approval_steps.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
31
backend/src/models/approval-definition.model.ts
Normal file
31
backend/src/models/approval-definition.model.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalDefinition extends BaseModel {
|
||||||
|
static tableName = 'approval_definitions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType!: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: any[] | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
version!: number;
|
||||||
|
isActive!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
requests: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_definitions.id',
|
||||||
|
to: 'approval_requests.definitionId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
backend/src/models/approval-effect-log.model.ts
Normal file
23
backend/src/models/approval-effect-log.model.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalEffectLog extends BaseModel {
|
||||||
|
static tableName = 'approval_effect_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
effectKey!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: Record<string, any> | null;
|
||||||
|
executedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_effect_logs.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
backend/src/models/approval-request.model.ts
Normal file
55
backend/src/models/approval-request.model.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalDefinition } from './approval-definition.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
import { ApprovalEffectLog } from './approval-effect-log.model';
|
||||||
|
|
||||||
|
export class ApprovalRequest extends BaseModel {
|
||||||
|
static tableName = 'approval_requests';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
definitionId!: string;
|
||||||
|
status!: string;
|
||||||
|
targetObjectType!: string;
|
||||||
|
targetObjectId!: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
versionHash?: string | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
definition?: ApprovalDefinition;
|
||||||
|
steps?: ApprovalStep[];
|
||||||
|
effectLogs?: ApprovalEffectLog[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
definition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-definition.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.definitionId',
|
||||||
|
to: 'approval_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_steps.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
effectLogs: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-effect-log.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_effect_logs.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
41
backend/src/models/approval-step.model.ts
Normal file
41
backend/src/models/approval-step.model.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalRequest } from './approval-request.model';
|
||||||
|
import { ApprovalAssignment } from './approval-assignment.model';
|
||||||
|
|
||||||
|
export class ApprovalStep extends BaseModel {
|
||||||
|
static tableName = 'approval_steps';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
stepKey!: string;
|
||||||
|
name!: string;
|
||||||
|
stepOrder!: number;
|
||||||
|
status!: string;
|
||||||
|
routing?: Record<string, any> | null;
|
||||||
|
voting?: Record<string, any> | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
request?: ApprovalRequest;
|
||||||
|
assignments?: ApprovalAssignment[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-assignment.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.id',
|
||||||
|
to: 'approval_assignments.stepId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
15
backend/src/models/task.model.ts
Normal file
15
backend/src/models/task.model.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Task extends BaseModel {
|
||||||
|
static tableName = 'tasks';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
title!: string;
|
||||||
|
description?: string;
|
||||||
|
status!: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: Date;
|
||||||
|
assignedToId?: string | null;
|
||||||
|
relatedObjectType?: string | null;
|
||||||
|
relatedObjectId?: string | null;
|
||||||
|
}
|
||||||
63
backend/src/task/task.controller.ts
Normal file
63
backend/src/task/task.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('tasks')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TaskController {
|
||||||
|
constructor(private taskService: TaskService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listTasks(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('assignedToId') assignedToId?: string,
|
||||||
|
@Query('relatedObjectType') relatedObjectType?: string,
|
||||||
|
@Query('relatedObjectId') relatedObjectId?: string,
|
||||||
|
) {
|
||||||
|
return this.taskService.listTasks(tenantId, {
|
||||||
|
status,
|
||||||
|
assignedToId,
|
||||||
|
relatedObjectType,
|
||||||
|
relatedObjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createTask(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.taskService.createTask(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':taskId')
|
||||||
|
async updateTask(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('taskId') taskId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.taskService.updateTask(tenantId, taskId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/task/task.module.ts
Normal file
12
backend/src/task/task.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TaskController } from './task.controller';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [TaskController],
|
||||||
|
providers: [TaskService],
|
||||||
|
exports: [TaskService],
|
||||||
|
})
|
||||||
|
export class TaskModule {}
|
||||||
91
backend/src/task/task.service.ts
Normal file
91
backend/src/task/task.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { Task } from '../models/task.model';
|
||||||
|
|
||||||
|
export interface TaskInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string | Date | null;
|
||||||
|
assignedToId?: string | null;
|
||||||
|
relatedObjectType?: string | null;
|
||||||
|
relatedObjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskService {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTasks(
|
||||||
|
tenantId: string,
|
||||||
|
filters: {
|
||||||
|
status?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex)
|
||||||
|
.modify((qb) => {
|
||||||
|
if (filters.status) qb.where('status', filters.status);
|
||||||
|
if (filters.assignedToId) qb.where('assignedToId', filters.assignedToId);
|
||||||
|
if (filters.relatedObjectType)
|
||||||
|
qb.where('relatedObjectType', filters.relatedObjectType);
|
||||||
|
if (filters.relatedObjectId) qb.where('relatedObjectId', filters.relatedObjectId);
|
||||||
|
})
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(tenantId: string, input: TaskInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex).insert({
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status ?? 'open',
|
||||||
|
priority: input.priority,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : null,
|
||||||
|
assignedToId: input.assignedToId ?? null,
|
||||||
|
relatedObjectType: input.relatedObjectType ?? null,
|
||||||
|
relatedObjectId: input.relatedObjectId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(tenantId: string, taskId: string, input: Partial<TaskInput>) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex).patchAndFetchById(taskId, {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status,
|
||||||
|
priority: input.priority,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||||
|
assignedToId: input.assignedToId ?? undefined,
|
||||||
|
relatedObjectType: input.relatedObjectType ?? undefined,
|
||||||
|
relatedObjectId: input.relatedObjectId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTaskByRelated(
|
||||||
|
tenantId: string,
|
||||||
|
relatedObjectType: string,
|
||||||
|
relatedObjectId: string,
|
||||||
|
input: Partial<TaskInput>,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex)
|
||||||
|
.patch({
|
||||||
|
status: input.status,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||||
|
})
|
||||||
|
.where({
|
||||||
|
relatedObjectType,
|
||||||
|
relatedObjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, Phone } from 'lucide-vue-next'
|
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, ClipboardCheck, ListChecks, ClipboardList, Phone } from 'lucide-vue-next'
|
||||||
import { useSoftphone } from '~/composables/useSoftphone'
|
import { useSoftphone } from '~/composables/useSoftphone'
|
||||||
|
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
@@ -100,6 +100,21 @@ const staticMenuItems = [
|
|||||||
url: '/',
|
url: '/',
|
||||||
icon: Home,
|
icon: Home,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Approvals',
|
||||||
|
url: '/approvals',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tasks',
|
||||||
|
url: '/tasks',
|
||||||
|
icon: ListChecks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Activity Log',
|
||||||
|
url: '/activity-log',
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Setup',
|
title: 'Setup',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
@@ -124,6 +139,11 @@ const staticMenuItems = [
|
|||||||
url: '/setup/roles',
|
url: '/setup/roles',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Approvals',
|
||||||
|
url: '/setup/approvals',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Integrations',
|
title: 'Integrations',
|
||||||
url: '/settings/integrations',
|
url: '/settings/integrations',
|
||||||
|
|||||||
70
frontend/pages/activity-log/index.vue
Normal file
70
frontend/pages/activity-log/index.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Activity Log</h1>
|
||||||
|
<button
|
||||||
|
@click="fetchActivities"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="activity in activities"
|
||||||
|
:key="activity.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold">{{ activity.action }}</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">{{ formatDate(activity.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">
|
||||||
|
{{ activity.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Subject: {{ activity.subjectType }} • {{ activity.subjectId }}</div>
|
||||||
|
<div>Causer: {{ activity.causerId || 'System' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const activities = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
activities.value = await api.get('/activity-log')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchActivities()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
232
frontend/pages/approvals/index.vue
Normal file
232
frontend/pages/approvals/index.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Approval Requests</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Approval Request</h2>
|
||||||
|
<form @submit.prevent="createRequest" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Definition</label>
|
||||||
|
<select
|
||||||
|
v-model="newRequest.definitionId"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a definition</option>
|
||||||
|
<option v-for="definition in definitions" :key="definition.id" :value="definition.id">
|
||||||
|
{{ definition.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.targetObjectType"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object ID</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.targetObjectId"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="record-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Action</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.action"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Submitted By (User ID)</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.submittedById"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="user-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Snapshot (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newRequest.snapshot"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": 1200, "currency": "USD"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Field Changes (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newRequest.fieldChanges"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": {"from": 500, "to": 1200}}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="request in requests"
|
||||||
|
:key="request.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">{{ request.definition?.name || 'Approval Request' }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ request.targetObjectType }} • {{ request.targetObjectId }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="statusClass(request.status)"
|
||||||
|
>
|
||||||
|
{{ request.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Steps: {{ request.steps?.length || 0 }}</div>
|
||||||
|
<div>Submitted: {{ formatDate(request.submittedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const requests = ref<any[]>([])
|
||||||
|
const definitions = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newRequest = ref({
|
||||||
|
definitionId: '',
|
||||||
|
targetObjectType: '',
|
||||||
|
targetObjectId: '',
|
||||||
|
action: '',
|
||||||
|
submittedById: '',
|
||||||
|
snapshot: '',
|
||||||
|
fieldChanges: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseJson = (value: string) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status: string) => {
|
||||||
|
if (status === 'approved') return 'bg-primary/10 text-primary'
|
||||||
|
if (status === 'rejected') return 'bg-destructive/10 text-destructive'
|
||||||
|
return 'bg-muted text-muted-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
requests.value = await api.get('/approvals')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDefinitions = async () => {
|
||||||
|
try {
|
||||||
|
definitions.value = await api.get('/setup/approvals')
|
||||||
|
} catch (e) {
|
||||||
|
definitions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRequest = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/approvals', {
|
||||||
|
definitionId: newRequest.value.definitionId,
|
||||||
|
targetObjectType: newRequest.value.targetObjectType,
|
||||||
|
targetObjectId: newRequest.value.targetObjectId,
|
||||||
|
action: newRequest.value.action || undefined,
|
||||||
|
submittedById: newRequest.value.submittedById || undefined,
|
||||||
|
snapshot: parseJson(newRequest.value.snapshot),
|
||||||
|
fieldChanges: parseJson(newRequest.value.fieldChanges),
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newRequest.value = {
|
||||||
|
definitionId: '',
|
||||||
|
targetObjectType: '',
|
||||||
|
targetObjectId: '',
|
||||||
|
action: '',
|
||||||
|
submittedById: '',
|
||||||
|
snapshot: '',
|
||||||
|
fieldChanges: '',
|
||||||
|
}
|
||||||
|
await fetchRequests()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating approval request: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchRequests(), fetchDefinitions()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
241
frontend/pages/setup/approvals/index.vue
Normal file
241
frontend/pages/setup/approvals/index.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Approval Definitions</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Approval Definition</h2>
|
||||||
|
<form @submit.prevent="createDefinition" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense Approval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Trigger Type</label>
|
||||||
|
<select
|
||||||
|
v-model="newDefinition.triggerType"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
>
|
||||||
|
<option value="action">Action</option>
|
||||||
|
<option value="state_transition">State Transition</option>
|
||||||
|
<option value="field_change">Field Change</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.targetObjectType"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Entry Criteria (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.entryCriteria"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": {"gte": 500}}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Steps (JSON Array)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.steps"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="4"
|
||||||
|
placeholder='[{"key":"manager","name":"Manager Review","assignees":[]}]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Voting Policy (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.votingPolicy"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"type":"majority"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Rejection Rule</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.rejectionRule"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="any"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Material Change Policy</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.materialChangePolicy"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="invalidate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="isActive" v-model="newDefinition.isActive" type="checkbox" />
|
||||||
|
<label for="isActive" class="text-sm">Active</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="definition in definitions"
|
||||||
|
:key="definition.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h3 class="text-xl font-semibold">{{ definition.name }}</h3>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="definition.isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ definition.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
{{ definition.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div><span class="text-muted-foreground">Trigger:</span> {{ definition.triggerType }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Target:</span>
|
||||||
|
{{ definition.targetObjectType || 'Any' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Version:</span> {{ definition.version }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const definitions = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newDefinition = ref({
|
||||||
|
name: '',
|
||||||
|
triggerType: 'action',
|
||||||
|
targetObjectType: '',
|
||||||
|
description: '',
|
||||||
|
entryCriteria: '',
|
||||||
|
steps: '',
|
||||||
|
votingPolicy: '',
|
||||||
|
rejectionRule: 'any',
|
||||||
|
materialChangePolicy: 'invalidate',
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseJson = (value: string) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDefinitions = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
definitions.value = await api.get('/setup/approvals')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDefinition = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/approvals', {
|
||||||
|
name: newDefinition.value.name,
|
||||||
|
triggerType: newDefinition.value.triggerType,
|
||||||
|
targetObjectType: newDefinition.value.targetObjectType || undefined,
|
||||||
|
description: newDefinition.value.description || undefined,
|
||||||
|
entryCriteria: parseJson(newDefinition.value.entryCriteria),
|
||||||
|
steps: parseJson(newDefinition.value.steps),
|
||||||
|
votingPolicy: parseJson(newDefinition.value.votingPolicy),
|
||||||
|
rejectionRule: newDefinition.value.rejectionRule || undefined,
|
||||||
|
materialChangePolicy: newDefinition.value.materialChangePolicy || undefined,
|
||||||
|
isActive: newDefinition.value.isActive,
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newDefinition.value = {
|
||||||
|
name: '',
|
||||||
|
triggerType: 'action',
|
||||||
|
targetObjectType: '',
|
||||||
|
description: '',
|
||||||
|
entryCriteria: '',
|
||||||
|
steps: '',
|
||||||
|
votingPolicy: '',
|
||||||
|
rejectionRule: 'any',
|
||||||
|
materialChangePolicy: 'invalidate',
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
await fetchDefinitions()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating approval definition: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDefinitions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
186
frontend/pages/tasks/index.vue
Normal file
186
frontend/pages/tasks/index.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Tasks</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Task</h2>
|
||||||
|
<form @submit.prevent="createTask" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Title</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Follow up on approval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newTask.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Assigned To (User ID)</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.assignedToId"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Due At</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.dueAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Related Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.relatedObjectType"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="ApprovalAssignment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Related Object ID</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.relatedObjectId"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold">{{ task.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="task.status === 'completed' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ task.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">
|
||||||
|
{{ task.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Assigned: {{ task.assignedToId || 'Unassigned' }}</div>
|
||||||
|
<div>Due: {{ formatDate(task.dueAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const tasks = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newTask = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToId: '',
|
||||||
|
dueAt: '',
|
||||||
|
relatedObjectType: '',
|
||||||
|
relatedObjectId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
tasks.value = await api.get('/tasks')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log('here');
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTask = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/tasks', {
|
||||||
|
title: newTask.value.title,
|
||||||
|
description: newTask.value.description || undefined,
|
||||||
|
assignedToId: newTask.value.assignedToId || undefined,
|
||||||
|
dueAt: newTask.value.dueAt || undefined,
|
||||||
|
relatedObjectType: newTask.value.relatedObjectType || undefined,
|
||||||
|
relatedObjectId: newTask.value.relatedObjectId || undefined,
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newTask.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToId: '',
|
||||||
|
dueAt: '',
|
||||||
|
relatedObjectType: '',
|
||||||
|
relatedObjectId: '',
|
||||||
|
}
|
||||||
|
await fetchTasks()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating task: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user