diff --git a/backend/migrations/tenant/20260301120000_add_approval_engine_tables.js b/backend/migrations/tenant/20260301120000_add_approval_engine_tables.js new file mode 100644 index 0000000..cd409be --- /dev/null +++ b/backend/migrations/tenant/20260301120000_add_approval_engine_tables.js @@ -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'); +}; diff --git a/backend/src/activity-log/activity-log.controller.ts b/backend/src/activity-log/activity-log.controller.ts new file mode 100644 index 0000000..1e9bf06 --- /dev/null +++ b/backend/src/activity-log/activity-log.controller.ts @@ -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; + causerId?: string; + }, + ) { + return this.activityLogService.logActivity(tenantId, body); + } +} diff --git a/backend/src/activity-log/activity-log.module.ts b/backend/src/activity-log/activity-log.module.ts new file mode 100644 index 0000000..8052189 --- /dev/null +++ b/backend/src/activity-log/activity-log.module.ts @@ -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 {} diff --git a/backend/src/activity-log/activity-log.service.ts b/backend/src/activity-log/activity-log.service.ts new file mode 100644 index 0000000..05591d1 --- /dev/null +++ b/backend/src/activity-log/activity-log.service.ts @@ -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; + 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'); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 00dcef8..8b8a0fa 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,9 @@ import { AppBuilderModule } from './app-builder/app-builder.module'; import { PageLayoutModule } from './page-layout/page-layout.module'; import { VoiceModule } from './voice/voice.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({ imports: [ @@ -24,6 +27,9 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; PageLayoutModule, VoiceModule, AiAssistantModule, + ActivityLogModule, + TaskModule, + ApprovalModule, ], }) export class AppModule {} diff --git a/backend/src/approval/approval-request.controller.ts b/backend/src/approval/approval-request.controller.ts new file mode 100644 index 0000000..901aee5 --- /dev/null +++ b/backend/src/approval/approval-request.controller.ts @@ -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; + snapshot?: Record; + 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, + ); + } +} diff --git a/backend/src/approval/approval-setup.controller.ts b/backend/src/approval/approval-setup.controller.ts new file mode 100644 index 0000000..0d4771f --- /dev/null +++ b/backend/src/approval/approval-setup.controller.ts @@ -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; + steps?: Array>; + votingPolicy?: Record; + 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; + steps?: Array>; + votingPolicy?: Record; + rejectionRule?: string; + materialChangePolicy?: string; + isActive?: boolean; + }, + ) { + return this.approvalService.updateDefinition(tenantId, definitionId, body); + } +} diff --git a/backend/src/approval/approval.module.ts b/backend/src/approval/approval.module.ts new file mode 100644 index 0000000..e722098 --- /dev/null +++ b/backend/src/approval/approval.module.ts @@ -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 {} diff --git a/backend/src/approval/approval.service.ts b/backend/src/approval/approval.service.ts new file mode 100644 index 0000000..7a0a6da --- /dev/null +++ b/backend/src/approval/approval.service.ts @@ -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 | null; + steps?: Array> | null; + votingPolicy?: Record | 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 | null; + snapshot?: Record | 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, + ) { + 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) || {}; + 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) { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + } +} diff --git a/backend/src/models/activity-log.model.ts b/backend/src/models/activity-log.model.ts new file mode 100644 index 0000000..b15b66b --- /dev/null +++ b/backend/src/models/activity-log.model.ts @@ -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; + causerId?: string | null; + createdAt!: Date; +} diff --git a/backend/src/models/approval-assignment.model.ts b/backend/src/models/approval-assignment.model.ts new file mode 100644 index 0000000..51807f3 --- /dev/null +++ b/backend/src/models/approval-assignment.model.ts @@ -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', + }, + }, + }); +} diff --git a/backend/src/models/approval-definition.model.ts b/backend/src/models/approval-definition.model.ts new file mode 100644 index 0000000..b992c52 --- /dev/null +++ b/backend/src/models/approval-definition.model.ts @@ -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 | null; + steps?: any[] | null; + votingPolicy?: Record | 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', + }, + }, + }); +} diff --git a/backend/src/models/approval-effect-log.model.ts b/backend/src/models/approval-effect-log.model.ts new file mode 100644 index 0000000..e7eb369 --- /dev/null +++ b/backend/src/models/approval-effect-log.model.ts @@ -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 | null; + executedAt!: Date; + + static relationMappings = () => ({ + request: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'approval-request.model', + join: { + from: 'approval_effect_logs.requestId', + to: 'approval_requests.id', + }, + }, + }); +} diff --git a/backend/src/models/approval-request.model.ts b/backend/src/models/approval-request.model.ts new file mode 100644 index 0000000..7e8158d --- /dev/null +++ b/backend/src/models/approval-request.model.ts @@ -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 | null; + snapshot?: Record | 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', + }, + }, + }); +} diff --git a/backend/src/models/approval-step.model.ts b/backend/src/models/approval-step.model.ts new file mode 100644 index 0000000..0184f0c --- /dev/null +++ b/backend/src/models/approval-step.model.ts @@ -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 | null; + voting?: Record | 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', + }, + }, + }); +} diff --git a/backend/src/models/task.model.ts b/backend/src/models/task.model.ts new file mode 100644 index 0000000..3c0f92d --- /dev/null +++ b/backend/src/models/task.model.ts @@ -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; +} diff --git a/backend/src/task/task.controller.ts b/backend/src/task/task.controller.ts new file mode 100644 index 0000000..f4d836a --- /dev/null +++ b/backend/src/task/task.controller.ts @@ -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); + } +} diff --git a/backend/src/task/task.module.ts b/backend/src/task/task.module.ts new file mode 100644 index 0000000..81cb888 --- /dev/null +++ b/backend/src/task/task.module.ts @@ -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 {} diff --git a/backend/src/task/task.service.ts b/backend/src/task/task.service.ts new file mode 100644 index 0000000..6a59ce3 --- /dev/null +++ b/backend/src/task/task.service.ts @@ -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) { + 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, + ) { + 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, + }); + } +} diff --git a/frontend/components/AppSidebar.vue b/frontend/components/AppSidebar.vue index 7a8774d..e05f6ad 100644 --- a/frontend/components/AppSidebar.vue +++ b/frontend/components/AppSidebar.vue @@ -17,7 +17,7 @@ import { SidebarRail, } from '@/components/ui/sidebar' 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' const { logout } = useAuth() @@ -100,6 +100,21 @@ const staticMenuItems = [ url: '/', icon: Home, }, + { + title: 'Approvals', + url: '/approvals', + icon: ClipboardCheck, + }, + { + title: 'Tasks', + url: '/tasks', + icon: ListChecks, + }, + { + title: 'Activity Log', + url: '/activity-log', + icon: ClipboardList, + }, { title: 'Setup', icon: Settings, @@ -124,6 +139,11 @@ const staticMenuItems = [ url: '/setup/roles', icon: Layers, }, + { + title: 'Approvals', + url: '/setup/approvals', + icon: ClipboardCheck, + }, { title: 'Integrations', url: '/settings/integrations', diff --git a/frontend/pages/activity-log/index.vue b/frontend/pages/activity-log/index.vue new file mode 100644 index 0000000..32a7955 --- /dev/null +++ b/frontend/pages/activity-log/index.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/pages/approvals/index.vue b/frontend/pages/approvals/index.vue new file mode 100644 index 0000000..c0c589b --- /dev/null +++ b/frontend/pages/approvals/index.vue @@ -0,0 +1,232 @@ +