WIP - scaffold approvals
This commit is contained in:
@@ -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');
|
||||
};
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user