Compare commits
2 Commits
codex/add-
...
approvals
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cddbd09f0f | ||
|
|
20fc90a3fb |
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { AiAssistantService } from './ai-assistant.service';
|
import { AiAssistantService } from './ai-assistant.service';
|
||||||
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
||||||
|
import { AiSearchRequestDto } from './dto/ai-search.dto';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -24,4 +25,17 @@ export class AiAssistantController {
|
|||||||
payload.context,
|
payload.context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('search')
|
||||||
|
async search(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Body() payload: AiSearchRequestDto,
|
||||||
|
) {
|
||||||
|
return this.aiAssistantService.searchRecords(
|
||||||
|
tenantId,
|
||||||
|
user.userId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
||||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
@@ -11,6 +11,30 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
|||||||
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
||||||
import { MeilisearchService } from '../search/meilisearch.service';
|
import { MeilisearchService } from '../search/meilisearch.service';
|
||||||
|
|
||||||
|
type AiSearchFilter = {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value?: any;
|
||||||
|
values?: any[];
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiSearchPlan = {
|
||||||
|
strategy: 'keyword' | 'query';
|
||||||
|
explanation: string;
|
||||||
|
keyword?: string | null;
|
||||||
|
filters?: AiSearchFilter[];
|
||||||
|
sort?: { field: string; direction: 'asc' | 'desc' } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiSearchPayload = {
|
||||||
|
objectApiName: string;
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiAssistantService {
|
export class AiAssistantService {
|
||||||
private readonly logger = new Logger(AiAssistantService.name);
|
private readonly logger = new Logger(AiAssistantService.name);
|
||||||
@@ -70,6 +94,108 @@ export class AiAssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchRecords(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: AiSearchPayload,
|
||||||
|
) {
|
||||||
|
const queryText = payload?.query?.trim();
|
||||||
|
if (!payload?.objectApiName || !queryText) {
|
||||||
|
throw new BadRequestException('objectApiName and query are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize tenant ID so Meilisearch index names align with indexed records
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
|
||||||
|
const objectDefinition = await this.objectService.getObjectDefinition(
|
||||||
|
resolvedTenantId,
|
||||||
|
payload.objectApiName,
|
||||||
|
);
|
||||||
|
if (!objectDefinition) {
|
||||||
|
throw new BadRequestException(`Object ${payload.objectApiName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1;
|
||||||
|
const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20;
|
||||||
|
|
||||||
|
const plan = await this.buildSearchPlan(
|
||||||
|
resolvedTenantId,
|
||||||
|
queryText,
|
||||||
|
objectDefinition,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('AI search plan:', plan);
|
||||||
|
|
||||||
|
if (plan.strategy === 'keyword') {
|
||||||
|
|
||||||
|
console.log('AI search plan (keyword):', plan);
|
||||||
|
|
||||||
|
const keyword = plan.keyword?.trim() || queryText;
|
||||||
|
if (this.meilisearchService.isEnabled()) {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const meiliResults = await this.meilisearchService.searchRecords(
|
||||||
|
resolvedTenantId,
|
||||||
|
payload.objectApiName,
|
||||||
|
keyword,
|
||||||
|
{ limit: pageSize, offset },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Meilisearch results:', meiliResults);
|
||||||
|
|
||||||
|
const ids = meiliResults.hits
|
||||||
|
.map((hit: any) => hit?.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const records = ids.length
|
||||||
|
? await this.objectService.searchRecordsByIds(
|
||||||
|
resolvedTenantId,
|
||||||
|
payload.objectApiName,
|
||||||
|
userId,
|
||||||
|
ids,
|
||||||
|
{ page, pageSize },
|
||||||
|
)
|
||||||
|
: { data: [], totalCount: 0, page, pageSize };
|
||||||
|
|
||||||
|
return {
|
||||||
|
...records,
|
||||||
|
totalCount: meiliResults.total ?? records.totalCount ?? 0,
|
||||||
|
strategy: plan.strategy,
|
||||||
|
explanation: plan.explanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = await this.objectService.searchRecordsByKeyword(
|
||||||
|
resolvedTenantId,
|
||||||
|
payload.objectApiName,
|
||||||
|
userId,
|
||||||
|
keyword,
|
||||||
|
{ page, pageSize },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
strategy: plan.strategy,
|
||||||
|
explanation: plan.explanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('AI search plan (query):', plan);
|
||||||
|
|
||||||
|
const filtered = await this.objectService.searchRecordsWithFilters(
|
||||||
|
resolvedTenantId,
|
||||||
|
payload.objectApiName,
|
||||||
|
userId,
|
||||||
|
plan.filters || [],
|
||||||
|
{ page, pageSize },
|
||||||
|
plan.sort || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filtered,
|
||||||
|
strategy: plan.strategy,
|
||||||
|
explanation: plan.explanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async runAssistantGraph(
|
private async runAssistantGraph(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -523,6 +649,110 @@ export class AiAssistantService {
|
|||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildSearchPlan(
|
||||||
|
tenantId: string,
|
||||||
|
message: string,
|
||||||
|
objectDefinition: any,
|
||||||
|
): Promise<AiSearchPlan> {
|
||||||
|
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
||||||
|
if (!openAiConfig) {
|
||||||
|
return this.buildSearchPlanFallback(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`AI search planning failed: ${error.message}`);
|
||||||
|
return this.buildSearchPlanFallback(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSearchPlanFallback(message: string): AiSearchPlan {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
return {
|
||||||
|
strategy: 'keyword',
|
||||||
|
keyword: trimmed,
|
||||||
|
explanation: `Searched records that matches the word: "${trimmed}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildSearchPlanWithAi(
|
||||||
|
openAiConfig: OpenAIConfig,
|
||||||
|
message: string,
|
||||||
|
objectDefinition: any,
|
||||||
|
): Promise<AiSearchPlan> {
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
apiKey: openAiConfig.apiKey,
|
||||||
|
model: this.normalizeChatModel(openAiConfig.model),
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parser = new JsonOutputParser<AiSearchPlan>();
|
||||||
|
const fields = (objectDefinition.fields || []).map((field: any) => ({
|
||||||
|
apiName: field.apiName,
|
||||||
|
label: field.label,
|
||||||
|
type: field.type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formatInstructions = parser.getFormatInstructions();
|
||||||
|
const today = new Date().toISOString();
|
||||||
|
|
||||||
|
const response = await model.invoke([
|
||||||
|
new SystemMessage(
|
||||||
|
`You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` +
|
||||||
|
`\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` +
|
||||||
|
`\n- strategy must be "keyword" or "query".` +
|
||||||
|
`\n- explanation must be a short sentence explaining the approach.` +
|
||||||
|
`\n- keyword should be the search term when strategy is "keyword", otherwise null.` +
|
||||||
|
`\n- filters is an array of {field, operator, value, values, from, to}.` +
|
||||||
|
`\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` +
|
||||||
|
`\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` +
|
||||||
|
`\n- sort should be {field, direction} when sorting is requested.` +
|
||||||
|
`\n- Only use field apiName values exactly as provided.` +
|
||||||
|
`\n${formatInstructions}`,
|
||||||
|
),
|
||||||
|
new HumanMessage(
|
||||||
|
`Object: ${objectDefinition.label || objectDefinition.apiName}.\n` +
|
||||||
|
`Fields: ${JSON.stringify(fields)}.\n` +
|
||||||
|
`Today is ${today}.\n` +
|
||||||
|
`User query: ${message}`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const content = typeof response.content === 'string' ? response.content : '{}';
|
||||||
|
const parsed = await parser.parse(content);
|
||||||
|
return this.normalizeSearchPlan(parsed, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
|
||||||
|
if (!plan || typeof plan !== 'object') {
|
||||||
|
return this.buildSearchPlanFallback(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = plan.strategy === 'query' ? 'query' : 'keyword';
|
||||||
|
const explanation = plan.explanation?.trim()
|
||||||
|
? plan.explanation.trim()
|
||||||
|
: strategy === 'keyword'
|
||||||
|
? `Searched records that matches the word: "${message.trim()}"`
|
||||||
|
: `Applied filters based on: "${message.trim()}"`;
|
||||||
|
|
||||||
|
if (strategy === 'keyword') {
|
||||||
|
return {
|
||||||
|
strategy,
|
||||||
|
keyword: plan.keyword?.trim() || message.trim(),
|
||||||
|
explanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
strategy,
|
||||||
|
explanation,
|
||||||
|
keyword: null,
|
||||||
|
filters: Array.isArray(plan.filters) ? plan.filters : [],
|
||||||
|
sort: plan.sort || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sanitizeUserOwnerFields(
|
private sanitizeUserOwnerFields(
|
||||||
fields: Record<string, any>,
|
fields: Record<string, any>,
|
||||||
fieldDefinitions: any[],
|
fieldDefinitions: any[],
|
||||||
@@ -899,12 +1129,18 @@ export class AiAssistantService {
|
|||||||
|
|
||||||
const providedValue = typeof value === 'string' ? value.trim() : value;
|
const providedValue = typeof value === 'string' ? value.trim() : value;
|
||||||
if (providedValue && typeof providedValue === 'string') {
|
if (providedValue && typeof providedValue === 'string') {
|
||||||
|
|
||||||
|
console.log('providedValue:', providedValue);
|
||||||
|
|
||||||
const meiliMatch = await this.meilisearchService.searchRecord(
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
||||||
resolvedTenantId,
|
resolvedTenantId,
|
||||||
targetDefinition?.apiName || targetApiName,
|
targetDefinition?.apiName || targetApiName,
|
||||||
providedValue,
|
providedValue,
|
||||||
displayField,
|
displayField,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('MeiliSearch lookup for', meiliMatch);
|
||||||
|
|
||||||
if (meiliMatch?.id) {
|
if (meiliMatch?.id) {
|
||||||
resolvedFields[field.apiName] = meiliMatch.id;
|
resolvedFields[field.apiName] = meiliMatch.id;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class AiSearchRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
objectApiName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
|
|||||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||||
import { VoiceModule } from './voice/voice.module';
|
import { VoiceModule } from './voice/voice.module';
|
||||||
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
||||||
|
import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from './task/task.module';
|
||||||
|
import { ApprovalModule } from './approval/approval.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +27,9 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
|||||||
PageLayoutModule,
|
PageLayoutModule,
|
||||||
VoiceModule,
|
VoiceModule,
|
||||||
AiAssistantModule,
|
AiAssistantModule,
|
||||||
|
ActivityLogModule,
|
||||||
|
TaskModule,
|
||||||
|
ApprovalModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
54
backend/src/approval/approval-request.controller.ts
Normal file
54
backend/src/approval/approval-request.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalRequestController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listRequests(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listRequests(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRequest(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any>;
|
||||||
|
snapshot?: Record<string, any>;
|
||||||
|
submittedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createRequest(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('assignments/:assignmentId')
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('assignmentId') assignmentId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
status: 'approved' | 'rejected';
|
||||||
|
response?: string;
|
||||||
|
actedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateAssignmentStatus(
|
||||||
|
tenantId,
|
||||||
|
assignmentId,
|
||||||
|
body.status,
|
||||||
|
body.response,
|
||||||
|
body.actedById,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/approval/approval-setup.controller.ts
Normal file
56
backend/src/approval/approval-setup.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('setup/approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalSetupController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listDefinitions(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listDefinitions(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createDefinition(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':definitionId')
|
||||||
|
async updateDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('definitionId') definitionId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType?: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateDefinition(tenantId, definitionId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/approval/approval.module.ts
Normal file
14
backend/src/approval/approval.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { ApprovalSetupController } from './approval-setup.controller';
|
||||||
|
import { ApprovalRequestController } from './approval-request.controller';
|
||||||
|
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from '../task/task.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule, ActivityLogModule, TaskModule],
|
||||||
|
controllers: [ApprovalSetupController, ApprovalRequestController],
|
||||||
|
providers: [ApprovalService],
|
||||||
|
})
|
||||||
|
export class ApprovalModule {}
|
||||||
402
backend/src/approval/approval.service.ts
Normal file
402
backend/src/approval/approval.service.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||||
|
import { TaskService } from '../task/task.service';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { ApprovalDefinition } from '../models/approval-definition.model';
|
||||||
|
import { ApprovalRequest } from '../models/approval-request.model';
|
||||||
|
import { ApprovalStep } from '../models/approval-step.model';
|
||||||
|
import { ApprovalAssignment } from '../models/approval-assignment.model';
|
||||||
|
import { ApprovalEffectLog } from '../models/approval-effect-log.model';
|
||||||
|
|
||||||
|
interface ApprovalDefinitionInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: Array<Record<string, any>> | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequestInput {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApprovalService {
|
||||||
|
constructor(
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
|
private activityLogService: ActivityLogService,
|
||||||
|
private taskService: TaskService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDefinitions(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalDefinition.query(knex).orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDefinition(tenantId: string, input: ApprovalDefinitionInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).insert({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? null,
|
||||||
|
steps: input.steps ?? null,
|
||||||
|
votingPolicy: input.votingPolicy ?? null,
|
||||||
|
rejectionRule: input.rejectionRule ?? null,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? null,
|
||||||
|
isActive: input.isActive ?? true,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.created',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Created approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDefinition(
|
||||||
|
tenantId: string,
|
||||||
|
definitionId: string,
|
||||||
|
input: Partial<ApprovalDefinitionInput>,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalDefinition.query(knex).findById(definitionId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsVersionBump =
|
||||||
|
input.entryCriteria !== undefined ||
|
||||||
|
input.steps !== undefined ||
|
||||||
|
input.votingPolicy !== undefined ||
|
||||||
|
input.rejectionRule !== undefined ||
|
||||||
|
input.materialChangePolicy !== undefined;
|
||||||
|
|
||||||
|
const definition = await ApprovalDefinition.query(knex).patchAndFetchById(definitionId, {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? undefined,
|
||||||
|
steps: input.steps ?? undefined,
|
||||||
|
votingPolicy: input.votingPolicy ?? undefined,
|
||||||
|
rejectionRule: input.rejectionRule ?? undefined,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? undefined,
|
||||||
|
isActive: input.isActive,
|
||||||
|
version: needsVersionBump ? existing.version + 1 : existing.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.updated',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Updated approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRequests(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalRequest.query(knex)
|
||||||
|
.withGraphFetched('[steps.assignments,definition]')
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(tenantId: string, input: ApprovalRequestInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).findById(input.definitionId);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionHash = this.createVersionHash({
|
||||||
|
snapshot: input.snapshot ?? {},
|
||||||
|
fieldChanges: input.fieldChanges ?? {},
|
||||||
|
definitionVersion: definition.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await ApprovalRequest.query(knex).insertAndFetch({
|
||||||
|
definitionId: definition.id,
|
||||||
|
status: 'pending',
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
action: input.action,
|
||||||
|
stateFrom: input.stateFrom,
|
||||||
|
stateTo: input.stateTo,
|
||||||
|
fieldChanges: input.fieldChanges ?? null,
|
||||||
|
snapshot: input.snapshot ?? null,
|
||||||
|
versionHash,
|
||||||
|
submittedById: input.submittedById ?? null,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepConfigs = Array.isArray(definition.steps) ? definition.steps : [];
|
||||||
|
for (const [index, stepConfig] of stepConfigs.entries()) {
|
||||||
|
const stepKey = stepConfig.key || `step-${index + 1}`;
|
||||||
|
const step = await ApprovalStep.query(knex).insertAndFetch({
|
||||||
|
requestId: request.id,
|
||||||
|
stepKey,
|
||||||
|
name: stepConfig.name || `Step ${index + 1}`,
|
||||||
|
stepOrder: stepConfig.order ?? index + 1,
|
||||||
|
status: 'pending',
|
||||||
|
routing: stepConfig.routing ?? null,
|
||||||
|
voting: stepConfig.voting ?? null,
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignees: string[] = Array.isArray(stepConfig.assignees)
|
||||||
|
? stepConfig.assignees
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const assigneeId of assignees) {
|
||||||
|
const assignment = await ApprovalAssignment.query(knex).insertAndFetch({
|
||||||
|
stepId: step.id,
|
||||||
|
assigneeId,
|
||||||
|
status: 'pending',
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.taskService.createTask(tenantId, {
|
||||||
|
title: `Approval needed: ${definition.name}`,
|
||||||
|
description: `Approval step ${step.name} requires your review.`,
|
||||||
|
dueAt: stepConfig.dueAt ?? undefined,
|
||||||
|
assignedToId: assigneeId,
|
||||||
|
relatedObjectType: 'ApprovalAssignment',
|
||||||
|
relatedObjectId: assignment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_assignment.created',
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignment.id,
|
||||||
|
description: `Assignment created for approval step ${step.name}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
requestId: request.id,
|
||||||
|
stepId: step.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_request.created',
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: request.id,
|
||||||
|
description: `Created approval request for ${input.targetObjectType}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
definitionId: definition.id,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
tenantId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
response?: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const assignment = await ApprovalAssignment.query(knex)
|
||||||
|
.findById(assignmentId)
|
||||||
|
.withGraphFetched('step.request');
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new NotFoundException('Approval assignment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAssignment = await ApprovalAssignment.query(knex).patchAndFetchById(
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
response,
|
||||||
|
respondedAt: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.taskService.updateTaskByRelated(
|
||||||
|
tenantId,
|
||||||
|
'ApprovalAssignment',
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_assignment.${status}`,
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignmentId,
|
||||||
|
description: `Assignment ${status}`,
|
||||||
|
causerId: actedById ?? assignment.assigneeId,
|
||||||
|
properties: {
|
||||||
|
stepId: assignment.stepId,
|
||||||
|
requestId: assignment.step?.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.evaluateStepCompletion(
|
||||||
|
tenantId,
|
||||||
|
assignment.stepId,
|
||||||
|
actedById ?? assignment.assigneeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateStepCompletion(tenantId: string, stepId: string, actedById?: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const step = await ApprovalStep.query(knex)
|
||||||
|
.findById(stepId)
|
||||||
|
.withGraphFetched('[assignments, request.[definition,steps]]');
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voting = (step.voting as Record<string, any>) || {};
|
||||||
|
const rule = voting.type || 'unanimous';
|
||||||
|
const rejectionRule = voting.rejectionRule || step.request?.definition?.rejectionRule || 'any';
|
||||||
|
|
||||||
|
const approvals = (step.assignments || []).filter((assignment) => assignment.status === 'approved');
|
||||||
|
const rejections = (step.assignments || []).filter((assignment) => assignment.status === 'rejected');
|
||||||
|
const totalAssignments = step.assignments?.length || 1;
|
||||||
|
|
||||||
|
if (rejections.length > 0 && rejectionRule === 'any') {
|
||||||
|
await this.completeStep(tenantId, step, 'rejected', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'rejected', actedById);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = this.checkApprovalRule(rule, approvals.length, totalAssignments, voting.threshold);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
await this.completeStep(tenantId, step, 'approved', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'approved', actedById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkApprovalRule(rule: string, approvals: number, total: number, threshold?: number) {
|
||||||
|
if (rule === 'majority') {
|
||||||
|
return approvals > total / 2;
|
||||||
|
}
|
||||||
|
if (rule === 'k-of-n') {
|
||||||
|
const required = threshold ?? total;
|
||||||
|
return approvals >= required;
|
||||||
|
}
|
||||||
|
return approvals === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeStep(
|
||||||
|
tenantId: string,
|
||||||
|
step: { id: string; status: string; requestId: string; name: string },
|
||||||
|
status: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
if (step.status === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApprovalStep.query(knex).patchAndFetchById(step.id, {
|
||||||
|
status,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_step.${status}`,
|
||||||
|
subjectType: 'ApprovalStep',
|
||||||
|
subjectId: step.id,
|
||||||
|
description: `Step ${step.name} marked ${status}`,
|
||||||
|
causerId: actedById,
|
||||||
|
properties: { requestId: step.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeRequestIfNeeded(
|
||||||
|
tenantId: string,
|
||||||
|
requestId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const request = await ApprovalRequest.query(knex)
|
||||||
|
.findById(requestId)
|
||||||
|
.withGraphFetched('steps');
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApproved = (request.steps || []).every((step) => step.status === 'approved');
|
||||||
|
if (status === 'rejected' || allApproved) {
|
||||||
|
const finalStatus = status === 'rejected' ? 'rejected' : 'approved';
|
||||||
|
await ApprovalRequest.query(knex).patchAndFetchById(requestId, {
|
||||||
|
status: finalStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_request.${finalStatus}`,
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: requestId,
|
||||||
|
description: `Request ${finalStatus}`,
|
||||||
|
causerId: actedById,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.logEffectExecution(tenantId, requestId, `on_${finalStatus}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logEffectExecution(tenantId: string, requestId: string, effectKey: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalEffectLog.query(knex)
|
||||||
|
.where({ requestId, effectKey })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApprovalEffectLog.query(knex).insert({
|
||||||
|
requestId,
|
||||||
|
effectKey,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVersionHash(payload: Record<string, any>) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/models/activity-log.model.ts
Normal file
14
backend/src/models/activity-log.model.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ActivityLog extends BaseModel {
|
||||||
|
static tableName = 'activity_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
action!: string;
|
||||||
|
subjectType!: string;
|
||||||
|
subjectId!: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
30
backend/src/models/approval-assignment.model.ts
Normal file
30
backend/src/models/approval-assignment.model.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
|
||||||
|
export class ApprovalAssignment extends BaseModel {
|
||||||
|
static tableName = 'approval_assignments';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
stepId!: string;
|
||||||
|
assigneeId!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: string | null;
|
||||||
|
respondedAt?: Date | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
reassignedFromId?: string | null;
|
||||||
|
delegatedById?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
step?: ApprovalStep;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
step: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_assignments.stepId',
|
||||||
|
to: 'approval_steps.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
31
backend/src/models/approval-definition.model.ts
Normal file
31
backend/src/models/approval-definition.model.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalDefinition extends BaseModel {
|
||||||
|
static tableName = 'approval_definitions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType!: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: any[] | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
version!: number;
|
||||||
|
isActive!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
requests: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_definitions.id',
|
||||||
|
to: 'approval_requests.definitionId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
backend/src/models/approval-effect-log.model.ts
Normal file
23
backend/src/models/approval-effect-log.model.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalEffectLog extends BaseModel {
|
||||||
|
static tableName = 'approval_effect_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
effectKey!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: Record<string, any> | null;
|
||||||
|
executedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_effect_logs.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
backend/src/models/approval-request.model.ts
Normal file
55
backend/src/models/approval-request.model.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalDefinition } from './approval-definition.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
import { ApprovalEffectLog } from './approval-effect-log.model';
|
||||||
|
|
||||||
|
export class ApprovalRequest extends BaseModel {
|
||||||
|
static tableName = 'approval_requests';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
definitionId!: string;
|
||||||
|
status!: string;
|
||||||
|
targetObjectType!: string;
|
||||||
|
targetObjectId!: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
versionHash?: string | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
definition?: ApprovalDefinition;
|
||||||
|
steps?: ApprovalStep[];
|
||||||
|
effectLogs?: ApprovalEffectLog[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
definition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-definition.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.definitionId',
|
||||||
|
to: 'approval_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_steps.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
effectLogs: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-effect-log.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_effect_logs.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
41
backend/src/models/approval-step.model.ts
Normal file
41
backend/src/models/approval-step.model.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalRequest } from './approval-request.model';
|
||||||
|
import { ApprovalAssignment } from './approval-assignment.model';
|
||||||
|
|
||||||
|
export class ApprovalStep extends BaseModel {
|
||||||
|
static tableName = 'approval_steps';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
stepKey!: string;
|
||||||
|
name!: string;
|
||||||
|
stepOrder!: number;
|
||||||
|
status!: string;
|
||||||
|
routing?: Record<string, any> | null;
|
||||||
|
voting?: Record<string, any> | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
request?: ApprovalRequest;
|
||||||
|
assignments?: ApprovalAssignment[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-assignment.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.id',
|
||||||
|
to: 'approval_assignments.stepId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,38 @@
|
|||||||
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
import { Model, ModelOptions, QueryContext } from 'objection';
|
||||||
|
|
||||||
export class BaseModel extends Model {
|
export class BaseModel extends Model {
|
||||||
static columnNameMappers = snakeCaseMappers();
|
/**
|
||||||
|
* Use a minimal column mapper: keep property names as-is, but handle
|
||||||
|
* timestamp fields that are stored as created_at/updated_at in the DB.
|
||||||
|
*/
|
||||||
|
static columnNameMappers = {
|
||||||
|
parse(dbRow: Record<string, any>) {
|
||||||
|
const mapped: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(dbRow || {})) {
|
||||||
|
if (key === 'created_at') {
|
||||||
|
mapped.createdAt = value;
|
||||||
|
} else if (key === 'updated_at') {
|
||||||
|
mapped.updatedAt = value;
|
||||||
|
} else {
|
||||||
|
mapped[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
},
|
||||||
|
format(model: Record<string, any>) {
|
||||||
|
const mapped: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(model || {})) {
|
||||||
|
if (key === 'createdAt') {
|
||||||
|
mapped.created_at = value;
|
||||||
|
} else if (key === 'updatedAt') {
|
||||||
|
mapped.updated_at = value;
|
||||||
|
} else {
|
||||||
|
mapped[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -179,7 +179,8 @@ export class DynamicModelFactory {
|
|||||||
* Convert a field definition to JSON schema property
|
* Convert a field definition to JSON schema property
|
||||||
*/
|
*/
|
||||||
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
|
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
|
||||||
switch (field.type.toUpperCase()) {
|
const baseSchema = () => {
|
||||||
|
switch (field.type.toUpperCase()) {
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
case 'STRING':
|
case 'STRING':
|
||||||
case 'EMAIL':
|
case 'EMAIL':
|
||||||
@@ -187,45 +188,57 @@ export class DynamicModelFactory {
|
|||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
case 'PICKLIST':
|
case 'PICKLIST':
|
||||||
case 'MULTI_PICKLIST':
|
case 'MULTI_PICKLIST':
|
||||||
return {
|
return {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
...(field.isUnique && { uniqueItems: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'LONG_TEXT':
|
case 'LONG_TEXT':
|
||||||
return { type: 'string' };
|
return { type: 'string' };
|
||||||
|
|
||||||
case 'NUMBER':
|
case 'NUMBER':
|
||||||
case 'DECIMAL':
|
case 'DECIMAL':
|
||||||
case 'CURRENCY':
|
case 'CURRENCY':
|
||||||
case 'PERCENT':
|
case 'PERCENT':
|
||||||
return {
|
return {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
...(field.isUnique && { uniqueItems: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'INTEGER':
|
case 'INTEGER':
|
||||||
return {
|
return {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
...(field.isUnique && { uniqueItems: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'BOOLEAN':
|
case 'BOOLEAN':
|
||||||
return { type: 'boolean', default: false };
|
return { type: 'boolean', default: false };
|
||||||
|
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
return { type: 'string', format: 'date' };
|
return { type: 'string', format: 'date' };
|
||||||
|
|
||||||
case 'DATE_TIME':
|
case 'DATE_TIME':
|
||||||
return { type: 'string', format: 'date-time' };
|
return { type: 'string', format: 'date-time' };
|
||||||
|
|
||||||
case 'LOOKUP':
|
case 'LOOKUP':
|
||||||
case 'BELONGS_TO':
|
case 'BELONGS_TO':
|
||||||
return { type: 'string' };
|
return { type: 'string' };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { type: 'string' };
|
return { type: 'string' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = baseSchema();
|
||||||
|
|
||||||
|
// Allow null for non-required fields so optional strings/numbers don't fail validation
|
||||||
|
if (!field.isRequired) {
|
||||||
|
return {
|
||||||
|
anyOf: [schema, { type: 'null' }],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ import { User } from '../models/user.model';
|
|||||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||||
import { MeilisearchService } from '../search/meilisearch.service';
|
import { MeilisearchService } from '../search/meilisearch.service';
|
||||||
|
|
||||||
|
type SearchFilter = {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value?: any;
|
||||||
|
values?: any[];
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchSort = {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchPagination = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ObjectService {
|
export class ObjectService {
|
||||||
private readonly logger = new Logger(ObjectService.name);
|
private readonly logger = new Logger(ObjectService.name);
|
||||||
@@ -62,8 +81,10 @@ export class ObjectService {
|
|||||||
.where({ objectDefinitionId: obj.id })
|
.where({ objectDefinitionId: obj.id })
|
||||||
.orderBy('label', 'asc');
|
.orderBy('label', 'asc');
|
||||||
|
|
||||||
// Normalize all fields to ensure system fields are properly marked
|
// Normalize all fields to ensure system fields are properly marked and add any missing system fields
|
||||||
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
|
const normalizedFields = this.addMissingSystemFields(
|
||||||
|
fields.map((field: any) => this.normalizeField(field)),
|
||||||
|
);
|
||||||
|
|
||||||
// Get app information if object belongs to an app
|
// Get app information if object belongs to an app
|
||||||
let app = null;
|
let app = null;
|
||||||
@@ -509,17 +530,14 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime endpoints - CRUD operations
|
private async buildAuthorizedQuery(
|
||||||
async getRecords(
|
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
filters?: any,
|
|
||||||
) {
|
) {
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Get user with roles and permissions
|
|
||||||
const user = await User.query(knex)
|
const user = await User.query(knex)
|
||||||
.findById(userId)
|
.findById(userId)
|
||||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||||
@@ -528,7 +546,6 @@ export class ObjectService {
|
|||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get object definition with authorization settings
|
|
||||||
const objectDefModel = await ObjectDefinition.query(knex)
|
const objectDefModel = await ObjectDefinition.query(knex)
|
||||||
.findOne({ apiName: objectApiName })
|
.findOne({ apiName: objectApiName })
|
||||||
.withGraphFetched('fields');
|
.withGraphFetched('fields');
|
||||||
@@ -537,20 +554,17 @@ export class ObjectService {
|
|||||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableName = this.getTableName(
|
// Normalize and enrich fields to include system fields for downstream permissions/search
|
||||||
objectDefModel.apiName,
|
const normalizedFields = this.addMissingSystemFields(
|
||||||
objectDefModel.label,
|
(objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
|
||||||
objectDefModel.pluralLabel,
|
|
||||||
);
|
);
|
||||||
|
objectDefModel.fields = normalizedFields;
|
||||||
|
|
||||||
// Ensure model is registered
|
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
||||||
|
|
||||||
// Use Objection model
|
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
let query = boundModel.query();
|
let query = boundModel.query();
|
||||||
|
|
||||||
// Apply authorization scope (modifies query in place)
|
|
||||||
await this.authService.applyScopeToQuery(
|
await this.authService.applyScopeToQuery(
|
||||||
query,
|
query,
|
||||||
objectDefModel,
|
objectDefModel,
|
||||||
@@ -559,15 +573,13 @@ export class ObjectService {
|
|||||||
knex,
|
knex,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build graph expression for lookup fields
|
const lookupFields = objectDefModel.fields?.filter((field) =>
|
||||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
field.type === 'LOOKUP' && field.referenceObject,
|
||||||
f.type === 'LOOKUP' && f.referenceObject
|
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
if (lookupFields.length > 0) {
|
if (lookupFields.length > 0) {
|
||||||
// Build relation expression - use singular lowercase for relation name
|
|
||||||
const relationExpression = lookupFields
|
const relationExpression = lookupFields
|
||||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
.map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
@@ -576,6 +588,175 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
objectDefModel,
|
||||||
|
user,
|
||||||
|
knex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySearchFilters(
|
||||||
|
query: any,
|
||||||
|
filters: SearchFilter[],
|
||||||
|
validFields: Set<string>,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(filters) || filters.length === 0) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
const field = filter?.field;
|
||||||
|
if (!field || !validFields.has(field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator = String(filter.operator || 'eq').toLowerCase();
|
||||||
|
const value = filter.value;
|
||||||
|
const values = Array.isArray(filter.values) ? filter.values : undefined;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
query.where(field, value);
|
||||||
|
break;
|
||||||
|
case 'neq':
|
||||||
|
query.whereNot(field, value);
|
||||||
|
break;
|
||||||
|
case 'gt':
|
||||||
|
query.where(field, '>', value);
|
||||||
|
break;
|
||||||
|
case 'gte':
|
||||||
|
query.where(field, '>=', value);
|
||||||
|
break;
|
||||||
|
case 'lt':
|
||||||
|
query.where(field, '<', value);
|
||||||
|
break;
|
||||||
|
case 'lte':
|
||||||
|
query.where(field, '<=', value);
|
||||||
|
break;
|
||||||
|
case 'contains':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'startswith':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'endswith':
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'in':
|
||||||
|
if (values?.length) {
|
||||||
|
query.whereIn(field, values);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
query.whereIn(field, value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notin':
|
||||||
|
if (values?.length) {
|
||||||
|
query.whereNotIn(field, values);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
query.whereNotIn(field, value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'isnull':
|
||||||
|
query.whereNull(field);
|
||||||
|
break;
|
||||||
|
case 'notnull':
|
||||||
|
query.whereNotNull(field);
|
||||||
|
break;
|
||||||
|
case 'between': {
|
||||||
|
const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined);
|
||||||
|
const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined);
|
||||||
|
if (from !== undefined && to !== undefined) {
|
||||||
|
query.whereBetween(field, [from, to]);
|
||||||
|
} else if (from !== undefined) {
|
||||||
|
query.where(field, '>=', from);
|
||||||
|
} else if (to !== undefined) {
|
||||||
|
query.where(field, '<=', to);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyKeywordFilter(
|
||||||
|
query: any,
|
||||||
|
keyword: string,
|
||||||
|
fields: string[],
|
||||||
|
) {
|
||||||
|
const trimmed = keyword?.trim();
|
||||||
|
if (!trimmed || fields.length === 0) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
query.where((builder: any) => {
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
for (const field of fields) {
|
||||||
|
builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeRecordQuery(
|
||||||
|
query: any,
|
||||||
|
objectDefModel: any,
|
||||||
|
user: any,
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1;
|
||||||
|
const parsedPageSize = Number.isFinite(Number(pagination?.pageSize))
|
||||||
|
? Number(pagination?.pageSize)
|
||||||
|
: 0;
|
||||||
|
const safePage = parsedPage > 0 ? parsedPage : 1;
|
||||||
|
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
|
||||||
|
const shouldPaginate = safePageSize > 0;
|
||||||
|
|
||||||
|
const totalCount = await query.clone().resultSize();
|
||||||
|
|
||||||
|
if (shouldPaginate) {
|
||||||
|
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await query.select('*');
|
||||||
|
const filteredRecords = await Promise.all(
|
||||||
|
records.map((record: any) =>
|
||||||
|
this.authService.filterReadableFields(record, objectDefModel.fields, user),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: filteredRecords,
|
||||||
|
totalCount,
|
||||||
|
page: shouldPaginate ? safePage : 1,
|
||||||
|
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime endpoints - CRUD operations
|
||||||
|
async getRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
filters?: any,
|
||||||
|
) {
|
||||||
|
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
// Extract pagination and sorting parameters from query string
|
// Extract pagination and sorting parameters from query string
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -603,36 +784,98 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortField) {
|
if (sortField) {
|
||||||
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc');
|
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
|
||||||
|
query = query.orderBy(sortField, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1;
|
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
|
||||||
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0;
|
}
|
||||||
const safePage = parsedPage > 0 ? parsedPage : 1;
|
|
||||||
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
|
|
||||||
const shouldPaginate = safePageSize > 0;
|
|
||||||
|
|
||||||
const totalCount = await query.clone().resultSize();
|
async searchRecordsByIds(
|
||||||
|
tenantId: string,
|
||||||
if (shouldPaginate) {
|
objectApiName: string,
|
||||||
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
|
userId: string,
|
||||||
|
recordIds: string[],
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
totalCount: 0,
|
||||||
|
page: pagination?.page ?? 1,
|
||||||
|
pageSize: pagination?.pageSize ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await query.select('*');
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
// Filter fields based on field-level permissions
|
objectApiName,
|
||||||
const filteredRecords = await Promise.all(
|
userId,
|
||||||
records.map(record =>
|
|
||||||
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
query.whereIn('id', recordIds);
|
||||||
data: filteredRecords,
|
const orderBindings: any[] = ['id'];
|
||||||
totalCount,
|
const cases = recordIds
|
||||||
page: shouldPaginate ? safePage : 1,
|
.map((id, index) => {
|
||||||
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
|
orderBindings.push(id, index);
|
||||||
};
|
return 'when ? then ?';
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (cases) {
|
||||||
|
query.orderByRaw(`case ?? ${cases} end`, orderBindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecordsWithFilters(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
filters: SearchFilter[],
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
sort?: SearchSort,
|
||||||
|
) {
|
||||||
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const validFields = new Set([
|
||||||
|
...(objectDefModel.fields?.map((field: any) => field.apiName) || []),
|
||||||
|
...this.getSystemFieldNames(),
|
||||||
|
]);
|
||||||
|
this.applySearchFilters(query, filters, validFields);
|
||||||
|
|
||||||
|
if (sort?.field && validFields.has(sort.field)) {
|
||||||
|
query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecordsByKeyword(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
userId: string,
|
||||||
|
keyword: string,
|
||||||
|
pagination?: SearchPagination,
|
||||||
|
) {
|
||||||
|
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const keywordFields = (objectDefModel.fields || [])
|
||||||
|
.filter((field: any) => this.isKeywordField(field.type))
|
||||||
|
.map((field: any) => field.apiName);
|
||||||
|
|
||||||
|
this.applyKeywordFilter(query, keyword, keywordFields);
|
||||||
|
|
||||||
|
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecord(
|
async getRecord(
|
||||||
@@ -936,7 +1179,8 @@ export class ObjectService {
|
|||||||
objectApiName,
|
objectApiName,
|
||||||
editableData,
|
editableData,
|
||||||
);
|
);
|
||||||
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
|
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
|
||||||
|
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
|
||||||
const record = await boundModel.query().where({ id: recordId }).first();
|
const record = await boundModel.query().where({ id: recordId }).first();
|
||||||
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
|
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
|
||||||
return record;
|
return record;
|
||||||
@@ -1041,9 +1285,22 @@ export class ObjectService {
|
|||||||
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
|
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) {
|
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
|
// Ensure model is registered
|
||||||
@@ -1051,14 +1308,23 @@ export class ObjectService {
|
|||||||
|
|
||||||
// Use Objection model
|
// Use Objection model
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
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
|
// Remove from search index
|
||||||
await Promise.all(
|
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(
|
private async indexRecord(
|
||||||
@@ -1073,12 +1339,32 @@ export class ObjectService {
|
|||||||
.map((field: any) => field.apiName)
|
.map((field: any) => field.apiName)
|
||||||
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
||||||
|
|
||||||
|
console.log('Indexing record', {
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
recordId: record.id,
|
||||||
|
fieldsToIndex,
|
||||||
|
});
|
||||||
|
|
||||||
await this.meilisearchService.upsertRecord(
|
await this.meilisearchService.upsertRecord(
|
||||||
tenantId,
|
tenantId,
|
||||||
objectApiName,
|
objectApiName,
|
||||||
record,
|
record,
|
||||||
fieldsToIndex,
|
fieldsToIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('Indexed record successfully');
|
||||||
|
|
||||||
|
|
||||||
|
const meiliResults = await this.meilisearchService.searchRecords(
|
||||||
|
tenantId,
|
||||||
|
objectApiName,
|
||||||
|
record.name,
|
||||||
|
{ limit: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Meilisearch results:', meiliResults);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeIndexedRecord(
|
private async removeIndexedRecord(
|
||||||
@@ -1091,15 +1377,48 @@ export class ObjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isSystemField(apiName: string): boolean {
|
private isSystemField(apiName: string): boolean {
|
||||||
|
return this.getSystemFieldNames().includes(apiName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSystemFieldNames(): string[] {
|
||||||
|
return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMissingSystemFields(fields: any[]): any[] {
|
||||||
|
const existing = new Map((fields || []).map((field) => [field.apiName, field]));
|
||||||
|
const systemDefaults = [
|
||||||
|
{ apiName: 'id', label: 'ID', type: 'STRING' },
|
||||||
|
{ apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' },
|
||||||
|
{ apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' },
|
||||||
|
{ apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged = [...fields];
|
||||||
|
for (const sysField of systemDefaults) {
|
||||||
|
if (!existing.has(sysField.apiName)) {
|
||||||
|
merged.push({
|
||||||
|
...sysField,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
isRequired: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isKeywordField(type: string | undefined): boolean {
|
||||||
|
const normalized = String(type || '').toUpperCase();
|
||||||
return [
|
return [
|
||||||
'id',
|
'STRING',
|
||||||
'ownerId',
|
'TEXT',
|
||||||
'created_at',
|
'EMAIL',
|
||||||
'updated_at',
|
'PHONE',
|
||||||
'createdAt',
|
'URL',
|
||||||
'updatedAt',
|
'RICH_TEXT',
|
||||||
'tenantId',
|
'TEXTAREA',
|
||||||
].includes(apiName);
|
].includes(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async normalizePolymorphicRelatedObject(
|
private async normalizePolymorphicRelatedObject(
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export class MeilisearchService {
|
|||||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
|
||||||
|
console.log('querying Meilisearch index:', { indexName, query, displayField });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.requestJson('POST', url, {
|
const response = await this.requestJson('POST', url, {
|
||||||
q: query,
|
q: query,
|
||||||
@@ -66,6 +68,48 @@ export class MeilisearchService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
query: string,
|
||||||
|
options?: { limit?: number; offset?: number },
|
||||||
|
): Promise<{ hits: any[]; total: number }> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config) return { hits: [], total: 0 };
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
|
||||||
|
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('POST', url, {
|
||||||
|
q: query,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}, this.buildHeaders(config));
|
||||||
|
|
||||||
|
console.log('Meilisearch response body:', response.body);
|
||||||
|
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch query failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
|
||||||
|
const total =
|
||||||
|
response.body?.estimatedTotalHits ??
|
||||||
|
response.body?.nbHits ??
|
||||||
|
hits.length;
|
||||||
|
return { hits, total };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch query failed: ${error.message}`);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async upsertRecord(
|
async upsertRecord(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Toaster } from 'vue-sonner'
|
import { Toaster } from 'vue-sonner'
|
||||||
import BottomDrawer from '@/components/BottomDrawer.vue'
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Toaster position="top-right" :duration="4000" richColors />
|
<Toaster position="top-right" :duration="4000" richColors />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
<BottomDrawer />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, Phone } from 'lucide-vue-next'
|
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, ClipboardCheck, ListChecks, ClipboardList, Phone } from 'lucide-vue-next'
|
||||||
import { useSoftphone } from '~/composables/useSoftphone'
|
import { useSoftphone } from '~/composables/useSoftphone'
|
||||||
|
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
@@ -100,6 +100,21 @@ const staticMenuItems = [
|
|||||||
url: '/',
|
url: '/',
|
||||||
icon: Home,
|
icon: Home,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Approvals',
|
||||||
|
url: '/approvals',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tasks',
|
||||||
|
url: '/tasks',
|
||||||
|
icon: ListChecks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Activity Log',
|
||||||
|
url: '/activity-log',
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Setup',
|
title: 'Setup',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
@@ -124,6 +139,11 @@ const staticMenuItems = [
|
|||||||
url: '/setup/roles',
|
url: '/setup/roles',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Approvals',
|
||||||
|
url: '/setup/approvals',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Integrations',
|
title: 'Integrations',
|
||||||
url: '/settings/integrations',
|
url: '/settings/integrations',
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { useSoftphone } from '~/composables/useSoftphone'
|
|||||||
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
|
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
|
||||||
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
|
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
|
||||||
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
|
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
|
||||||
|
const props = defineProps<{
|
||||||
|
bounds?: { left: number; width: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
const softphone = useSoftphone()
|
const softphone = useSoftphone()
|
||||||
|
|
||||||
@@ -190,9 +193,17 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2">
|
<div
|
||||||
|
class="pointer-events-none fixed bottom-0 z-30 flex justify-center px-2"
|
||||||
|
:style="{
|
||||||
|
left: props.bounds?.left ? `${props.bounds.left}px` : '0',
|
||||||
|
width: props.bounds?.width ? `${props.bounds.width}px` : '100vw',
|
||||||
|
right: props.bounds?.width ? 'auto' : '0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto w-full border border-border bg-background shadow-xl transition-all duration-200"
|
class="pointer-events-auto w-full border border-border bg-background transition-all duration-200"
|
||||||
|
:class="{ 'shadow-2xl': isDrawerOpen }"
|
||||||
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
|
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2">
|
<div class="grid grid-cols-3 items-center justify-between border-border px-2 py-2">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<CheckboxRoot
|
<CheckboxRoot
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
: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)"
|
props.class)"
|
||||||
>
|
>
|
||||||
<CheckboxIndicator class="grid place-content-center text-current">
|
<CheckboxIndicator class="grid place-content-center text-current">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||||
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
|
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
|
||||||
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
|
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
totalCount?: number
|
totalCount?: number
|
||||||
|
searchSummary?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -30,6 +32,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
loading: false,
|
loading: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
baseUrl: '/runtime/objects',
|
baseUrl: '/runtime/objects',
|
||||||
|
searchSummary: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -47,11 +50,13 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const selectedRows = ref<Set<string>>(new Set())
|
const normalizeId = (id: any) => String(id)
|
||||||
|
const selectedRowIds = ref<string[]>([])
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortField = ref<string>('')
|
const sortField = ref<string>('')
|
||||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
const bulkAction = ref('delete')
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const visibleFields = computed(() =>
|
const visibleFields = computed(() =>
|
||||||
@@ -92,27 +97,39 @@ const showLoadMore = computed(() => (
|
|||||||
))
|
))
|
||||||
|
|
||||||
const allSelected = 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) => {
|
set: (val: boolean) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
selectedRows.value = new Set(props.data.map(row => row.id))
|
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
|
||||||
} else {
|
} else {
|
||||||
selectedRows.value.clear()
|
selectedRowIds.value = []
|
||||||
}
|
}
|
||||||
emit('row-select', getSelectedRows())
|
emit('row-select', getSelectedRows())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const 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) => {
|
const toggleRowSelection = (rowId: string) => {
|
||||||
if (selectedRows.value.has(rowId)) {
|
const normalizedId = normalizeId(rowId)
|
||||||
selectedRows.value.delete(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 {
|
} else {
|
||||||
selectedRows.value.add(rowId)
|
nextSelection.delete(normalizedId)
|
||||||
}
|
}
|
||||||
|
selectedRowIds.value = Array.from(nextSelection)
|
||||||
emit('row-select', getSelectedRows())
|
emit('row-select', getSelectedRows())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +151,14 @@ const handleAction = (actionId: string) => {
|
|||||||
emit('action', actionId, getSelectedRows())
|
emit('action', actionId, getSelectedRows())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBulkAction = () => {
|
||||||
|
if (bulkAction.value === 'delete') {
|
||||||
|
emit('delete', getSelectedRows())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('action', bulkAction.value, getSelectedRows())
|
||||||
|
}
|
||||||
|
|
||||||
const goToPage = (page: number) => {
|
const goToPage = (page: number) => {
|
||||||
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
||||||
if (nextPage !== currentPage.value) {
|
if (nextPage !== currentPage.value) {
|
||||||
@@ -155,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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -172,18 +210,31 @@ watch(
|
|||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
|
||||||
|
{{ searchSummary }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Bulk Actions -->
|
<!-- Bulk Actions -->
|
||||||
<template v-if="selectedRows.size > 0">
|
<template v-if="selectedRowIds.length > 0">
|
||||||
<Badge variant="secondary" class="px-3 py-1">
|
<Badge variant="secondary" class="px-3 py-1">
|
||||||
{{ selectedRows.size }} selected
|
{{ selectedRowIds.length }} selected
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
|
<div class="flex items-center gap-2">
|
||||||
<Trash2 class="h-4 w-4 mr-2" />
|
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
|
||||||
Delete
|
<SelectTrigger class="h-8 w-[180px]">
|
||||||
</Button>
|
<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" />
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Custom Actions -->
|
<!-- Custom Actions -->
|
||||||
@@ -217,7 +268,10 @@ watch(
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead v-if="selectable" class="w-12">
|
<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>
|
||||||
<TableHead
|
<TableHead
|
||||||
v-for="field in visibleFields"
|
v-for="field in visibleFields"
|
||||||
@@ -258,8 +312,8 @@ watch(
|
|||||||
>
|
>
|
||||||
<TableCell v-if="selectable" @click.stop>
|
<TableCell v-if="selectable" @click.stop>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedRows.has(row.id)"
|
:model-value="selectedRowIds.includes(normalizeId(row.id))"
|
||||||
@update:checked="toggleRowSelection(row.id)"
|
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell v-for="field in visibleFields" :key="field.id">
|
<TableCell v-for="field in visibleFields" :key="field.id">
|
||||||
|
|||||||
@@ -330,9 +330,25 @@ export const useViewState = <T extends { id?: string }>(
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
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}`)))
|
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
|
||||||
records.value = records.value.filter(r => !ids.includes(r.id!))
|
records.value = records.value.filter(r => !ids.includes(r.id!))
|
||||||
totalCount.value = Math.max(0, totalCount.value - ids.length)
|
totalCount.value = Math.max(0, totalCount.value - ids.length)
|
||||||
|
return {
|
||||||
|
deletedIds: ids,
|
||||||
|
deniedIds: [],
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
console.error('Failed to delete records:', e)
|
console.error('Failed to delete records:', e)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import AppSidebar from '@/components/AppSidebar.vue'
|
import AppSidebar from '@/components/AppSidebar.vue'
|
||||||
import BottomDrawer from '@/components/BottomDrawer.vue'
|
import BottomDrawer from '@/components/BottomDrawer.vue'
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
|
const { breadcrumbs: customBreadcrumbs } = useBreadcrumbs()
|
||||||
|
const drawerBounds = useState('bottomDrawerBounds', () => ({ left: 0, width: 0 }))
|
||||||
|
const insetRef = ref<any>(null)
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
// If custom breadcrumbs are set by the page, use those
|
// If custom breadcrumbs are set by the page, use those
|
||||||
@@ -30,12 +33,47 @@ const breadcrumbs = computed(() => {
|
|||||||
isLast: index === paths.length - 1,
|
isLast: index === paths.length - 1,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resolveInsetEl = (): HTMLElement | null => {
|
||||||
|
const maybeComponent = insetRef.value as any
|
||||||
|
if (!maybeComponent) return null
|
||||||
|
return maybeComponent.$el ? maybeComponent.$el as HTMLElement : (maybeComponent as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBounds = () => {
|
||||||
|
const el = resolveInsetEl()
|
||||||
|
if (!el || typeof el.getBoundingClientRect !== 'function') return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
drawerBounds.value = {
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateBounds()
|
||||||
|
const el = resolveInsetEl()
|
||||||
|
if (el && 'ResizeObserver' in window) {
|
||||||
|
resizeObserver = new ResizeObserver(updateBounds)
|
||||||
|
resizeObserver.observe(el)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', updateBounds)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const el = resolveInsetEl()
|
||||||
|
if (resizeObserver && el) {
|
||||||
|
resizeObserver.unobserve(el)
|
||||||
|
}
|
||||||
|
resizeObserver = null
|
||||||
|
window.removeEventListener('resize', updateBounds)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset class="flex flex-col">
|
<SidebarInset ref="insetRef" class="relative flex flex-col">
|
||||||
<header
|
<header
|
||||||
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
|
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
|
||||||
>
|
>
|
||||||
@@ -73,7 +111,8 @@ const breadcrumbs = computed(() => {
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
|
||||||
|
<BottomDrawer :bounds="drawerBounds" />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
|
|||||||
import ListView from '@/components/views/ListView.vue'
|
import ListView from '@/components/views/ListView.vue'
|
||||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
import EditView from '@/components/views/EditViewEnhanced.vue'
|
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -145,6 +153,17 @@ const editConfig = computed(() => {
|
|||||||
|
|
||||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
||||||
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
||||||
|
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
|
// Fetch object definition
|
||||||
const fetchObjectDefinition = async () => {
|
const fetchObjectDefinition = async () => {
|
||||||
@@ -189,16 +208,42 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (rows: any[]) => {
|
const handleDelete = async (rows: any[]) => {
|
||||||
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
|
pendingDeleteRows.value = rows
|
||||||
try {
|
deleteSummary.value = null
|
||||||
const ids = rows.map(r => r.id)
|
deleteDialogOpen.value = true
|
||||||
await deleteRecords(ids)
|
}
|
||||||
|
|
||||||
|
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 = 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') {
|
if (view.value !== 'list') {
|
||||||
await router.push(`/${objectApiName.value.toLowerCase()}/`)
|
await router.push(`/${objectApiName.value.toLowerCase()}/`)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e.message || 'Failed to delete records'
|
|
||||||
}
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to delete records'
|
||||||
|
} finally {
|
||||||
|
deleteSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +280,36 @@ const loadListRecords = async (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchListRecords = async (
|
||||||
|
page = 1,
|
||||||
|
options?: { append?: boolean; pageSize?: number }
|
||||||
|
) => {
|
||||||
|
if (!isSearchActive.value) {
|
||||||
|
return initializeListRecords()
|
||||||
|
}
|
||||||
|
searchLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = options?.pageSize ?? listPageSize.value
|
||||||
|
const response = await api.post('/ai/search', {
|
||||||
|
objectApiName: objectApiName.value,
|
||||||
|
query: searchQuery.value.trim(),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
const data = response?.data ?? []
|
||||||
|
const total = response?.totalCount ?? data.length
|
||||||
|
records.value = options?.append ? [...records.value, ...data] : data
|
||||||
|
totalCount.value = total
|
||||||
|
searchSummary.value = response?.explanation || ''
|
||||||
|
return response
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to search records'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initializeListRecords = async () => {
|
const initializeListRecords = async () => {
|
||||||
const firstResult = await loadListRecords(1)
|
const firstResult = await loadListRecords(1)
|
||||||
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
||||||
@@ -247,6 +322,10 @@ const initializeListRecords = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = async (page: number, pageSize: number) => {
|
const handlePageChange = async (page: number, pageSize: number) => {
|
||||||
|
if (isSearchActive.value) {
|
||||||
|
await searchListRecords(page, { append: page > 1, pageSize })
|
||||||
|
return
|
||||||
|
}
|
||||||
const loadedPages = Math.ceil(records.value.length / pageSize)
|
const loadedPages = Math.ceil(records.value.length / pageSize)
|
||||||
if (page > loadedPages && totalCount.value > records.value.length) {
|
if (page > loadedPages && totalCount.value > records.value.length) {
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
await loadListRecords(page, { append: true, pageSize })
|
||||||
@@ -254,9 +333,24 @@ const handlePageChange = async (page: number, pageSize: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadMore = async (page: number, pageSize: number) => {
|
const handleLoadMore = async (page: number, pageSize: number) => {
|
||||||
|
if (isSearchActive.value) {
|
||||||
|
await searchListRecords(page, { append: true, pageSize })
|
||||||
|
return
|
||||||
|
}
|
||||||
await loadListRecords(page, { append: true, pageSize })
|
await loadListRecords(page, { append: true, pageSize })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
searchQuery.value = trimmed
|
||||||
|
if (!trimmed) {
|
||||||
|
searchSummary.value = ''
|
||||||
|
await initializeListRecords()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for route changes
|
// Watch for route changes
|
||||||
watch(() => route.params, async (newParams, oldParams) => {
|
watch(() => route.params, async (newParams, oldParams) => {
|
||||||
// Reset current record when navigating to 'new'
|
// Reset current record when navigating to 'new'
|
||||||
@@ -325,14 +419,16 @@ onMounted(async () => {
|
|||||||
v-else-if="view === 'list' && listConfig"
|
v-else-if="view === 'list' && listConfig"
|
||||||
:config="listConfig"
|
:config="listConfig"
|
||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading || searchLoading"
|
||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
|
:search-summary="searchSummary"
|
||||||
:base-url="`/runtime/objects`"
|
:base-url="`/runtime/objects`"
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
@search="handleSearch"
|
||||||
@page-change="handlePageChange"
|
@page-change="handlePageChange"
|
||||||
@load-more="handleLoadMore"
|
@load-more="handleLoadMore"
|
||||||
/>
|
/>
|
||||||
@@ -366,6 +462,46 @@ onMounted(async () => {
|
|||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
70
frontend/pages/activity-log/index.vue
Normal file
70
frontend/pages/activity-log/index.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Activity Log</h1>
|
||||||
|
<button
|
||||||
|
@click="fetchActivities"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="activity in activities"
|
||||||
|
:key="activity.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold">{{ activity.action }}</h3>
|
||||||
|
<span class="text-xs text-muted-foreground">{{ formatDate(activity.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">
|
||||||
|
{{ activity.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Subject: {{ activity.subjectType }} • {{ activity.subjectId }}</div>
|
||||||
|
<div>Causer: {{ activity.causerId || 'System' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const activities = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
activities.value = await api.get('/activity-log')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchActivities()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
232
frontend/pages/approvals/index.vue
Normal file
232
frontend/pages/approvals/index.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Approval Requests</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Approval Request</h2>
|
||||||
|
<form @submit.prevent="createRequest" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Definition</label>
|
||||||
|
<select
|
||||||
|
v-model="newRequest.definitionId"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a definition</option>
|
||||||
|
<option v-for="definition in definitions" :key="definition.id" :value="definition.id">
|
||||||
|
{{ definition.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.targetObjectType"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object ID</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.targetObjectId"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="record-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Action</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.action"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Submitted By (User ID)</label>
|
||||||
|
<input
|
||||||
|
v-model="newRequest.submittedById"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="user-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Snapshot (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newRequest.snapshot"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": 1200, "currency": "USD"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Field Changes (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newRequest.fieldChanges"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": {"from": 500, "to": 1200}}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="request in requests"
|
||||||
|
:key="request.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">{{ request.definition?.name || 'Approval Request' }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ request.targetObjectType }} • {{ request.targetObjectId }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="statusClass(request.status)"
|
||||||
|
>
|
||||||
|
{{ request.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Steps: {{ request.steps?.length || 0 }}</div>
|
||||||
|
<div>Submitted: {{ formatDate(request.submittedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const requests = ref<any[]>([])
|
||||||
|
const definitions = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newRequest = ref({
|
||||||
|
definitionId: '',
|
||||||
|
targetObjectType: '',
|
||||||
|
targetObjectId: '',
|
||||||
|
action: '',
|
||||||
|
submittedById: '',
|
||||||
|
snapshot: '',
|
||||||
|
fieldChanges: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseJson = (value: string) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status: string) => {
|
||||||
|
if (status === 'approved') return 'bg-primary/10 text-primary'
|
||||||
|
if (status === 'rejected') return 'bg-destructive/10 text-destructive'
|
||||||
|
return 'bg-muted text-muted-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
requests.value = await api.get('/approvals')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDefinitions = async () => {
|
||||||
|
try {
|
||||||
|
definitions.value = await api.get('/setup/approvals')
|
||||||
|
} catch (e) {
|
||||||
|
definitions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRequest = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/approvals', {
|
||||||
|
definitionId: newRequest.value.definitionId,
|
||||||
|
targetObjectType: newRequest.value.targetObjectType,
|
||||||
|
targetObjectId: newRequest.value.targetObjectId,
|
||||||
|
action: newRequest.value.action || undefined,
|
||||||
|
submittedById: newRequest.value.submittedById || undefined,
|
||||||
|
snapshot: parseJson(newRequest.value.snapshot),
|
||||||
|
fieldChanges: parseJson(newRequest.value.fieldChanges),
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newRequest.value = {
|
||||||
|
definitionId: '',
|
||||||
|
targetObjectType: '',
|
||||||
|
targetObjectId: '',
|
||||||
|
action: '',
|
||||||
|
submittedById: '',
|
||||||
|
snapshot: '',
|
||||||
|
fieldChanges: '',
|
||||||
|
}
|
||||||
|
await fetchRequests()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating approval request: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchRequests(), fetchDefinitions()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
241
frontend/pages/setup/approvals/index.vue
Normal file
241
frontend/pages/setup/approvals/index.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Approval Definitions</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Approval Definition</h2>
|
||||||
|
<form @submit.prevent="createDefinition" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense Approval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Trigger Type</label>
|
||||||
|
<select
|
||||||
|
v-model="newDefinition.triggerType"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
>
|
||||||
|
<option value="action">Action</option>
|
||||||
|
<option value="state_transition">State Transition</option>
|
||||||
|
<option value="field_change">Field Change</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.targetObjectType"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Expense"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Entry Criteria (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.entryCriteria"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"amount": {"gte": 500}}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Steps (JSON Array)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.steps"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="4"
|
||||||
|
placeholder='[{"key":"manager","name":"Manager Review","assignees":[]}]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Voting Policy (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newDefinition.votingPolicy"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background font-mono text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder='{"type":"majority"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Rejection Rule</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.rejectionRule"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="any"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Material Change Policy</label>
|
||||||
|
<input
|
||||||
|
v-model="newDefinition.materialChangePolicy"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="invalidate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="isActive" v-model="newDefinition.isActive" type="checkbox" />
|
||||||
|
<label for="isActive" class="text-sm">Active</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="definition in definitions"
|
||||||
|
:key="definition.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h3 class="text-xl font-semibold">{{ definition.name }}</h3>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="definition.isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ definition.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
{{ definition.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div><span class="text-muted-foreground">Trigger:</span> {{ definition.triggerType }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Target:</span>
|
||||||
|
{{ definition.targetObjectType || 'Any' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Version:</span> {{ definition.version }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const definitions = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newDefinition = ref({
|
||||||
|
name: '',
|
||||||
|
triggerType: 'action',
|
||||||
|
targetObjectType: '',
|
||||||
|
description: '',
|
||||||
|
entryCriteria: '',
|
||||||
|
steps: '',
|
||||||
|
votingPolicy: '',
|
||||||
|
rejectionRule: 'any',
|
||||||
|
materialChangePolicy: 'invalidate',
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseJson = (value: string) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDefinitions = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
definitions.value = await api.get('/setup/approvals')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDefinition = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/setup/approvals', {
|
||||||
|
name: newDefinition.value.name,
|
||||||
|
triggerType: newDefinition.value.triggerType,
|
||||||
|
targetObjectType: newDefinition.value.targetObjectType || undefined,
|
||||||
|
description: newDefinition.value.description || undefined,
|
||||||
|
entryCriteria: parseJson(newDefinition.value.entryCriteria),
|
||||||
|
steps: parseJson(newDefinition.value.steps),
|
||||||
|
votingPolicy: parseJson(newDefinition.value.votingPolicy),
|
||||||
|
rejectionRule: newDefinition.value.rejectionRule || undefined,
|
||||||
|
materialChangePolicy: newDefinition.value.materialChangePolicy || undefined,
|
||||||
|
isActive: newDefinition.value.isActive,
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newDefinition.value = {
|
||||||
|
name: '',
|
||||||
|
triggerType: 'action',
|
||||||
|
targetObjectType: '',
|
||||||
|
description: '',
|
||||||
|
entryCriteria: '',
|
||||||
|
steps: '',
|
||||||
|
votingPolicy: '',
|
||||||
|
rejectionRule: 'any',
|
||||||
|
materialChangePolicy: 'invalidate',
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
await fetchDefinitions()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating approval definition: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDefinitions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
186
frontend/pages/tasks/index.vue
Normal file
186
frontend/pages/tasks/index.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div v-if="loading" class="text-center py-12">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Tasks</h1>
|
||||||
|
<button
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Create Task</h2>
|
||||||
|
<form @submit.prevent="createTask" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Title</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="Follow up on approval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newTask.description"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Assigned To (User ID)</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.assignedToId"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Due At</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.dueAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Related Object Type</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.relatedObjectType"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
placeholder="ApprovalAssignment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Related Object ID</label>
|
||||||
|
<input
|
||||||
|
v-model="newTask.relatedObjectId"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showCreateForm = false"
|
||||||
|
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="p-6 border rounded-lg bg-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-semibold">{{ task.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="task.status === 'completed' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ task.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">
|
||||||
|
{{ task.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div>Assigned: {{ task.assignedToId || 'Unassigned' }}</div>
|
||||||
|
<div>Due: {{ formatDate(task.dueAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { api } = useApi()
|
||||||
|
|
||||||
|
const tasks = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
const newTask = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToId: '',
|
||||||
|
dueAt: '',
|
||||||
|
relatedObjectType: '',
|
||||||
|
relatedObjectId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
tasks.value = await api.get('/tasks')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log('here');
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTask = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/tasks', {
|
||||||
|
title: newTask.value.title,
|
||||||
|
description: newTask.value.description || undefined,
|
||||||
|
assignedToId: newTask.value.assignedToId || undefined,
|
||||||
|
dueAt: newTask.value.dueAt || undefined,
|
||||||
|
relatedObjectType: newTask.value.relatedObjectType || undefined,
|
||||||
|
relatedObjectId: newTask.value.relatedObjectId || undefined,
|
||||||
|
})
|
||||||
|
showCreateForm.value = false
|
||||||
|
newTask.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToId: '',
|
||||||
|
dueAt: '',
|
||||||
|
relatedObjectType: '',
|
||||||
|
relatedObjectId: '',
|
||||||
|
}
|
||||||
|
await fetchTasks()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Error creating task: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user