Compare commits
19 Commits
list-view-
...
47fa72451d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fa72451d | ||
|
|
730fddd181 | ||
|
|
a62f68fc10 | ||
|
|
d2b3fce4eb | ||
|
|
ca11c8cbe7 | ||
|
|
f8a3cffb64 | ||
|
|
852c4e28d2 | ||
|
|
2075fec183 | ||
|
|
9be98e4a09 | ||
|
|
43cae4289b | ||
|
|
4de9203fd5 | ||
|
|
8b9fa81594 | ||
|
|
a75b41fd0b | ||
|
|
7ae36411db | ||
|
|
c9a3e00a94 | ||
|
|
8ad3fac1b0 | ||
|
|
b34da6956c | ||
|
|
6c73eb1658 | ||
|
|
8e4690c9c9 |
@@ -4,7 +4,6 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
import { AiAssistantService } from './ai-assistant.service';
|
||||
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
||||
import { AiSearchRequestDto } from './dto/ai-search.dto';
|
||||
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -25,17 +24,4 @@ export class AiAssistantController {
|
||||
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 { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
@@ -11,30 +11,6 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
||||
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
|
||||
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()
|
||||
export class AiAssistantService {
|
||||
private readonly logger = new Logger(AiAssistantService.name);
|
||||
@@ -94,108 +70,6 @@ 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(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
@@ -649,110 +523,6 @@ export class AiAssistantService {
|
||||
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(
|
||||
fields: Record<string, any>,
|
||||
fieldDefinitions: any[],
|
||||
@@ -1129,18 +899,12 @@ export class AiAssistantService {
|
||||
|
||||
const providedValue = typeof value === 'string' ? value.trim() : value;
|
||||
if (providedValue && typeof providedValue === 'string') {
|
||||
|
||||
console.log('providedValue:', providedValue);
|
||||
|
||||
const meiliMatch = await this.meilisearchService.searchRecord(
|
||||
resolvedTenantId,
|
||||
targetDefinition?.apiName || targetApiName,
|
||||
providedValue,
|
||||
displayField,
|
||||
);
|
||||
|
||||
console.log('MeiliSearch lookup for', meiliMatch);
|
||||
|
||||
if (meiliMatch?.id) {
|
||||
resolvedFields[field.apiName] = meiliMatch.id;
|
||||
continue;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,38 +1,7 @@
|
||||
import { Model, ModelOptions, QueryContext } from 'objection';
|
||||
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
||||
|
||||
export class BaseModel extends Model {
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
static columnNameMappers = snakeCaseMappers();
|
||||
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -179,8 +179,7 @@ export class DynamicModelFactory {
|
||||
* Convert a field definition to JSON schema property
|
||||
*/
|
||||
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
|
||||
const baseSchema = () => {
|
||||
switch (field.type.toUpperCase()) {
|
||||
switch (field.type.toUpperCase()) {
|
||||
case 'TEXT':
|
||||
case 'STRING':
|
||||
case 'EMAIL':
|
||||
@@ -188,57 +187,45 @@ export class DynamicModelFactory {
|
||||
case 'PHONE':
|
||||
case 'PICKLIST':
|
||||
case 'MULTI_PICKLIST':
|
||||
return {
|
||||
type: 'string',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
return {
|
||||
type: 'string',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'LONG_TEXT':
|
||||
return { type: 'string' };
|
||||
return { type: 'string' };
|
||||
|
||||
case 'NUMBER':
|
||||
case 'DECIMAL':
|
||||
case 'CURRENCY':
|
||||
case 'PERCENT':
|
||||
return {
|
||||
type: 'number',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
return {
|
||||
type: 'number',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'INTEGER':
|
||||
return {
|
||||
type: 'integer',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
return {
|
||||
type: 'integer',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'BOOLEAN':
|
||||
return { type: 'boolean', default: false };
|
||||
return { type: 'boolean', default: false };
|
||||
|
||||
case 'DATE':
|
||||
return { type: 'string', format: 'date' };
|
||||
return { type: 'string', format: 'date' };
|
||||
|
||||
case 'DATE_TIME':
|
||||
return { type: 'string', format: 'date-time' };
|
||||
return { type: 'string', format: 'date-time' };
|
||||
|
||||
case 'LOOKUP':
|
||||
case 'BELONGS_TO':
|
||||
return { type: 'string' };
|
||||
return { type: 'string' };
|
||||
|
||||
default:
|
||||
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 { type: 'string' };
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,25 +10,6 @@ import { User } from '../models/user.model';
|
||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||
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()
|
||||
export class ObjectService {
|
||||
private readonly logger = new Logger(ObjectService.name);
|
||||
@@ -81,10 +62,8 @@ export class ObjectService {
|
||||
.where({ objectDefinitionId: obj.id })
|
||||
.orderBy('label', 'asc');
|
||||
|
||||
// Normalize all fields to ensure system fields are properly marked and add any missing system fields
|
||||
const normalizedFields = this.addMissingSystemFields(
|
||||
fields.map((field: any) => this.normalizeField(field)),
|
||||
);
|
||||
// Normalize all fields to ensure system fields are properly marked
|
||||
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
|
||||
|
||||
// Get app information if object belongs to an app
|
||||
let app = null;
|
||||
@@ -530,220 +509,6 @@ export class ObjectService {
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAuthorizedQuery(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
userId: string,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName })
|
||||
.withGraphFetched('fields');
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
// Normalize and enrich fields to include system fields for downstream permissions/search
|
||||
const normalizedFields = this.addMissingSystemFields(
|
||||
(objectDefModel.fields || []).map((field: any) => this.normalizeField(field)),
|
||||
);
|
||||
objectDefModel.fields = normalizedFields;
|
||||
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
||||
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let query = boundModel.query();
|
||||
|
||||
await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDefModel,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
const lookupFields = objectDefModel.fields?.filter((field) =>
|
||||
field.type === 'LOOKUP' && field.referenceObject,
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
const relationExpression = lookupFields
|
||||
.map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (relationExpression) {
|
||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -751,12 +516,66 @@ export class ObjectService {
|
||||
userId: string,
|
||||
filters?: any,
|
||||
) {
|
||||
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
userId,
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName })
|
||||
.withGraphFetched('fields');
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(
|
||||
objectDefModel.apiName,
|
||||
objectDefModel.label,
|
||||
objectDefModel.pluralLabel,
|
||||
);
|
||||
|
||||
// Ensure model is registered
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
|
||||
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let query = boundModel.query();
|
||||
|
||||
// Apply authorization scope (modifies query in place)
|
||||
await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDefModel,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
const relationExpression = lookupFields
|
||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (relationExpression) {
|
||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract pagination and sorting parameters from query string
|
||||
const {
|
||||
page,
|
||||
@@ -784,98 +603,36 @@ export class ObjectService {
|
||||
}
|
||||
|
||||
if (sortField) {
|
||||
const direction = sortDirection === 'desc' ? 'desc' : 'asc';
|
||||
query = query.orderBy(sortField, direction);
|
||||
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
|
||||
}
|
||||
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1;
|
||||
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;
|
||||
|
||||
async searchRecordsByIds(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
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 totalCount = await query.clone().resultSize();
|
||||
|
||||
if (shouldPaginate) {
|
||||
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
|
||||
}
|
||||
|
||||
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
userId,
|
||||
const records = await query.select('*');
|
||||
|
||||
// Filter fields based on field-level permissions
|
||||
const filteredRecords = await Promise.all(
|
||||
records.map(record =>
|
||||
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
||||
)
|
||||
);
|
||||
|
||||
query.whereIn('id', recordIds);
|
||||
const orderBindings: any[] = ['id'];
|
||||
const cases = recordIds
|
||||
.map((id, index) => {
|
||||
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);
|
||||
|
||||
return {
|
||||
data: filteredRecords,
|
||||
totalCount,
|
||||
page: shouldPaginate ? safePage : 1,
|
||||
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecord(
|
||||
@@ -1179,8 +936,7 @@ export class ObjectService {
|
||||
objectApiName,
|
||||
editableData,
|
||||
);
|
||||
// Use patch to avoid validating or overwriting fields that aren't present in the edit view
|
||||
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
|
||||
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
|
||||
const record = await boundModel.query().where({ id: recordId }).first();
|
||||
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
|
||||
return record;
|
||||
@@ -1284,23 +1040,10 @@ export class ObjectService {
|
||||
if (missingIds.length > 0) {
|
||||
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const deletableIds: string[] = [];
|
||||
const deniedIds: string[] = [];
|
||||
|
||||
|
||||
// Check if user can delete each record
|
||||
for (const record of records) {
|
||||
const canDelete = await this.authService.canPerformAction(
|
||||
'delete',
|
||||
objectDefModel,
|
||||
record,
|
||||
user,
|
||||
knex,
|
||||
);
|
||||
if (canDelete) {
|
||||
deletableIds.push(record.id);
|
||||
} else {
|
||||
deniedIds.push(record.id);
|
||||
}
|
||||
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex);
|
||||
}
|
||||
|
||||
// Ensure model is registered
|
||||
@@ -1308,23 +1051,14 @@ export class ObjectService {
|
||||
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
if (deletableIds.length > 0) {
|
||||
await boundModel.query().whereIn('id', deletableIds).delete();
|
||||
}
|
||||
await boundModel.query().whereIn('id', recordIds).delete();
|
||||
|
||||
// Remove from search index
|
||||
await Promise.all(
|
||||
deletableIds.map((id) =>
|
||||
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
|
||||
),
|
||||
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deleted: deletableIds.length,
|
||||
deletedIds: deletableIds,
|
||||
deniedIds,
|
||||
};
|
||||
return { success: true, deleted: recordIds.length };
|
||||
}
|
||||
|
||||
private async indexRecord(
|
||||
@@ -1339,32 +1073,12 @@ export class ObjectService {
|
||||
.map((field: any) => field.apiName)
|
||||
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
||||
|
||||
console.log('Indexing record', {
|
||||
tenantId,
|
||||
objectApiName,
|
||||
recordId: record.id,
|
||||
fieldsToIndex,
|
||||
});
|
||||
|
||||
await this.meilisearchService.upsertRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
record,
|
||||
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(
|
||||
@@ -1377,48 +1091,15 @@ export class ObjectService {
|
||||
}
|
||||
|
||||
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 [
|
||||
'STRING',
|
||||
'TEXT',
|
||||
'EMAIL',
|
||||
'PHONE',
|
||||
'URL',
|
||||
'RICH_TEXT',
|
||||
'TEXTAREA',
|
||||
].includes(normalized);
|
||||
'id',
|
||||
'ownerId',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'tenantId',
|
||||
].includes(apiName);
|
||||
}
|
||||
|
||||
private async normalizePolymorphicRelatedObject(
|
||||
|
||||
@@ -28,8 +28,6 @@ export class MeilisearchService {
|
||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||
|
||||
console.log('querying Meilisearch index:', { indexName, query, displayField });
|
||||
|
||||
try {
|
||||
const response = await this.requestJson('POST', url, {
|
||||
q: query,
|
||||
@@ -68,48 +66,6 @@ export class MeilisearchService {
|
||||
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(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Toaster } from 'vue-sonner'
|
||||
import BottomDrawer from '@/components/BottomDrawer.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Toaster position="top-right" :duration="4000" richColors />
|
||||
<NuxtPage />
|
||||
<BottomDrawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ import { useSoftphone } from '~/composables/useSoftphone'
|
||||
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
|
||||
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
|
||||
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
|
||||
const props = defineProps<{
|
||||
bounds?: { left: number; width: number }
|
||||
}>()
|
||||
|
||||
const softphone = useSoftphone()
|
||||
|
||||
@@ -193,17 +190,9 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 class="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center px-2">
|
||||
<div
|
||||
class="pointer-events-auto w-full border border-border bg-background transition-all duration-200"
|
||||
:class="{ 'shadow-2xl': isDrawerOpen }"
|
||||
class="pointer-events-auto w-full border border-border bg-background shadow-xl transition-all duration-200"
|
||||
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
|
||||
>
|
||||
<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
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
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',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator class="grid place-content-center text-current">
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
|
||||
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
|
||||
@@ -24,7 +23,6 @@ interface Props {
|
||||
selectable?: boolean
|
||||
baseUrl?: string
|
||||
totalCount?: number
|
||||
searchSummary?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -32,7 +30,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
selectable: false,
|
||||
baseUrl: '/runtime/objects',
|
||||
searchSummary: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -50,13 +47,11 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
// State
|
||||
const normalizeId = (id: any) => String(id)
|
||||
const selectedRowIds = ref<string[]>([])
|
||||
const selectedRows = ref<Set<string>>(new Set())
|
||||
const searchQuery = ref('')
|
||||
const sortField = ref<string>('')
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||
const currentPage = ref(1)
|
||||
const bulkAction = ref('delete')
|
||||
|
||||
// Computed
|
||||
const visibleFields = computed(() =>
|
||||
@@ -97,39 +92,27 @@ const showLoadMore = computed(() => (
|
||||
))
|
||||
|
||||
const allSelected = computed({
|
||||
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
|
||||
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
|
||||
set: (val: boolean) => {
|
||||
if (val) {
|
||||
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
|
||||
selectedRows.value = new Set(props.data.map(row => row.id))
|
||||
} else {
|
||||
selectedRowIds.value = []
|
||||
selectedRows.value.clear()
|
||||
}
|
||||
emit('row-select', getSelectedRows())
|
||||
},
|
||||
})
|
||||
|
||||
const getSelectedRows = () => {
|
||||
const idSet = new Set(selectedRowIds.value)
|
||||
return props.data.filter(row => idSet.has(normalizeId(row.id)))
|
||||
return props.data.filter(row => selectedRows.value.has(row.id))
|
||||
}
|
||||
|
||||
const toggleRowSelection = (rowId: string) => {
|
||||
const normalizedId = normalizeId(rowId)
|
||||
const nextSelection = new Set(selectedRowIds.value)
|
||||
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
|
||||
selectedRowIds.value = Array.from(nextSelection)
|
||||
emit('row-select', getSelectedRows())
|
||||
}
|
||||
|
||||
const setRowSelection = (rowId: string, checked: boolean) => {
|
||||
const normalizedId = normalizeId(rowId)
|
||||
const nextSelection = new Set(selectedRowIds.value)
|
||||
if (checked) {
|
||||
nextSelection.add(normalizedId)
|
||||
if (selectedRows.value.has(rowId)) {
|
||||
selectedRows.value.delete(rowId)
|
||||
} else {
|
||||
nextSelection.delete(normalizedId)
|
||||
selectedRows.value.add(rowId)
|
||||
}
|
||||
selectedRowIds.value = Array.from(nextSelection)
|
||||
emit('row-select', getSelectedRows())
|
||||
}
|
||||
|
||||
@@ -151,14 +134,6 @@ const handleAction = (actionId: string) => {
|
||||
emit('action', actionId, getSelectedRows())
|
||||
}
|
||||
|
||||
const handleBulkAction = () => {
|
||||
if (bulkAction.value === 'delete') {
|
||||
emit('delete', getSelectedRows())
|
||||
return
|
||||
}
|
||||
emit('action', bulkAction.value, getSelectedRows())
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
||||
if (nextPage !== currentPage.value) {
|
||||
@@ -180,19 +155,6 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(rows) => {
|
||||
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
|
||||
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
|
||||
if (nextSelection.length !== selectedRowIds.value.length) {
|
||||
selectedRowIds.value = nextSelection
|
||||
emit('row-select', getSelectedRows())
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -210,31 +172,18 @@ watch(
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
|
||||
{{ searchSummary }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Bulk Actions -->
|
||||
<template v-if="selectedRowIds.length > 0">
|
||||
<template v-if="selectedRows.size > 0">
|
||||
<Badge variant="secondary" class="px-3 py-1">
|
||||
{{ selectedRowIds.length }} selected
|
||||
{{ selectedRows.size }} selected
|
||||
</Badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
|
||||
<SelectTrigger class="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Select action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="delete">Delete selected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" @click="handleBulkAction">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Custom Actions -->
|
||||
@@ -268,10 +217,7 @@ watch(
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead v-if="selectable" class="w-12">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
@update:model-value="(value: boolean) => (allSelected = value)"
|
||||
/>
|
||||
<Checkbox v-model:checked="allSelected" />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-for="field in visibleFields"
|
||||
@@ -312,8 +258,8 @@ watch(
|
||||
>
|
||||
<TableCell v-if="selectable" @click.stop>
|
||||
<Checkbox
|
||||
:model-value="selectedRowIds.includes(normalizeId(row.id))"
|
||||
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
|
||||
:checked="selectedRows.has(row.id)"
|
||||
@update:checked="toggleRowSelection(row.id)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell v-for="field in visibleFields" :key="field.id">
|
||||
|
||||
@@ -330,25 +330,9 @@ export const useViewState = <T extends { id?: string }>(
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
|
||||
if (useBulkEndpoint) {
|
||||
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
|
||||
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
|
||||
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
|
||||
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
|
||||
return {
|
||||
deletedIds,
|
||||
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
|
||||
records.value = records.value.filter(r => !ids.includes(r.id!))
|
||||
totalCount.value = Math.max(0, totalCount.value - ids.length)
|
||||
return {
|
||||
deletedIds: ids,
|
||||
deniedIds: [],
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
console.error('Failed to delete records:', e)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import BottomDrawer from '@/components/BottomDrawer.vue'
|
||||
import {
|
||||
@@ -15,9 +15,6 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
|
||||
|
||||
const route = useRoute()
|
||||
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(() => {
|
||||
// If custom breadcrumbs are set by the page, use those
|
||||
@@ -33,47 +30,12 @@ const breadcrumbs = computed(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset ref="insetRef" class="relative flex flex-col">
|
||||
<SidebarInset class="flex flex-col">
|
||||
<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"
|
||||
>
|
||||
@@ -111,8 +73,7 @@ onBeforeUnmount(() => {
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Keep BottomDrawer bound to the inset width so it aligns with the sidebar layout -->
|
||||
<BottomDrawer :bounds="drawerBounds" />
|
||||
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
@@ -6,14 +6,6 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||
import ListView from '@/components/views/ListView.vue'
|
||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -153,17 +145,6 @@ const editConfig = computed(() => {
|
||||
|
||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
||||
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
|
||||
const fetchObjectDefinition = async () => {
|
||||
@@ -208,42 +189,16 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
|
||||
}
|
||||
|
||||
const handleDelete = async (rows: any[]) => {
|
||||
pendingDeleteRows.value = rows
|
||||
deleteSummary.value = null
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
const resetDeleteDialog = () => {
|
||||
deleteDialogOpen.value = false
|
||||
deleteSubmitting.value = false
|
||||
pendingDeleteRows.value = []
|
||||
deleteSummary.value = null
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (pendingDeleteRows.value.length === 0) {
|
||||
resetDeleteDialog()
|
||||
return
|
||||
}
|
||||
|
||||
deleteSubmitting.value = true
|
||||
try {
|
||||
const ids = 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 (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
|
||||
try {
|
||||
const ids = rows.map(r => r.id)
|
||||
await deleteRecords(ids)
|
||||
if (view.value !== 'list') {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,36 +235,6 @@ const loadListRecords = async (
|
||||
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 firstResult = await loadListRecords(1)
|
||||
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
|
||||
@@ -322,10 +247,6 @@ const initializeListRecords = async () => {
|
||||
}
|
||||
|
||||
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)
|
||||
if (page > loadedPages && totalCount.value > records.value.length) {
|
||||
await loadListRecords(page, { append: true, pageSize })
|
||||
@@ -333,24 +254,9 @@ const handlePageChange = 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 })
|
||||
}
|
||||
|
||||
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(() => route.params, async (newParams, oldParams) => {
|
||||
// Reset current record when navigating to 'new'
|
||||
@@ -419,16 +325,14 @@ onMounted(async () => {
|
||||
v-else-if="view === 'list' && listConfig"
|
||||
:config="listConfig"
|
||||
:data="records"
|
||||
:loading="dataLoading || searchLoading"
|
||||
:loading="dataLoading"
|
||||
:total-count="totalCount"
|
||||
:search-summary="searchSummary"
|
||||
:base-url="`/runtime/objects`"
|
||||
selectable
|
||||
@row-click="handleRowClick"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@search="handleSearch"
|
||||
@page-change="handlePageChange"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
@@ -462,46 +366,6 @@ onMounted(async () => {
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="deleteDialogOpen">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete records</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<p>
|
||||
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
|
||||
</p>
|
||||
<p v-if="deleteSummary" class="text-muted-foreground">
|
||||
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
|
||||
</p>
|
||||
<p v-if="deniedDeleteCount > 0" class="text-destructive">
|
||||
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
|
||||
</p>
|
||||
<p v-if="!deleteSummary" class="text-muted-foreground">
|
||||
Records you do not have permission to delete will be skipped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
|
||||
{{ deleteSummary ? 'Close' : 'Cancel' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!deleteSummary"
|
||||
variant="destructive"
|
||||
@click="confirmDelete"
|
||||
:disabled="deleteSubmitting"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user