Compare commits

..

2 Commits

Author SHA1 Message Date
Francisco Gaona
cddbd09f0f WIP - scaffold approvals 2026-01-16 23:27:09 +01:00
Francisco Gaona
20fc90a3fb Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant 2026-01-16 18:01:26 +01:00
30 changed files with 2152 additions and 33 deletions

View File

@@ -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');
};

View 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);
}
}

View 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 {}

View 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');
}
}

View File

@@ -694,8 +694,6 @@ export class AiAssistantService {
type: field.type,
}));
console.log('fields:',fields);
const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString();

View File

@@ -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 {}

View 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,
);
}
}

View 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);
}
}

View 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 {}

View 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');
}
}

View 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;
}

View 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',
},
},
});
}

View 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',
},
},
});
}

View 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',
},
},
});
}

View 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',
},
},
});
}

View 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',
},
},
});
}

View 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;
}

View File

@@ -1285,9 +1285,22 @@ export class ObjectService {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
}
// Check if user can delete each record
const deletableIds: string[] = [];
const deniedIds: string[] = [];
for (const record of records) {
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex);
const canDelete = await this.authService.canPerformAction(
'delete',
objectDefModel,
record,
user,
knex,
);
if (canDelete) {
deletableIds.push(record.id);
} else {
deniedIds.push(record.id);
}
}
// Ensure model is registered
@@ -1295,14 +1308,23 @@ export class ObjectService {
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().whereIn('id', recordIds).delete();
if (deletableIds.length > 0) {
await boundModel.query().whereIn('id', deletableIds).delete();
}
// Remove from search index
await Promise.all(
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)),
deletableIds.map((id) =>
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
),
);
return { success: true, deleted: recordIds.length };
return {
success: true,
deleted: deletableIds.length,
deletedIds: deletableIds,
deniedIds,
};
}
private async indexRecord(

View 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);
}
}

View 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 {}

View 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,
});
}
}

View File

@@ -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',

View File

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot
v-bind="forwarded"
:class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
@@ -49,11 +50,13 @@ const emit = defineEmits<{
}>()
// State
const selectedRows = ref<Set<string>>(new Set())
const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed
const visibleFields = computed(() =>
@@ -94,27 +97,39 @@ const showLoadMore = computed(() => (
))
const allSelected = computed({
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
set: (val: boolean) => {
if (val) {
selectedRows.value = new Set(props.data.map(row => row.id))
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
} else {
selectedRows.value.clear()
selectedRowIds.value = []
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
return props.data.filter(row => selectedRows.value.has(row.id))
const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
}
const toggleRowSelection = (rowId: string) => {
if (selectedRows.value.has(rowId)) {
selectedRows.value.delete(rowId)
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
} else {
selectedRows.value.add(rowId)
nextSelection.delete(normalizedId)
}
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
@@ -136,6 +151,14 @@ const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows())
}
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, getSelectedRows())
}
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) {
@@ -157,6 +180,19 @@ watch(
}
}
)
watch(
() => props.data,
(rows) => {
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
if (nextSelection.length !== selectedRowIds.value.length) {
selectedRowIds.value = nextSelection
emit('row-select', getSelectedRows())
}
},
{ deep: true }
)
</script>
<template>
@@ -181,14 +217,24 @@ watch(
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRows.size > 0">
<template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRows.size }} selected
{{ selectedRowIds.length }} selected
</Badge>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
<div class="flex items-center gap-2">
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Delete
Run
</Button>
</div>
</template>
<!-- Custom Actions -->
@@ -222,7 +268,10 @@ watch(
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox v-model:checked="allSelected" />
<Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
</TableHead>
<TableHead
v-for="field in visibleFields"
@@ -263,8 +312,8 @@ watch(
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:checked="selectedRows.has(row.id)"
@update:checked="toggleRowSelection(row.id)"
:model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">

View File

@@ -330,9 +330,25 @@ export const useViewState = <T extends { id?: string }>(
loading.value = true
error.value = null
try {
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
if (useBulkEndpoint) {
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
return {
deletedIds,
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
}
}
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - ids.length)
return {
deletedIds: ids,
deniedIds: [],
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete records:', e)

View File

@@ -6,6 +6,14 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute()
const router = useRouter()
@@ -148,8 +156,14 @@ const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?
const searchQuery = ref('')
const searchSummary = ref('')
const searchLoading = ref(false)
const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref<any[]>([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition
const fetchObjectDefinition = async () => {
@@ -194,16 +208,42 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
}
const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
pendingDeleteRows.value = rows
deleteSummary.value = null
deleteDialogOpen.value = true
}
const resetDeleteDialog = () => {
deleteDialogOpen.value = false
deleteSubmitting.value = false
pendingDeleteRows.value = []
deleteSummary.value = null
}
const confirmDelete = async () => {
if (pendingDeleteRows.value.length === 0) {
resetDeleteDialog()
return
}
deleteSubmitting.value = true
try {
const ids = rows.map(r => r.id)
await deleteRecords(ids)
const ids = pendingDeleteRows.value.map(r => r.id)
const result = await deleteRecords(ids)
const deletedIds = result?.deletedIds ?? []
const deniedIds = result?.deniedIds ?? []
deleteSummary.value = { deletedIds, deniedIds }
if (deniedIds.length === 0) {
resetDeleteDialog()
if (view.value !== 'list') {
await router.push(`/${objectApiName.value.toLowerCase()}/`)
}
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
}
} finally {
deleteSubmitting.value = false
}
}
@@ -422,6 +462,46 @@ onMounted(async () => {
@back="handleBack"
/>
</div>
<Dialog v-model:open="deleteDialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Delete records</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div class="space-y-2 text-sm">
<p>
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
</p>
<p v-if="deleteSummary" class="text-muted-foreground">
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
</p>
<p v-if="deniedDeleteCount > 0" class="text-destructive">
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
</p>
<p v-if="!deleteSummary" class="text-muted-foreground">
Records you do not have permission to delete will be skipped.
</p>
</div>
<DialogFooter>
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
{{ deleteSummary ? 'Close' : 'Cancel' }}
</Button>
<Button
v-if="!deleteSummary"
variant="destructive"
@click="confirmDelete"
:disabled="deleteSubmitting"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</NuxtLayout>
</template>

View 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>

View 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>

View 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>

View 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>