Compare commits

..

24 Commits

Author SHA1 Message Date
phyroslam
96989d0ec3 Enhance semantic links with LLM classification and richer UI labels 2026-04-12 21:37:07 -07:00
Francisco Gaona
12a82372f4 WIP - Semantic linking working asynchronously 2026-04-12 11:24:03 +02:00
Francisco Gaona
efa57c4ba8 WIP - semantic linking seems to be working fine 2026-04-12 11:09:43 +02:00
Francisco Gaona
3f9be316ce WIP - semantinc linking working with other fields 2026-04-12 10:48:37 +02:00
Francisco Gaona
385a842ab8 WIP - semantic linking working with just name 2026-04-12 09:26:23 +02:00
Francisco Gaona
320f8c4266 WIP - some progress with semantic linking but still needs a lot of work 2026-04-11 23:30:25 +02:00
Francisco Gaona
12b0a0881e WIP - initial UI for comments and semantic links 2026-04-11 22:14:24 +02:00
Francisco Gaona
dc18b08a3a WIP - enable embedings 2026-04-11 21:14:34 +02:00
phyroslam
df183230d8 Add phased knowledge layer: comments and semantic linking pipeline 2026-04-11 11:40:01 -07:00
Francisco Gaona
baf3997fb6 WIP - fix displaying name for owner field 2026-04-10 22:19:11 +02:00
Francisco Gaona
a2d48f6a03 WIP - Fix options for picklist field, some progress on multi picklist. 2026-04-10 21:41:13 +02:00
Francisco Gaona
fb2533fa4c WIP - sharing list views 2026-04-10 10:57:20 +02:00
Francisco Gaona
12304d5890 WIP - saving list views 2026-04-10 10:37:11 +02:00
Francisco Gaona
a0bdb09c03 WIP - resolving related look ups for search filtering 2026-04-10 09:07:06 +02:00
Francisco Gaona
c89dc04d4c WIP - improve list filtering explanation 2026-04-09 21:46:29 +02:00
Francisco Gaona
7a175923b0 Merge branch 'codex/fix-ai-search-result-explanation' into integrate-query-explain-fix 2026-04-09 19:06:37 +02:00
phyroslam
ed48623f27 Use LLM refinement for query explanation text 2026-04-09 08:58:09 -07:00
Francisco Gaona
228c3fb704 WIP - fixes to spreadsheet view 2026-04-09 09:28:25 +02:00
Francisco Gaona
5f14a4050a WIP - added spredsheet view using ag-grid 2026-02-06 22:01:59 +01:00
Francisco Gaona
eb1619c56c WIP - fix AI suggestions during call progress 2026-02-05 03:02:02 +01:00
Francisco Gaona
9226442525 WIP - fix twilio functionality now that we use BFF 2026-02-05 02:41:54 +01:00
Francisco Gaona
49a571215d WIP - fix browser refresh not holding user authentication 2026-02-04 08:55:08 +01:00
Francisco Gaona
0e2f3dddbc WIP - BFF 2026-02-04 00:21:06 +01:00
Francisco Gaona
f68321c802 Use AI assistant to create records in the system, added configurable list views 2026-01-31 03:24:46 +01:00
125 changed files with 8858 additions and 4941 deletions

View File

@@ -1,5 +1,5 @@
NUXT_PORT=3001
NUXT_HOST=0.0.0.0
# Point Nuxt to the API container (not localhost)
NUXT_PUBLIC_API_BASE_URL=https://tenant1.routebox.co
# Nitro BFF backend URL (server-only, not exposed to client)
BACKEND_URL=https://backend.routebox.co

View File

@@ -1,324 +0,0 @@
# AI Process Builder + Chat Orchestrator
A complete implementation of tenant-scoped AI process automation where admins design LangGraph-compiled workflows via React Flow UI, and end-users execute them through a Deep Agent chat orchestrator with deterministic, audited execution.
## Architecture Overview
### Backend Components
#### 1. **Deep Agent Orchestrator** ([deep-agent.orchestrator.ts](backend/src/ai-processes/deep-agent.orchestrator.ts))
- Uses LangChain/OpenAI to intelligently select processes
- Extracts structured inputs from natural language
- Generates friendly confirmation messages
- Three-step workflow: discover → select → extract → execute
#### 2. **Graph Compiler** ([ai-processes.compiler.ts](backend/src/ai-processes/ai-processes.compiler.ts))
- Validates ReactFlow JSON graphs (Start/End nodes, reachability, cycles)
- Compiles to LangGraph-compatible state machines
- Validates tool allowlist and JSON schemas (Ajv)
- Persists compiled artifact for versioned execution
#### 3. **Runtime Executor** ([ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts))
- Executes compiled graphs deterministically
- Implements 4 node types: LLMDecisionNode, ToolNode, HumanInputNode, End
- Handles conditional edges via jsonlogic
- Emits real-time events for streaming updates
#### 4. **Tool Registry** ([tools/tool-registry.ts](backend/src/ai-processes/tools/tool-registry.ts))
- Tenant-scoped tool allowlist (database-backed via AiToolConfig)
- Demo tools wrapping ObjectService (findAccount, createAccount, etc.)
- Context injection (tenantId, userId, knex) for secure execution
#### 5. **Orchestrator Service** ([ai-processes.orchestrator.service.ts](backend/src/ai-processes/ai-processes.orchestrator.service.ts))
- Integrates Deep Agent for process selection
- Falls back to standard AI assistant when no processes configured
- Manages chat sessions and message history
- Streams execution events via SSE
### Frontend Components
#### 1. **AIChatBar** ([components/AIChatBar.vue](frontend/components/AIChatBar.vue))
- Updated to call `/ai-processes/chat/messages` endpoint
- SSE event stream consumer for real-time updates
- Displays process selection, node execution, tool calls
- Handles NEED_INPUT events for human-in-the-loop
#### 2. **Process Management UI** ([pages/ai-processes/](frontend/pages/ai-processes/))
- List view: displays all processes with versions
- Editor view: React Flow integration via iframe + postMessage
- Test runner for quick validation
#### 3. **React Flow Editor** ([ai-processes-editor/src/App.tsx](frontend/ai-processes-editor/src/App.tsx))
- Node palette: Start, LLMDecisionNode, ToolNode, HumanInputNode, End
- Visual graph designer with drag-drop
- Auto-saves to parent window via postMessage
- Loads existing graphs for editing
### Data Models (Objection.js)
```typescript
AiProcess
├── id, tenantId, name, description, latestVersion
└── relations: versions[], runs[]
AiProcessVersion
├── id, tenantId, processId, version
├── graphJson (ReactFlow definition)
└── compiledJson (LangGraph artifact)
AiProcessRun
├── id, tenantId, processId, version, status
├── inputJson, outputJson, errorJson, stateJson
└── currentNodeId (for resume)
AiChatSession
├── id, tenantId, userId
└── relations: messages[]
AiChatMessage
├── id, sessionId, role, content
└── timestamps
AiAuditEvent
├── id, tenantId, runId, eventType
└── payloadJson (full event data)
AiToolConfig
├── id, tenantId, toolName, enabled
└── configJson (tool-specific settings)
```
## Demo Process: Register New Pet
A complete workflow demonstrating conditional logic and tool orchestration:
1. **Extract Info** (LLMDecisionNode)
- Parses user message for pet + owner details
- Outputs structured JSON with validation
2. **Find/Create Account** (Conditional)
- Searches for existing account by name/email
- Creates new account if not found
- Merges results into state
3. **Find/Create Contact** (Conditional)
- Searches for existing contact under account
- Creates new contact if not found
4. **Create Pet** (ToolNode)
- Inserts pet record linked to contact
- Returns pet ID
### Seed the Demo Process
```bash
cd backend
npm run migrate:tenant -- <tenant-slug>
npm run seed:demo-process -- <tenant-slug>
```
### Test the Demo Process
1. Navigate to `/ai-processes` in your tenant subdomain
2. Open "Register New Pet" process
3. Click "Test Run" or use the chat bar:
```
User: "Register a dog named Max, breed Golden Retriever, age 3,
owned by John Smith, email john@example.com"
Agent: 🔄 Selected process: Register New Pet
I'll register Max (Golden Retriever, 3 years old) for John Smith.
⚙️ Executing step: Extract Info
✓ Extracted pet details
🔧 Using tool: findAccount
Account not found, creating new account
🔧 Using tool: createAccount
✓ Created account for John Smith
🔧 Using tool: findContact
Contact not found, creating new contact
🔧 Using tool: createContact
✓ Created contact: John Smith
🔧 Using tool: createPet
✓ Created pet: Max (ID: pet_1234567890)
✅ Process completed successfully!
```
## API Endpoints
### Process Management (Admin)
```typescript
GET /tenants/:tenantId/ai-processes
POST /tenants/:tenantId/ai-processes
GET /tenants/:tenantId/ai-processes/:id
POST /tenants/:tenantId/ai-processes/:id/versions
GET /tenants/:tenantId/ai-processes/:id/versions
POST /tenants/:tenantId/ai-processes/:id/runs
POST /tenants/:tenantId/ai-processes/runs/:runId/resume
```
### Chat Orchestrator (End User)
```typescript
POST /tenants/:tenantId/ai-processes/chat/messages
SSE /tenants/:tenantId/ai-processes/stream?sessionId=xxx
```
## Event Stream Types
```typescript
type StreamEvent =
| { type: 'agent_started' }
| { type: 'processes_listed', data: { count: number } }
| { type: 'process_selected', processId: string, version: number }
| { type: 'agent_message', data: { message: string } }
| { type: 'node_started', nodeId: string }
| { type: 'node_completed', nodeId: string }
| { type: 'tool_called', toolName: string, nodeId: string }
| { type: 'llm_decision', nodeId: string, data: any }
| { type: 'need_input', data: { prompt: string, schema: JSONSchema } }
| { type: 'final', data: { output: any } }
| { type: 'error', data: { error: string } }
```
## Security & Guardrails
### 1. **Tenancy Isolation**
- All queries filtered by `tenantId` (enforced in Objection models)
- Tool context includes tenant scope
- Database-per-tenant architecture (inherited from platform)
### 2. **Tool Allowlist**
- Two-level validation:
- Tenant-level: `AiToolConfig` table (enabled tools per tenant)
- Compile-time: validates toolName exists in registry
- Runtime check before tool execution
### 3. **Schema Validation**
- LLMDecisionNode output validated against JSON Schema (Ajv)
- HumanInputNode input validated before resume
- Graph structure validated at compile time
### 4. **Audit Trail**
- Every node execution logged to `ai_audit_events`
- Includes: tool calls, LLM decisions, state mutations, errors
- Queryable for compliance dashboards
### 5. **Versioning**
- Immutable process versions (create-only)
- Runs reference specific version number
- Graph definition + compiled artifact stored together
## Running the System
### 1. **Run Migrations**
```bash
cd backend
npm run migrate:tenant -- tenant1
```
### 2. **Seed Demo Data**
```bash
npm run seed:demo-process -- tenant1
```
### 3. **Start Backend**
```bash
npm run start:dev
```
### 4. **Build Editor (if needed)**
```bash
cd frontend/ai-processes-editor
npm install
npm run build
```
### 5. **Start Frontend**
```bash
cd frontend
npm run dev
```
### 6. **Access UI**
- Admin UI: `http://tenant1.localhost:3001/ai-processes`
- Chat UI: Available in bottom drawer on any page (⌘K to toggle)
## Extension Points
### Adding New Node Types
1. Define type in [ai-processes.types.ts](backend/src/ai-processes/ai-processes.types.ts)
2. Add schema validation in [ai-processes.schemas.ts](backend/src/ai-processes/ai-processes.schemas.ts)
3. Implement executor in [ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts)
4. Add UI component in React Flow editor
### Adding New Tools
1. Implement handler in [tools/demo-tools.ts](backend/src/ai-processes/tools/demo-tools.ts)
2. Register in `demoTools` export
3. Add to tenant allowlist via UI or seed script
4. Document input/output schema
### Custom LLM Decision Logic
Override `llmDecision` callback in [ai-processes.service.ts](backend/src/ai-processes/ai-processes.service.ts):
```typescript
llmDecision: async (node, state) => {
const prompt = renderTemplate(node.data.promptTemplate, state);
const response = await callOpenAI(prompt, node.data.model);
return validateAgainstSchema(response, node.data.outputSchema);
}
```
## Troubleshooting
### Process not appearing in chat
- Check: `npm run seed:demo-process` completed successfully
- Verify: Process exists in database (`select * from ai_processes`)
- Check: Tools enabled (`select * from ai_tool_configs`)
### Graph validation errors
- Ensure exactly one Start node
- Ensure at least one End node
- Check all edges reference valid node IDs
- Verify tool names match registered tools
### SSE stream not working
- Check CORS settings for subdomain routing
- Verify `sessionId` returned from initial message
- Check browser console for connection errors
- Fallback: use polling endpoint (TODO: implement)
## Next Steps
1. **Enhanced Input Extraction**: Use Deep Agent to extract required fields per process
2. **Visual Schema Builder**: UI for JSON Schema creation (drag-drop fields)
3. **Conditional Edge Builder**: Visual jsonlogic editor
4. **Process Analytics**: Dashboard showing run success rates, avg duration
5. **Human-in-Loop UI**: Dynamic form renderer for HumanInputNode
6. **Process Marketplace**: Share processes across tenants (with permissions)
7. **Python Microservice**: Optional Python runtime for native LangGraph support
## License
MIT

View File

@@ -1,115 +0,0 @@
-- Insert demo AI process directly
SET @process_id = '2d883482-4df0-44d7-b6cf-8541b482afe4';
SET @version_id = '437b1e72-405e-4862-a8bc-f368e554b482';
SET @user_id = 'system';
-- Insert process
INSERT INTO ai_processes (id, name, created_by)
VALUES (@process_id, 'Register New Pet', @user_id);
-- Insert process version with compiled graph
INSERT INTO ai_process_versions (id, process_id, version, graph_json, compiled_json, created_by)
VALUES (
@version_id,
@process_id,
1,
'{}',
JSON_OBJECT(
'id', 'register_new_pet',
'name', 'Register New Pet',
'description', 'Complete pet registration workflow',
'allowCycles', false,
'startNodeId', 'start',
'endNodeIds', JSON_ARRAY('end'),
'maxIterations', 50,
'nodes', JSON_ARRAY(
JSON_OBJECT('id', 'start', 'type', 'Start', 'data', JSON_OBJECT('label', 'Start')),
JSON_OBJECT('id', 'extract_info', 'type', 'LLMDecisionNode', 'data', JSON_OBJECT(
'label', 'Extract Info',
'promptTemplate', 'Extract: petName, species, ownerFirstName, ownerLastName, ownerEmail, accountName from: {{state.message}}',
'inputKeys', JSON_ARRAY('message'),
'outputSchema', JSON_OBJECT(
'type', 'object',
'properties', JSON_OBJECT(
'petName', JSON_OBJECT('type', 'string'),
'species', JSON_OBJECT('type', 'string'),
'ownerFirstName', JSON_OBJECT('type', 'string'),
'ownerLastName', JSON_OBJECT('type', 'string'),
'ownerEmail', JSON_OBJECT('type', 'string'),
'accountName', JSON_OBJECT('type', 'string')
),
'required', JSON_ARRAY('petName', 'species', 'ownerFirstName', 'ownerLastName')
)
)),
JSON_OBJECT('id', 'find_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Account',
'toolName', 'findAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('found', 'accountFound', 'accountId', 'accountId')
)),
JSON_OBJECT('id', 'create_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Account',
'toolName', 'createAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('accountId', 'accountId')
)),
JSON_OBJECT('id', 'find_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Contact',
'toolName', 'findContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('found', 'contactFound', 'contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Contact',
'toolName', 'createContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_pet', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Pet',
'toolName', 'createPet',
'argsTemplate', JSON_OBJECT(
'name', '{{state.petName}}',
'species', '{{state.species}}',
'ownerId', '{{state.contactId}}'
),
'outputMapping', JSON_OBJECT('petId', 'petId')
)),
JSON_OBJECT('id', 'end', 'type', 'End', 'data', JSON_OBJECT('label', 'End'))
),
'edges', JSON_ARRAY(
JSON_OBJECT('id', 'e1', 'source', 'start', 'target', 'extract_info'),
JSON_OBJECT('id', 'e2', 'source', 'extract_info', 'target', 'find_account'),
JSON_OBJECT('id', 'e3', 'source', 'find_account', 'target', 'find_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), true))),
JSON_OBJECT('id', 'e4', 'source', 'find_account', 'target', 'create_account', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), false))),
JSON_OBJECT('id', 'e5', 'source', 'create_account', 'target', 'find_contact'),
JSON_OBJECT('id', 'e6', 'source', 'find_contact', 'target', 'create_pet', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), true))),
JSON_OBJECT('id', 'e7', 'source', 'find_contact', 'target', 'create_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), false))),
JSON_OBJECT('id', 'e8', 'source', 'create_contact', 'target', 'create_pet'),
JSON_OBJECT('id', 'e9', 'source', 'create_pet', 'target', 'end')
)
),
@user_id
);
-- Insert tool allowlist
INSERT INTO ai_tool_configs (id, tool_name, enabled)
VALUES
(UUID(), 'findAccount', true),
(UUID(), 'createAccount', true),
(UUID(), 'findContact', true),
(UUID(), 'createContact', true),
(UUID(), 'createPet', true)
ON DUPLICATE KEY UPDATE enabled = true;
SELECT 'Demo process inserted successfully!' as result;

View File

@@ -0,0 +1,95 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// Check if layout_type column already exists (in case of partial migration)
const hasLayoutType = await knex.schema.hasColumn('page_layouts', 'layout_type');
// Check if the old index exists
const [indexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_is_default_index'`);
const hasOldIndex = indexes.length > 0;
// Check if foreign key exists
const [fks] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'page_layouts'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
`);
const hasForeignKey = fks.length > 0;
if (hasOldIndex) {
// First, drop the foreign key constraint that depends on the index (if it exists)
if (hasForeignKey) {
await knex.schema.alterTable('page_layouts', (table) => {
table.dropForeign(['object_id']);
});
}
// Now we can safely drop the old index
await knex.schema.alterTable('page_layouts', (table) => {
table.dropIndex(['object_id', 'is_default']);
});
}
// Add layout_type column if it doesn't exist
if (!hasLayoutType) {
await knex.schema.alterTable('page_layouts', (table) => {
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
// Default to 'detail' for existing layouts
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
});
}
// Check if new index exists
const [newIndexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_layout_type_is_default_index'`);
const hasNewIndex = newIndexes.length > 0;
if (!hasNewIndex) {
// Create new index including layout_type
await knex.schema.alterTable('page_layouts', (table) => {
table.index(['object_id', 'layout_type', 'is_default']);
});
}
// Re-check if foreign key exists (may have been dropped above or in previous attempt)
const [fksAfter] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'page_layouts'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
`);
if (fksAfter.length === 0) {
// Re-add the foreign key constraint
await knex.schema.alterTable('page_layouts', (table) => {
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
});
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// Drop the foreign key first
await knex.schema.alterTable('page_layouts', (table) => {
table.dropForeign(['object_id']);
});
// Drop the new index and column, restore old index
await knex.schema.alterTable('page_layouts', (table) => {
table.dropIndex(['object_id', 'layout_type', 'is_default']);
table.dropColumn('layout_type');
table.index(['object_id', 'is_default']);
});
// Re-add the foreign key constraint
await knex.schema.alterTable('page_layouts', (table) => {
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
});
};

View File

@@ -1,72 +0,0 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_processes', (table) => {
table.uuid('id').primary();
table.string('name').notNullable();
table.text('description');
table.integer('latest_version').notNullable().defaultTo(1);
table.string('created_by').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
await knex.schema.createTable('ai_process_versions', (table) => {
table.uuid('id').primary();
table.uuid('process_id').notNullable();
table.integer('version').notNullable();
table.json('graph_json').notNullable();
table.json('compiled_json').notNullable();
table.string('created_by').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['process_id', 'version']);
table.index(['process_id']);
});
await knex.schema.createTable('ai_process_runs', (table) => {
table.uuid('id').primary();
table.uuid('process_id').notNullable();
table.integer('version').notNullable();
table.string('status').notNullable();
table.json('input_json').notNullable();
table.json('output_json');
table.json('error_json');
table.json('state_json');
table.string('current_node_id');
table.timestamp('started_at').defaultTo(knex.fn.now());
table.timestamp('ended_at');
table.index(['process_id']);
});
await knex.schema.createTable('ai_chat_sessions', (table) => {
table.uuid('id').primary();
table.string('user_id').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['user_id']);
});
await knex.schema.createTable('ai_chat_messages', (table) => {
table.uuid('id').primary();
table.uuid('session_id').notNullable();
table.string('role').notNullable();
table.text('content').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['session_id']);
});
await knex.schema.createTable('ai_audit_events', (table) => {
table.uuid('id').primary();
table.uuid('run_id').notNullable();
table.string('event_type').notNullable();
table.json('payload_json').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['run_id']);
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ai_audit_events');
await knex.schema.dropTableIfExists('ai_chat_messages');
await knex.schema.dropTableIfExists('ai_chat_sessions');
await knex.schema.dropTableIfExists('ai_process_runs');
await knex.schema.dropTableIfExists('ai_process_versions');
await knex.schema.dropTableIfExists('ai_processes');
};

View File

@@ -1,14 +0,0 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_tool_configs', (table) => {
table.uuid('id').primary();
table.string('tool_name').notNullable().unique();
table.boolean('enabled').notNullable().defaultTo(true);
table.json('config_json');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ai_tool_configs');
};

View File

@@ -0,0 +1,55 @@
/**
* Creates the saved_list_views table.
* Each row stores a named, reusable search/filter configuration for a specific
* CRM object type. Views can be private to the owning user or shared with the
* whole tenant.
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('saved_list_views', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
// Human-readable name given by the user (or AI-suggested)
table.string('name').notNullable();
// The object this view belongs to (e.g. "Dog", "Contact")
table.string('object_api_name').notNullable();
// The user who created/owns this view
table.uuid('user_id').notNullable();
// When true the view is visible to all users in the tenant
table.boolean('is_shared').notNullable().defaultTo(false);
// Strategy is always "query" for saved views (keyword views are not saved)
table.string('strategy').notNullable().defaultTo('query');
// Resolved filters as JSON array of AiSearchFilter objects
table.json('filters').notNullable();
// Optional sort: { field: string, direction: "asc" | "desc" }
table.json('sort').nullable();
// AI-generated plain-language explanation of what this view shows
table.text('description').nullable();
table.timestamps(true, true);
// Foreign key to users
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
// Primary lookup: all views for an object visible to a user
table.index(['object_api_name', 'user_id']);
table.index(['object_api_name', 'is_shared']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('saved_list_views');
};

View File

@@ -0,0 +1,35 @@
/**
* Inserts a system object_definition row for SavedListView.
* This allows saved_list_views records to be shared via record_shares
* (which requires a valid objectDefinitionId FK).
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
// Only insert if it doesn't already exist (idempotent)
const existing = await knex('object_definitions')
.where({ apiName: 'SavedListView' })
.first();
if (!existing) {
await knex('object_definitions').insert({
apiName: 'SavedListView',
label: 'Saved List View',
pluralLabel: 'Saved List Views',
description: 'System object for sharing saved list views via record_shares',
isSystem: true,
isCustom: false,
});
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
await knex('object_definitions')
.where({ apiName: 'SavedListView' })
.delete();
};

View File

@@ -0,0 +1,30 @@
/**
* Add 'alias' and virtual 'name' column to users table.
*
* - alias: a user-editable display name / nickname
* - name: a generated column that returns COALESCE(alias, CONCAT(firstName, ' ', lastName), email)
* so that lookup fields referencing User.name always resolve.
*/
exports.up = function (knex) {
return knex.schema.alterTable('users', (table) => {
table.string('alias', 255).nullable().after('lastName');
table.string('name', 512).nullable().after('alias');
}).then(() => {
// Backfill existing rows: name = alias, or firstName + lastName, or email
return knex.raw(`
UPDATE users
SET name = COALESCE(
NULLIF(alias, ''),
NULLIF(TRIM(CONCAT(COALESCE(firstName, ''), ' ', COALESCE(lastName, ''))), ''),
email
)
`);
});
};
exports.down = function (knex) {
return knex.schema.alterTable('users', (table) => {
table.dropColumn('name');
table.dropColumn('alias');
});
};

View File

@@ -0,0 +1,93 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
await knex.schema.createTable('comments', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('parent_object_api_name').notNullable();
table.uuid('parent_record_id').notNullable();
table.uuid('author_user_id').notNullable();
table.text('content').notNullable();
table.timestamps(true, true);
table.foreign('author_user_id').references('id').inTable('users').onDelete('CASCADE');
table.index(['parent_object_api_name', 'parent_record_id'], 'comments_parent_idx');
table.index(['author_user_id'], 'comments_author_idx');
});
await knex.schema.createTable('semantic_documents', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('entity_type').notNullable();
table.uuid('entity_id').notNullable();
table.string('title').nullable();
table.text('narrative').nullable();
table.json('metadata').nullable();
table.json('source_summary').nullable();
table.timestamps(true, true);
table.unique(['entity_type', 'entity_id'], {
indexName: 'semantic_documents_entity_unique',
});
table.index(['entity_type'], 'semantic_documents_type_idx');
});
await knex.schema.createTable('semantic_chunks', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('semantic_document_id').notNullable();
table.integer('chunk_index').notNullable();
table.string('source_kind').notNullable().defaultTo('base_record');
table.uuid('source_ref_id').nullable();
table.text('text').notNullable();
table.json('metadata').nullable();
table.timestamps(true, true);
table.foreign('semantic_document_id').references('id').inTable('semantic_documents').onDelete('CASCADE');
table.unique(['semantic_document_id', 'chunk_index'], {
indexName: 'semantic_chunks_doc_index_unique',
});
table.index(['semantic_document_id'], 'semantic_chunks_document_idx');
table.index(['source_kind'], 'semantic_chunks_source_kind_idx');
});
await knex.schema.createTable('semantic_links', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('source_entity_type', 100).notNullable();
table.uuid('source_entity_id').notNullable();
table.string('target_entity_type', 100).notNullable();
table.uuid('target_entity_id').notNullable();
table.string('link_type', 100).notNullable().defaultTo('related_to');
table.string('status').notNullable().defaultTo('suggested');
table.string('origin').notNullable().defaultTo('semantic');
table.decimal('confidence', 5, 4).notNullable().defaultTo(0);
table.text('reason').nullable();
table.json('evidence').nullable();
table.uuid('suggested_by_user_id').nullable();
table.uuid('reviewed_by_user_id').nullable();
table.timestamp('reviewed_at').nullable();
table.timestamps(true, true);
table.foreign('suggested_by_user_id').references('id').inTable('users').onDelete('SET NULL');
table.foreign('reviewed_by_user_id').references('id').inTable('users').onDelete('SET NULL');
table.unique(
['source_entity_type', 'source_entity_id', 'target_entity_type', 'target_entity_id', 'link_type'],
{ indexName: 'semantic_links_unique_pair_type' },
);
table.index(['source_entity_type', 'source_entity_id'], 'semantic_links_source_idx');
table.index(['target_entity_type', 'target_entity_id'], 'semantic_links_target_idx');
table.index(['status'], 'semantic_links_status_idx');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('semantic_links');
await knex.schema.dropTableIfExists('semantic_chunks');
await knex.schema.dropTableIfExists('semantic_documents');
await knex.schema.dropTableIfExists('comments');
};

View File

@@ -11,7 +11,7 @@
"dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12",
"@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
@@ -25,17 +25,14 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0",
"@types/json-logic-js": "^2.0.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"deepagents": "^1.5.0",
"ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"langchain": "^1.2.10",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"openai": "^6.15.0",
@@ -100,41 +97,6 @@
}
}
},
"node_modules/@angular-devkit/core/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/core/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@angular-devkit/core/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -267,6 +229,26 @@
"tslib": "^2.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -728,6 +710,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -968,23 +959,6 @@
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@fastify/cors": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
@@ -1745,10 +1719,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@langchain/anthropic": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz",
"integrity": "sha512-VXq5fsEJ4FB5XGrnoG+bfm0I7OlmYLI4jZ6cX9RasyqhGo9wcDyKw1+uEQ1H7Og7jWrTa1bfXCun76wttewJnw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "1.1.15"
}
},
"node_modules/@langchain/core": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.12.tgz",
"integrity": "sha512-sHWLvhyLi3fntlg3MEPB89kCjxEX7/+imlIYJcp6uFGCAZfGxVWklqp22HwjT1szorUBYrkO8u0YA554ReKxGQ==",
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz",
"integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
@@ -2543,7 +2533,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -2557,7 +2546,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -2567,7 +2555,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -2973,12 +2960,6 @@
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/json-logic-js": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@types/json-logic-js/-/json-logic-js-2.0.8.tgz",
"integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3636,15 +3617,15 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
@@ -3652,9 +3633,9 @@
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -3681,22 +3662,6 @@
"ajv": "^8.8.2"
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -4107,7 +4072,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -4832,6 +4796,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepagents": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/deepagents/-/deepagents-1.5.0.tgz",
"integrity": "sha512-tjZLOISPMpqfk+k/iE1uIZavXW9j4NrhopUmH5ARqzmk95EEtGDyN++tgnY+tdVOOZTjE2LHjOVV7or58dtx8A==",
"license": "MIT",
"dependencies": {
"@langchain/anthropic": "^1.3.7",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.14",
"fast-glob": "^3.3.3",
"langchain": "^1.2.7",
"micromatch": "^4.0.8",
"yaml": "^2.8.2",
"zod": "^4.3.5"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5592,7 +5572,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -5627,6 +5606,23 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -5801,7 +5797,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -6206,7 +6201,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -6655,7 +6649,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6684,7 +6677,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -6719,7 +6711,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -7654,12 +7645,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-logic-js": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",
"integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -7676,6 +7661,19 @@
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -7878,9 +7876,9 @@
}
},
"node_modules/langchain": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.7.tgz",
"integrity": "sha512-G+3Ftz/08CurJaE7LukQGBf3mCSz7XM8LZeAaFPg391Ru4lT8eLYfG6Fv4ZI0u6EBsPVcOQfaS9ig8nCRmJeqA==",
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz",
"integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.0.0",
@@ -7893,7 +7891,7 @@
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "1.1.12"
"@langchain/core": "1.1.15"
}
},
"node_modules/langchain/node_modules/uuid": {
@@ -8268,7 +8266,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -8278,7 +8275,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -8292,7 +8288,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -8722,22 +8717,37 @@
"knex": ">=1.0.1"
}
},
"node_modules/objection/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"node_modules/objection/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"peerDependencies": {
"ajv": "^8.0.0"
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
"node_modules/objection/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/obliterator": {
"version": "2.0.5",
@@ -9399,7 +9409,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -9441,7 +9450,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -9780,7 +9788,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -10535,24 +10542,6 @@
}
}
},
"node_modules/terser-webpack-plugin/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/terser-webpack-plugin/node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -10728,7 +10717,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -10780,6 +10768,12 @@
"tree-kill": "cli.js"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@@ -11128,7 +11122,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
@@ -11312,25 +11305,6 @@
"node": ">=10.13.0"
}
},
"node_modules/webpack/node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/webpack/node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -11546,6 +11520,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -11599,9 +11588,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -23,13 +23,12 @@
"migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js",
"migrate:status": "ts-node -r tsconfig-paths/register scripts/check-migration-status.ts",
"migrate:tenant": "ts-node -r tsconfig-paths/register scripts/migrate-tenant.ts",
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts",
"seed:demo-process": "ts-node -r tsconfig-paths/register scripts/seed-demo-process.ts"
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
},
"dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12",
"@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
@@ -43,17 +42,14 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0",
"@types/json-logic-js": "^2.0.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"deepagents": "^1.5.0",
"ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"langchain": "^1.2.10",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"openai": "^6.15.0",

View File

@@ -20,6 +20,8 @@ model User {
password String
firstName String?
lastName String?
alias String?
name String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -181,101 +183,6 @@ model ContactDetail {
@@map("contact_details")
}
// AI Process Builder + Chat Orchestrator
model AiProcess {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
description String? @db.Text
latestVersion Int @default(1) @map("latest_version")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
versions AiProcessVersion[]
runs AiProcessRun[]
@@index([tenantId])
@@map("ai_processes")
}
model AiProcessVersion {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
graphJson Json @map("graph_json")
compiledJson Json @map("compiled_json")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@unique([processId, version])
@@index([tenantId])
@@map("ai_process_versions")
}
model AiProcessRun {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
status String
inputJson Json @map("input_json")
outputJson Json? @map("output_json")
errorJson Json? @map("error_json")
stateJson Json? @map("state_json")
currentNodeId String? @map("current_node_id")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([processId])
@@map("ai_process_runs")
}
model AiChatSession {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
messages AiChatMessage[]
@@index([tenantId])
@@index([userId])
@@map("ai_chat_sessions")
}
model AiChatMessage {
id String @id @default(uuid())
sessionId String @map("session_id")
role String
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
session AiChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@map("ai_chat_messages")
}
model AiAuditEvent {
id String @id @default(uuid())
tenantId String @map("tenant_id")
runId String @map("run_id")
eventType String @map("event_type")
payloadJson Json @map("payload_json")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@index([runId])
@@map("ai_audit_events")
}
// Application Builder
model App {
id String @id @default(uuid())

View File

@@ -1,332 +0,0 @@
import { randomUUID } from 'crypto';
import { AiProcess, AiProcessVersion, AiToolConfig } from '../src/models/ai-process.model';
// Bootstrap NestJS to get proper services
async function getTenantContext(tenantSlugOrId: string) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../src/app.module');
const { TenantDatabaseService } = await import('../src/tenant/tenant-database.service');
// Create app context (without listening)
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
const tenantDbService = app.get(TenantDatabaseService);
// Resolve tenant ID
const tenantId = await tenantDbService.resolveTenantId(tenantSlugOrId);
// Get proper Knex connection
const knex = await tenantDbService.getTenantKnexById(tenantId);
return { tenantId, knex, app };
}
/**
* Seed script for demo AI Process: Register New Pet
*
* This process demonstrates:
* - Conditional logic (find or create account/contact)
* - Tool usage (findAccount, createAccount, findContact, createContact, createPet)
* - Sequential execution
* - LLM decision nodes with structured JSON output
*
* Usage:
* npm run seed:demo-process -- <tenant-slug-or-id>
*/
const demoProcessGraph = {
id: 'register_new_pet',
name: 'Register New Pet',
description: 'Complete pet registration workflow with account and contact resolution',
allowCycles: false,
nodes: [
{
id: 'start',
type: 'Start',
position: { x: 250, y: 50 },
data: { label: 'Start' },
},
{
id: 'extract_info',
type: 'LLMDecisionNode',
position: { x: 250, y: 150 },
data: {
label: 'Extract Pet Info',
promptTemplate: `Extract pet registration information from the user message.
User message: {{state.message}}
Extract:
- Pet name (required)
- Pet species (required, e.g., "dog", "cat", "bird")
- Pet breed (optional)
- Pet age (optional, as number)
- Owner first name (required)
- Owner last name (required)
- Owner email (optional)
- Owner phone (optional)
- Account/Company name (optional, defaults to owner's full name)
Return JSON with these exact fields.`,
inputKeys: ['message'],
outputSchema: {
type: 'object',
properties: {
petName: { type: 'string' },
species: { type: 'string' },
breed: { type: 'string' },
age: { type: 'number' },
ownerFirstName: { type: 'string' },
ownerLastName: { type: 'string' },
ownerEmail: { type: 'string' },
ownerPhone: { type: 'string' },
accountName: { type: 'string' },
},
required: ['petName', 'species', 'ownerFirstName', 'ownerLastName'],
},
model: { name: 'gpt-4o', temperature: 0 },
},
},
{
id: 'find_account',
type: 'ToolNode',
position: { x: 250, y: 280 },
data: {
label: 'Find Account',
toolName: 'findAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
},
outputMapping: {
found: 'accountFound',
accountId: 'accountId',
},
},
},
{
id: 'create_account',
type: 'ToolNode',
position: { x: 450, y: 380 },
data: {
label: 'Create Account',
toolName: 'createAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
},
outputMapping: {
accountId: 'accountId',
},
},
},
{
id: 'find_contact',
type: 'ToolNode',
position: { x: 250, y: 480 },
data: {
label: 'Find Contact',
toolName: 'findContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
found: 'contactFound',
contactId: 'contactId',
},
},
},
{
id: 'create_contact',
type: 'ToolNode',
position: { x: 450, y: 580 },
data: {
label: 'Create Contact',
toolName: 'createContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
contactId: 'contactId',
},
},
},
{
id: 'create_pet',
type: 'ToolNode',
position: { x: 250, y: 680 },
data: {
label: 'Create Pet Record',
toolName: 'createPet',
argsTemplate: {
name: '{{state.petName}}',
species: '{{state.species}}',
breed: '{{state.breed}}',
age: '{{state.age}}',
ownerId: '{{state.contactId}}',
},
outputMapping: {
petId: 'petId',
},
},
},
{
id: 'end',
type: 'End',
position: { x: 250, y: 780 },
data: { label: 'End' },
},
],
edges: [
{ id: 'e1', source: 'start', target: 'extract_info' },
{ id: 'e2', source: 'extract_info', target: 'find_account' },
{
id: 'e3',
source: 'find_account',
target: 'find_contact',
condition: { '==': [{ var: 'accountFound' }, true] },
},
{
id: 'e4',
source: 'find_account',
target: 'create_account',
condition: { '==': [{ var: 'accountFound' }, false] },
},
{ id: 'e5', source: 'create_account', target: 'find_contact' },
{
id: 'e6',
source: 'find_contact',
target: 'create_pet',
condition: { '==': [{ var: 'contactFound' }, true] },
},
{
id: 'e7',
source: 'find_contact',
target: 'create_contact',
condition: { '==': [{ var: 'contactFound' }, false] },
},
{ id: 'e8', source: 'create_contact', target: 'create_pet' },
{ id: 'e9', source: 'create_pet', target: 'end' },
],
};
const demoTools = [
'findAccount',
'createAccount',
'findContact',
'createContact',
'createPet',
];
async function seedDemoProcess(tenantSlugOrId: string) {
let app;
try {
console.log(`\n🌱 Seeding demo AI process for tenant: ${tenantSlugOrId}\n`);
const context = await getTenantContext(tenantSlugOrId);
const { tenantId, knex, app: nestApp } = context;
app = nestApp;
console.log(`✓ Resolved tenant ID: ${tenantId}`);
console.log(`✓ Connected to tenant database`);
// Check if process already exists
const existing = await AiProcess.query(knex)
.where('name', demoProcessGraph.name)
.first();
if (existing) {
console.log(`⚠ Process "${demoProcessGraph.name}" already exists (ID: ${existing.id})`);
console.log(` To create a new version, update via the UI.`);
return;
}
// Create process in transaction
await knex.transaction(async (trx) => {
const processId = randomUUID();
const userId = 'system'; // System user for seed data
// Create process
await AiProcess.query(trx).insert({
id: processId,
name: demoProcessGraph.name,
description: demoProcessGraph.description,
latestVersion: 1,
createdBy: userId,
});
console.log(`✓ Created process: ${demoProcessGraph.name} (${processId})`);
// Create initial version
// Note: In production, this would call the compiler service
// For seed, we're storing a simplified version
await AiProcessVersion.query(trx).insert({
id: randomUUID(),
processId,
version: 1,
graphJson: demoProcessGraph,
compiledJson: {
graphId: demoProcessGraph.id,
version: 1,
nodes: demoProcessGraph.nodes,
edges: demoProcessGraph.edges,
startNodeId: 'start',
endNodeIds: ['end'],
adjacency: {},
},
createdBy: userId,
});
console.log(`✓ Created process version 1`);
// Enable demo tools for tenant
for (const toolName of demoTools) {
const existingTool = await AiToolConfig.query(trx)
.where('tool_name', toolName)
.first();
if (!existingTool) {
await AiToolConfig.query(trx).insert({
id: randomUUID(),
toolName,
enabled: true,
});
console.log(`✓ Enabled tool: ${toolName}`);
}
}
});
console.log(`\n✅ Demo process seeded successfully!\n`);
console.log(`Next steps:`);
console.log(` 1. Navigate to /ai-processes in your frontend`);
console.log(` 2. Open the "${demoProcessGraph.name}" process`);
console.log(` 3. Test it by sending a message like:`);
console.log(` "Register a dog named Max, owned by John Smith (john@email.com)"`);
console.log();
if (app) await app.close();
process.exit(0);
} catch (error) {
console.error('❌ Seed failed:', error);
if (app) await app.close();
process.exit(1);
}
}
// Get tenant from command line args
const tenantSlugOrId = process.argv[2];
if (!tenantSlugOrId) {
console.error('Usage: npm run seed:demo-process -- <tenant-slug-or-id>');
process.exit(1);
}
seedDemoProcess(tenantSlugOrId);

View File

@@ -38,4 +38,12 @@ export class AiAssistantController {
payload,
);
}
@Post('suggest-view-name')
async suggestViewName(
@TenantId() tenantId: string,
@Body() payload: { objectLabel: string; filters: any[]; explanation?: string },
) {
return this.aiAssistantService.suggestViewName(tenantId, payload);
}
}

View File

@@ -10,6 +10,5 @@ import { MeilisearchModule } from '../search/meilisearch.module';
imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule],
controllers: [AiAssistantController],
providers: [AiAssistantService],
exports: [AiAssistantService],
})
export class AiAssistantModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,94 @@ export interface AiChatContext {
export interface AiAssistantReply {
reply: string;
action?: 'create_record' | 'collect_fields' | 'clarify';
action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending';
missingFields?: string[];
record?: any;
records?: any[]; // Multiple records when plan execution completes
plan?: RecordCreationPlan;
}
// ============================================
// Entity Discovery Types
// ============================================
export interface EntityFieldInfo {
apiName: string;
label: string;
type: string;
isRequired: boolean;
isSystem: boolean;
referenceObject?: string; // For LOOKUP fields, the target entity
description?: string;
}
export interface EntityRelationship {
fieldApiName: string;
fieldLabel: string;
targetEntity: string;
relationshipType: 'lookup' | 'master-detail' | 'polymorphic';
}
export interface EntityInfo {
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
fields: EntityFieldInfo[];
requiredFields: string[]; // Field apiNames that are required
relationships: EntityRelationship[];
}
export interface SystemEntities {
entities: EntityInfo[];
entityByApiName: Record<string, EntityInfo>; // Changed from Map for state serialization
loadedAt: number;
}
// ============================================
// Planning Types
// ============================================
export interface PlannedRecord {
id: string; // Temporary ID for planning (e.g., "temp_account_1")
entityApiName: string;
entityLabel: string;
fields: Record<string, any>;
resolvedFields?: Record<string, any>; // Fields after dependency resolution
missingRequiredFields: string[];
dependsOn: string[]; // IDs of other planned records this depends on
status: 'pending' | 'ready' | 'created' | 'failed';
createdRecordId?: string; // Actual ID after creation
wasExisting?: boolean; // True if record already existed in database
error?: string;
}
export interface RecordCreationPlan {
id: string;
records: PlannedRecord[];
executionOrder: string[]; // Ordered list of planned record IDs
status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed';
createdRecords: any[];
errors: string[];
}
// ============================================
// State Types
// ============================================
export interface AiAssistantState {
message: string;
messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent
history?: AiChatMessage[];
context: AiChatContext;
// Entity discovery
systemEntities?: SystemEntities;
// Planning
plan?: RecordCreationPlan;
// Legacy fields (kept for compatibility during transition)
objectDefinition?: any;
pageLayout?: any;
extractedFields?: Record<string, any>;

View File

@@ -1,25 +0,0 @@
import { compileProcessGraph, GraphValidationError } from '../ai-processes.compiler';
import { demoRegisterNewPetProcess } from '../demo-process';
describe('ai-processes compiler', () => {
it('throws when missing start node', () => {
const badGraph = {
...demoRegisterNewPetProcess,
nodes: demoRegisterNewPetProcess.nodes.filter((n) => n.type !== 'Start'),
};
expect(() =>
compileProcessGraph(badGraph, { tenantId: 'default', version: 1 }),
).toThrow(GraphValidationError);
});
it('compiles the demo process graph', () => {
const compiled = compileProcessGraph(demoRegisterNewPetProcess, {
tenantId: 'default',
version: 1,
});
expect(compiled.startNodeId).toBe('start');
expect(compiled.endNodeIds).toContain('end');
});
});

View File

@@ -1,40 +0,0 @@
import { compileProcessGraph } from '../ai-processes.compiler';
import { demoRegisterNewPetProcess } from '../demo-process';
import { runCompiledGraph } from '../ai-processes.runner';
import { ToolRegistry } from '../tools/tool-registry';
describe('ai-processes runner', () => {
it('runs the demo process until human input is required', async () => {
const compiled = compileProcessGraph(demoRegisterNewPetProcess, {
tenantId: 'default',
version: 1,
});
const result = await runCompiledGraph({
compiledGraph: compiled,
input: {
accountName: 'Acme Inc',
firstName: 'Jamie',
lastName: 'Doe',
},
toolRegistry: new ToolRegistry(),
toolContext: { tenantId: 'default', userId: 'user-1' },
llmDecision: async (node, state) => {
if (node.id === 'decide_account') {
return { accountAction: 'find', accountName: state.accountName };
}
if (node.id === 'decide_contact') {
return {
contactAction: 'find',
firstName: state.firstName,
lastName: state.lastName,
};
}
return {};
},
});
expect(result.status).toBe('waiting');
expect(result.currentNodeId).toBe('need_pet');
});
});

View File

@@ -1,191 +0,0 @@
import { apply as applyJsonLogic } from 'json-logic-js';
import { createAjv } from './ai-processes.schemas';
import {
CompiledGraph,
ProcessGraphDefinition,
ProcessGraphEdge,
ProcessGraphNode,
} from './ai-processes.types';
import { ToolRegistry } from './tools/tool-registry';
export class GraphValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'GraphValidationError';
}
}
export interface CompileOptions {
tenantId: string;
version: number;
}
export const validateGraphDefinition = (
graph: ProcessGraphDefinition,
tenantId: string,
) => {
const ajv = createAjv();
const validate = ajv.getSchema<ProcessGraphDefinition>('processGraph');
if (!validate) {
throw new GraphValidationError('Graph schema is not registered.');
}
const valid = validate(graph);
if (!valid) {
throw new GraphValidationError(
`Graph schema validation failed: ${ajv.errorsText(validate.errors)}`,
);
}
const startNodes = graph.nodes.filter((node) => node.type === 'Start');
const endNodes = graph.nodes.filter((node) => node.type === 'End');
if (startNodes.length !== 1) {
throw new GraphValidationError('Graph must contain exactly one Start node.');
}
if (endNodes.length < 1) {
throw new GraphValidationError('Graph must contain at least one End node.');
}
const nodeIds = new Set(graph.nodes.map((node) => node.id));
graph.edges.forEach((edge) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
throw new GraphValidationError(`Edge ${edge.id} references unknown nodes.`);
}
});
const adjacency = buildAdjacency(graph.edges);
const reachable = new Set<string>();
const queue = [startNodes[0].id];
while (queue.length) {
const current = queue.shift();
if (!current || reachable.has(current)) continue;
reachable.add(current);
(adjacency[current] || []).forEach((neighbor) => queue.push(neighbor));
}
graph.nodes.forEach((node) => {
if (!reachable.has(node.id)) {
throw new GraphValidationError(`Node ${node.id} is not reachable.`);
}
});
if (!graph.allowCycles && hasCycle(graph.nodes, graph.edges)) {
throw new GraphValidationError('Graph contains cycles but allowCycles=false.');
}
const toolRegistry = new ToolRegistry();
const allToolNames = toolRegistry.getAllToolNames();
graph.nodes.forEach((node) => {
if (node.type === 'ToolNode') {
const toolName = (node.data as { toolName?: string }).toolName;
if (!toolName) {
throw new GraphValidationError(
`ToolNode ${node.id} missing toolName configuration.`,
);
}
// Validate tool exists in registry (allowlist check happens at runtime)
if (!allToolNames.includes(toolName)) {
throw new GraphValidationError(
`Tool ${toolName} is not registered in the tool registry.`,
);
}
}
if (node.type === 'LLMDecisionNode') {
const data = node.data as {
promptTemplate?: string;
inputKeys?: string[];
outputSchema?: Record<string, unknown>;
model?: { name?: string; temperature?: number };
};
if (!data.promptTemplate || !data.outputSchema || !data.model?.name) {
throw new GraphValidationError(
`LLMDecisionNode ${node.id} missing required configuration.`,
);
}
}
if (node.type === 'HumanInputNode') {
const data = node.data as {
requiredFieldsSchema?: Record<string, unknown>;
promptToUser?: string;
};
if (!data.requiredFieldsSchema || !data.promptToUser) {
throw new GraphValidationError(
`HumanInputNode ${node.id} missing required configuration.`,
);
}
}
});
graph.edges.forEach((edge) => {
if (edge.condition) {
try {
applyJsonLogic(edge.condition, {});
} catch (error) {
throw new GraphValidationError(
`Edge ${edge.id} has invalid json-logic condition.`,
);
}
}
});
};
export const compileProcessGraph = (
graph: ProcessGraphDefinition,
options: CompileOptions,
): CompiledGraph => {
validateGraphDefinition(graph, options.tenantId);
const startNodeId = graph.nodes.find((node) => node.type === 'Start')?.id;
if (!startNodeId) {
throw new GraphValidationError('Start node missing after validation.');
}
const endNodeIds = graph.nodes
.filter((node) => node.type === 'End')
.map((node) => node.id);
return {
graphId: graph.id,
version: options.version,
nodes: graph.nodes,
edges: graph.edges,
startNodeId,
endNodeIds,
adjacency: buildAdjacency(graph.edges),
allowCycles: graph.allowCycles,
maxIterations: graph.maxIterations,
};
};
const buildAdjacency = (edges: ProcessGraphEdge[]) => {
return edges.reduce<Record<string, string[]>>((acc, edge) => {
if (!acc[edge.source]) {
acc[edge.source] = [];
}
acc[edge.source].push(edge.target);
return acc;
}, {});
};
const hasCycle = (nodes: ProcessGraphNode[], edges: ProcessGraphEdge[]) => {
const adjacency = buildAdjacency(edges);
const visited = new Set<string>();
const stack = new Set<string>();
const visit = (nodeId: string): boolean => {
if (stack.has(nodeId)) return true;
if (visited.has(nodeId)) return false;
visited.add(nodeId);
stack.add(nodeId);
const neighbors = adjacency[nodeId] || [];
for (const neighbor of neighbors) {
if (visit(neighbor)) return true;
}
stack.delete(nodeId);
return false;
};
return nodes.some((node) => visit(node.id));
};

View File

@@ -1,144 +0,0 @@
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
Sse,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service';
import { CreateAiProcessDto, UpdateAiProcessDto } from './dto/ai-process.dto';
import { CreateAiRunDto, ResumeAiRunDto } from './dto/ai-run.dto';
import { CreateChatSessionDto, SendChatMessageDto } from './dto/ai-chat.dto';
@Controller('tenants/:tenantId')
@UseGuards(JwtAuthGuard)
export class AiProcessesController {
constructor(
private readonly processesService: AiProcessesService,
private readonly streamService: AiProcessesStreamService,
private readonly orchestratorService: AiProcessesOrchestratorService,
) {}
@Get('ai-processes')
async listProcesses(@TenantId() tenantId: string) {
return this.processesService.listProcesses(tenantId);
}
@Post('ai-processes')
async createProcess(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: CreateAiProcessDto,
) {
return this.processesService.createProcess(
tenantId,
user.userId,
payload.name,
payload.description,
payload.graph,
);
}
@Put('ai-processes/:processId')
async updateProcess(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('processId') processId: string,
@Body() payload: UpdateAiProcessDto,
) {
return this.processesService.createProcessVersion(
tenantId,
user.userId,
processId,
payload.graph,
);
}
@Get('ai-processes/:processId/versions')
async listVersions(
@TenantId() tenantId: string,
@Param('processId') processId: string,
) {
return this.processesService.listProcessVersions(tenantId, processId);
}
@Post('ai-processes/:processId/runs')
async createRun(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('processId') processId: string,
@Body() payload: CreateAiRunDto,
) {
return this.processesService.createRun(
tenantId,
user.userId,
processId,
payload.input,
payload.sessionId,
payload.sessionId
? (event) => this.streamService.emit(payload.sessionId as string, event)
: undefined,
);
}
@Post('ai-runs/:runId/resume')
async resumeRun(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('runId') runId: string,
@Body() payload: ResumeAiRunDto,
) {
return this.processesService.resumeRun(
tenantId,
user.userId,
runId,
payload.input,
payload.sessionId,
payload.sessionId
? (event) => this.streamService.emit(payload.sessionId as string, event)
: undefined,
);
}
@Post('ai-chat/sessions')
async createSession(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() _payload: CreateChatSessionDto,
) {
return this.orchestratorService.createSession(tenantId, user.userId);
}
@Post('ai-chat/messages')
@Post('ai-processes/chat/messages')
async sendChatMessage(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: SendChatMessageDto,
) {
return this.orchestratorService.sendMessage(
tenantId,
user.userId,
payload.message,
payload.sessionId,
payload.processId,
payload.history,
payload.context,
);
}
@Sse('ai-chat/stream')
@Sse('ai-processes/stream')
streamChat(@Query('sessionId') sessionId: string) {
return this.streamService.getStream(sessionId);
}
}

View File

@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { TenantModule } from '../tenant/tenant.module';
import { AiAssistantModule } from '../ai-assistant/ai-assistant.module';
import { AiProcessesController } from './ai-processes.controller';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service';
@Module({
imports: [TenantModule, AiAssistantModule],
controllers: [AiProcessesController],
providers: [
AiProcessesService,
AiProcessesStreamService,
AiProcessesOrchestratorService,
],
exports: [AiProcessesService],
})
export class AiProcessesModule {}

View File

@@ -1,212 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiAssistantService } from '../ai-assistant/ai-assistant.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { AiChatMessage, AiChatSession } from '../models/ai-chat.model';
import { DeepAgentOrchestrator } from './deep-agent.orchestrator';
@Injectable()
export class AiProcessesOrchestratorService {
constructor(
private readonly processesService: AiProcessesService,
private readonly streamService: AiProcessesStreamService,
private readonly tenantDbService: TenantDatabaseService,
private readonly aiAssistantService: AiAssistantService,
) {}
private async getTenantContext(tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return { knex, tenantId: resolvedTenantId };
}
private async createSessionWithContext(
knex: Knex,
tenantId: string,
userId: string,
) {
return AiChatSession.query(knex).insert({
userId,
});
}
async createSession(tenantId: string, userId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return this.createSessionWithContext(knex, resolvedTenantId, userId);
}
async sendMessage(
tenantId: string,
userId: string,
message: string,
sessionId?: string,
processId?: string,
history?: { role: string; text: string }[],
context?: Record<string, unknown>,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const session = sessionId
? await AiChatSession.query(knex).findById(sessionId)
: await this.createSessionWithContext(knex, resolvedTenantId, userId);
if (!session) {
throw new Error('Chat session not found.');
}
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'user',
content: message,
});
this.streamService.emit(session.id, { type: 'agent_started' });
const processes = await this.processesService.listProcesses(resolvedTenantId);
this.streamService.emit(session.id, {
type: 'processes_listed',
data: { count: processes.length },
});
// If no processes configured, fallback to standard AI assistant
if (!processes.length) {
const response = await this.aiAssistantService.handleChat(
resolvedTenantId,
userId,
message,
(history ?? []) as any,
context ?? {},
);
this.streamService.emit(session.id, {
type: 'final',
data: { reply: response.reply, action: response.action },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: response.reply,
});
return {
sessionId: session.id,
reply: response.reply,
action: response.action,
record: response.record,
};
}
// Get OpenAI credentials from tenant integrations
const credentials = await this.aiAssistantService.getOpenAiConfig(resolvedTenantId);
if (!credentials?.apiKey) {
throw new Error('OpenAI credentials not configured for this tenant');
}
// Create Deep Agent with tenant's credentials
const deepAgent = new DeepAgentOrchestrator(credentials.apiKey, credentials.model);
// Use Deep Agent to select the best process
const processInfos = processes.map((p) => ({
id: p.id,
name: p.name,
description: p.description || undefined,
}));
const selection = await deepAgent.selectProcess(
message,
processInfos,
history as any,
);
// If we need more information or no match, respond with question
if (selection.action === 'need_more_info' || selection.action === 'no_match') {
const reply = selection.question || selection.reasoning ||
'I\'m not sure which process to use. Could you provide more details?';
this.streamService.emit(session.id, {
type: 'final',
data: { reply, needsMoreInfo: true },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: reply,
});
return { sessionId: session.id, reply, needsMoreInfo: true };
}
// Process selected - find it and execute
const selectedProcess = processes.find((p) => p.id === selection.processId);
if (!selectedProcess) {
throw new Error('Selected process not found.');
}
this.streamService.emit(session.id, {
type: 'process_selected',
processId: selectedProcess.id,
version: selectedProcess.latestVersion,
data: { processName: selectedProcess.name, reasoning: selection.reasoning },
});
// Extract inputs from the message
// For now, we'll use a simple approach - just pass the message as input
// In a more sophisticated implementation, we'd use the deep agent to extract structured inputs
const startMessage = await deepAgent.generateStartMessage(
selectedProcess.name,
{ message },
);
this.streamService.emit(session.id, {
type: 'agent_message',
data: { message: startMessage },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: startMessage,
});
const { run, result } = await this.processesService.createRun(
resolvedTenantId,
userId,
selectedProcess.id,
{ message, context: context || {} },
session.id,
(payload) => this.streamService.emit(session.id, payload),
);
// Emit final event
this.streamService.emit(session.id, {
type: 'final',
data: {
runId: run.id,
status: result.status,
output: result.output,
message: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
},
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
});
return { sessionId: session.id, runId: run.id, status: result.status };
}
}

View File

@@ -1,222 +0,0 @@
import { apply as applyJsonLogic } from 'json-logic-js';
import Ajv from 'ajv';
import { ToolRegistry, ToolContext } from './tools/tool-registry';
import {
AiProcessEventPayload,
CompiledGraph,
ProcessGraphNode,
} from './ai-processes.types';
export interface RunOptions {
compiledGraph: CompiledGraph;
input: Record<string, unknown>;
toolRegistry: ToolRegistry;
toolContext: ToolContext;
onEvent?: (event: AiProcessEventPayload) => void;
llmDecision: (
node: ProcessGraphNode,
state: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
}
export interface RunResult {
status: 'running' | 'waiting' | 'completed' | 'error';
state: Record<string, unknown>;
currentNodeId?: string;
output?: Record<string, unknown>;
error?: Record<string, unknown>;
}
export const runCompiledGraph = async (
options: RunOptions,
startNodeId?: string,
): Promise<RunResult> => {
const {
compiledGraph,
input,
toolRegistry,
toolContext,
onEvent,
llmDecision,
} = options;
const state: Record<string, unknown> = { ...input };
let currentNodeId = startNodeId ?? compiledGraph.startNodeId;
let iterations = 0;
const maxIterations = compiledGraph.maxIterations ?? 50;
const emit = (payload: AiProcessEventPayload) => {
if (onEvent) {
onEvent(payload);
}
};
while (currentNodeId) {
if (
compiledGraph.nodes.length > 0 &&
compiledGraph.endNodeIds.includes(currentNodeId)
) {
emit({ type: 'node_started', nodeId: currentNodeId });
emit({ type: 'node_completed', nodeId: currentNodeId });
emit({ type: 'final', data: { output: state } });
return { status: 'completed', state, output: state };
}
const node = compiledGraph.nodes.find((item) => item.id === currentNodeId);
if (!node) {
return {
status: 'error',
state,
error: { message: `Node ${currentNodeId} not found.` },
};
}
emit({ type: 'node_started', nodeId: node.id });
if (node.type === 'LLMDecisionNode') {
const output = await llmDecision(node, state);
validateNodeOutput(node, output);
Object.assign(state, output);
}
if (node.type === 'ToolNode') {
const toolName = (node.data as { toolName: string }).toolName;
emit({ type: 'tool_called', nodeId: node.id, toolName });
const tool = toolRegistry.getTool(toolName);
const argsTemplate = (node.data as { argsTemplate: Record<string, unknown> })
.argsTemplate;
const resolvedArgs = resolveTemplate(argsTemplate, state);
// Debug logging
console.log(`[ToolNode ${node.id}] Tool: ${toolName}`);
console.log(`[ToolNode ${node.id}] State keys:`, Object.keys(state));
console.log(`[ToolNode ${node.id}] ArgsTemplate:`, JSON.stringify(argsTemplate));
console.log(`[ToolNode ${node.id}] ResolvedArgs:`, JSON.stringify(resolvedArgs));
const toolResult = await tool(toolContext, {
...resolvedArgs,
state,
});
console.log(`[ToolNode ${node.id}] ToolResult:`, JSON.stringify(toolResult));
const outputMapping = (node.data as { outputMapping: Record<string, string> })
.outputMapping;
Object.entries(outputMapping).forEach(([key, path]) => {
console.log(`[ToolNode ${node.id}] Mapping: toolResult['${key}'] = ${toolResult[key]} -> state['${path}']`);
state[path] = toolResult[key];
});
}
if (node.type === 'HumanInputNode') {
const data = node.data as {
requiredFieldsSchema: Record<string, unknown>;
promptToUser: string;
};
emit({
type: 'need_input',
nodeId: node.id,
data: {
requiredFieldsSchema: data.requiredFieldsSchema,
promptToUser: data.promptToUser,
},
});
return { status: 'waiting', state, currentNodeId: node.id };
}
emit({ type: 'node_completed', nodeId: node.id });
const nextTargets = compiledGraph.edges.filter(
(edge) => edge.source === node.id,
);
if (nextTargets.length === 0) {
return {
status: 'error',
state,
error: { message: `No outgoing edges for node ${node.id}.` },
};
}
const selectedEdge = selectEdge(nextTargets, state);
if (!selectedEdge) {
return {
status: 'error',
state,
error: { message: `No edge conditions matched for node ${node.id}.` },
};
}
currentNodeId = selectedEdge.target;
iterations += 1;
if (!compiledGraph.allowCycles && iterations > compiledGraph.nodes.length) {
return {
status: 'error',
state,
error: { message: 'Cycle detected during execution.' },
};
}
if (compiledGraph.allowCycles && iterations > maxIterations) {
return {
status: 'error',
state,
error: { message: 'Max iterations exceeded.' },
};
}
}
return { status: 'completed', state, output: state };
};
const resolveTemplate = (
template: Record<string, unknown>,
state: Record<string, unknown>,
) => {
return Object.entries(template).reduce<Record<string, unknown>>(
(acc, [key, value]) => {
if (typeof value === 'string' && value.startsWith('{{state.')) {
const path = value.replace('{{state.', '').replace('}}', '');
acc[key] = state[path];
} else {
acc[key] = value;
}
return acc;
},
{},
);
};
const selectEdge = (
edges: { condition?: Record<string, unknown>; target: string }[],
state: Record<string, unknown>,
) => {
if (edges.length === 1) return edges[0];
return edges.find((edge) => {
if (!edge.condition) return true;
try {
return Boolean(applyJsonLogic(edge.condition, state));
} catch (error) {
return false;
}
});
};
const validateNodeOutput = (
node: ProcessGraphNode,
output: Record<string, unknown>,
) => {
const schema = (node.data as { outputSchema?: Record<string, unknown> })
.outputSchema;
if (!schema) return;
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
if (!validate(output)) {
const errors = validate.errors?.map(e => `${e.instancePath} ${e.message}`).join(', ');
throw new Error(
`LLM output invalid for node ${node.id}. Errors: ${errors}. Output: ${JSON.stringify(output)}`
);
}
};

View File

@@ -1,79 +0,0 @@
import Ajv, { JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';
import {
AiNodeType,
ProcessGraphDefinition,
ProcessGraphEdge,
ProcessGraphNode,
} from './ai-processes.types';
const nodeTypes: AiNodeType[] = [
'Start',
'LLMDecisionNode',
'ToolNode',
'HumanInputNode',
'End',
];
export const graphSchema: any = {
type: 'object',
required: ['id', 'name', 'nodes', 'edges'],
additionalProperties: false,
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
allowCycles: { type: 'boolean', nullable: true },
maxIterations: { type: 'number', nullable: true },
nodes: {
type: 'array',
items: { $ref: '#/definitions/processGraphNode' },
minItems: 1,
},
edges: {
type: 'array',
items: { $ref: '#/definitions/processGraphEdge' },
minItems: 0,
},
},
definitions: {
processGraphEdge: {
type: 'object',
required: ['id', 'source', 'target'],
additionalProperties: false,
properties: {
id: { type: 'string' },
source: { type: 'string' },
target: { type: 'string' },
condition: { type: 'object', nullable: true },
},
},
processGraphNode: {
type: 'object',
required: ['id', 'type', 'data'],
additionalProperties: false,
properties: {
id: { type: 'string' },
type: { type: 'string', enum: nodeTypes },
position: {
type: 'object',
nullable: true,
required: ['x', 'y'],
additionalProperties: false,
properties: {
x: { type: 'number' },
y: { type: 'number' },
},
},
data: { type: 'object' },
},
},
},
};
export const createAjv = () => {
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema(graphSchema, 'processGraph');
return ajv;
};

View File

@@ -1,319 +0,0 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Knex } from 'knex';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import {
AiAuditEvent,
AiProcess,
AiProcessRun,
AiProcessVersion,
} from '../models/ai-process.model';
import { compileProcessGraph } from './ai-processes.compiler';
import { runCompiledGraph } from './ai-processes.runner';
import {
AiProcessEventPayload,
CompiledGraph,
ProcessGraphDefinition,
} from './ai-processes.types';
import { ToolRegistry } from './tools/tool-registry';
import { demoTools } from './tools/demo-tools';
@Injectable()
export class AiProcessesService {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
private async getTenantContext(tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return { knex, tenantId: resolvedTenantId };
}
async listProcesses(tenantId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return AiProcess.query(knex)
.withGraphFetched('versions')
.orderBy('created_at', 'desc');
}
async getProcess(tenantId: string, processId: string) {
const { knex } = await this.getTenantContext(tenantId);
return AiProcess.query(knex)
.findById(processId)
.withGraphFetched('versions');
}
async createProcess(
tenantId: string,
userId: string,
name: string,
description: string | undefined,
graph: ProcessGraphDefinition,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const compiled = compileProcessGraph(graph, {
tenantId: resolvedTenantId,
version: 1,
});
return knex.transaction(async (trx) => {
const processId = randomUUID();
await AiProcess.query(trx).insert({
id: processId,
name,
description,
latestVersion: 1,
createdBy: userId,
});
await trx('ai_process_versions').insert({
id: randomUUID(),
process_id: processId,
version: 1,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
return AiProcess.query(trx)
.findById(processId)
.withGraphFetched('versions');
});
}
async createProcessVersion(
tenantId: string,
userId: string,
processId: string,
graph: ProcessGraphDefinition,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const process = await AiProcess.query(knex).findById(processId);
if (!process) {
throw new Error('Process not found.');
}
const nextVersion = process.latestVersion + 1;
const compiled = compileProcessGraph(graph, {
tenantId: resolvedTenantId,
version: nextVersion,
});
return knex.transaction(async (trx) => {
await AiProcess.query(trx)
.findById(processId)
.patch({ latestVersion: nextVersion });
const versionId = randomUUID();
await trx('ai_process_versions').insert({
id: versionId,
process_id: processId,
version: nextVersion,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
return AiProcessVersion.query(trx).findById(versionId);
});
}
async listProcessVersions(tenantId: string, processId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return AiProcessVersion.query(knex)
.where({ process_id: processId })
.orderBy('version', 'desc');
}
async createRun(
tenantId: string,
userId: string,
processId: string,
input: Record<string, unknown>,
sessionId: string | undefined,
emitEvent?: (payload: AiProcessEventPayload) => void,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const process = await AiProcess.query(knex).findById(processId);
if (!process) {
throw new Error('Process not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: processId,
version: process.latestVersion,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const runId = randomUUID();
await AiProcessRun.query(knex).insert({
id: runId,
processId,
version: versionRecord.version,
status: 'running',
inputJson: input,
stateJson: input,
currentNodeId: null,
});
const run = await AiProcessRun.query(knex).findById(runId);
if (!run) {
throw new Error('Run not created.');
}
const compiled = versionRecord.compiledJson as unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
onEvent: (event) => emitAndAudit({ ...event, runId, sessionId }),
llmDecision: async (node, state) =>
this.mockDecision(node.id, state),
},
run.currentNodeId ?? undefined,
);
const updatedRun = await this.persistRunResult(runId, result, knex);
return { run: updatedRun, result };
}
async resumeRun(
tenantId: string,
userId: string,
runId: string,
input: Record<string, unknown>,
sessionId: string | undefined,
emitEvent?: (payload: AiProcessEventPayload) => void,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const run = await AiProcessRun.query(knex).findById(runId);
if (!run) {
throw new Error('Run not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: run.processId,
version: run.version,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const compiled = versionRecord.compiledJson as unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const mergedState = { ...(run.stateJson || {}), ...input };
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId: run.id,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input: mergedState,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
onEvent: (event) =>
emitAndAudit({ ...event, runId: run.id, sessionId }),
llmDecision: async (node, state) =>
this.mockDecision(node.id, state),
},
run.currentNodeId ?? undefined,
);
const updatedRun = await this.persistRunResult(run.id, result, knex);
return { run: updatedRun, result };
}
private async persistRunResult(runId: string, result: any, knex: Knex) {
const endedAt =
result.status === 'completed' || result.status === 'error'
? new Date()
: null;
return AiProcessRun.query(knex).patchAndFetchById(runId, {
status: result.status,
outputJson: result.output,
errorJson: result.error,
stateJson: result.state,
currentNodeId: result.currentNodeId ?? null,
endedAt,
});
}
private async mockDecision(
nodeId: string,
state: Record<string, unknown>,
) {
if (nodeId === 'extract_info') {
// Extract pet registration info from the message
const message = (state.message as string) || '';
// Simple extraction (in production, this would use an LLM)
const petNameMatch = message.match(/(?:dog|cat|pet)\s+named\s+(\w+)/i);
const petTypeMatch = message.match(/(dog|cat)/i);
const ownerNameMatch = message.match(/owned\s+by\s+([\w\s]+?)(?:\s*\(|$)/i);
const emailMatch = message.match(/\(?([\w\.-]+@[\w\.-]+\.\w+)\)?/i);
const ownerName = ownerNameMatch?.[1]?.trim() || 'Unknown Owner';
const nameParts = ownerName.split(/\s+/);
const firstName = nameParts[0] || 'Unknown';
const lastName = nameParts.slice(1).join(' ') || 'Owner';
return {
petName: petNameMatch?.[1] || 'Unknown Pet',
species: petTypeMatch?.[1]?.toLowerCase() || 'dog',
ownerFirstName: firstName,
ownerLastName: lastName,
ownerEmail: emailMatch?.[1] || null,
accountName: `${firstName} ${lastName}`,
};
}
if (nodeId === 'decide_account') {
const accountName = (state.accountName as string) ?? 'New Account';
const accountAction = state.accountId ? 'find' : 'create';
return { accountAction, accountName };
}
if (nodeId === 'decide_contact') {
const firstName = (state.firstName as string) ?? 'Jane';
const lastName = (state.lastName as string) ?? 'Doe';
const contactAction = state.contactId ? 'find' : 'create';
return { contactAction, firstName, lastName };
}
return {};
}
}

View File

@@ -1,33 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MessageEvent } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
import { AiProcessEventPayload } from './ai-processes.types';
@Injectable()
export class AiProcessesStreamService {
private readonly streams = new Map<string, Subject<MessageEvent>>();
getStream(sessionId: string): Observable<MessageEvent> {
return this.getSubject(sessionId).asObservable();
}
emit(sessionId: string, payload: AiProcessEventPayload) {
const subject = this.getSubject(sessionId);
subject.next({ type: payload.type, data: payload });
}
close(sessionId: string) {
const subject = this.streams.get(sessionId);
if (subject) {
subject.complete();
this.streams.delete(sessionId);
}
}
private getSubject(sessionId: string) {
if (!this.streams.has(sessionId)) {
this.streams.set(sessionId, new Subject<MessageEvent>());
}
return this.streams.get(sessionId) as Subject<MessageEvent>;
}
}

View File

@@ -1,125 +0,0 @@
import { JSONSchema7 } from 'json-schema';
export type AiNodeType =
| 'Start'
| 'LLMDecisionNode'
| 'ToolNode'
| 'HumanInputNode'
| 'End';
export interface ProcessGraphDefinition {
id: string;
name: string;
description?: string;
allowCycles?: boolean;
maxIterations?: number;
nodes: ProcessGraphNode[];
edges: ProcessGraphEdge[];
}
export interface ProcessGraphNode {
id: string;
type: AiNodeType;
position?: { x: number; y: number };
data:
| StartNodeData
| LLMDecisionNodeData
| ToolNodeData
| HumanInputNodeData
| EndNodeData;
}
export interface ProcessGraphEdge {
id: string;
source: string;
target: string;
condition?: JsonLogicExpression;
}
export type JsonLogicExpression = Record<string, unknown>;
export interface StartNodeData {
label?: string;
}
export interface EndNodeData {
label?: string;
}
export interface LLMDecisionNodeData {
label?: string;
promptTemplate: string;
inputKeys: string[];
outputSchema: JSONSchema7;
model: {
name: string;
temperature: number;
};
}
export interface ToolNodeData {
label?: string;
toolName: string;
argsTemplate: Record<string, unknown>;
outputMapping: Record<string, string>;
}
export interface HumanInputNodeData {
label?: string;
requiredFieldsSchema: JSONSchema7;
promptToUser: string;
}
export interface CompiledGraph {
graphId: string;
version: number;
nodes: ProcessGraphNode[];
edges: ProcessGraphEdge[];
startNodeId: string;
endNodeIds: string[];
adjacency: Record<string, string[]>;
allowCycles?: boolean;
maxIterations?: number;
}
export type AiProcessStatus = 'running' | 'waiting' | 'completed' | 'error';
export interface AiProcessRunContext {
state: Record<string, unknown>;
currentNodeId?: string;
iterationCount?: number;
}
export type AiProcessEventType =
| 'agent_started'
| 'processes_listed'
| 'process_selected'
| 'agent_message'
| 'node_started'
| 'tool_called'
| 'node_completed'
| 'need_input'
| 'final'
| 'error';
export interface AiProcessEventPayload {
type: AiProcessEventType;
runId?: string;
sessionId?: string;
nodeId?: string;
toolName?: string;
processId?: string;
version?: number;
data?: Record<string, unknown>;
}
export interface NeedInputPayload {
runId: string;
requiredFieldsSchema: JSONSchema7;
promptToUser: string;
}
export interface ProcessSelection {
processId: string;
version: number;
}

View File

@@ -1,202 +0,0 @@
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
export interface ProcessInfo {
id: string;
name: string;
description?: string;
}
export interface ProcessSelectionResult {
action: 'select_process' | 'need_more_info' | 'no_match';
processId?: string;
question?: string;
reasoning?: string;
}
export interface InputExtractionResult {
hasAllInputs: boolean;
extractedInputs: Record<string, unknown>;
missingFields?: string[];
question?: string;
}
export class DeepAgentOrchestrator {
private model: ChatOpenAI;
constructor(
apiKey: string,
modelName: string = 'gpt-4o',
temperature: number = 0,
) {
this.model = new ChatOpenAI({
apiKey,
modelName,
temperature,
});
}
/**
* Step 1: Select the best matching process from available processes
*/
async selectProcess(
userMessage: string,
availableProcesses: ProcessInfo[],
conversationHistory?: { role: string; text: string }[],
): Promise<ProcessSelectionResult> {
const processList = availableProcesses
.map((p) => `- ${p.name} (ID: ${p.id}): ${p.description || 'No description'}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const systemPrompt = `You are an intelligent process orchestrator. Your task is to select the most appropriate business process based on the user's request.
Available processes:
${processList}
Rules:
1. Select exactly ONE process that best matches the user's intent
2. If the request is ambiguous or matches multiple processes, ask for clarification
3. If no process matches, indicate no match
4. Always provide reasoning for your decision
Respond with JSON:
{
"action": "select_process" | "need_more_info" | "no_match",
"processId": "selected process ID or null",
"question": "clarifying question if needed",
"reasoning": "brief explanation of decision"
}`;
const userPrompt = `User request: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<ProcessSelectionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
action: 'no_match',
reasoning: 'Failed to parse LLM response',
};
} catch (error: any) {
console.error('Process selection error:', error);
return {
action: 'no_match',
reasoning: `Error: ${error.message}`,
};
}
}
/**
* Step 2: Extract required inputs from user message
*/
async extractInputs(
userMessage: string,
requiredFields: { name: string; description: string; required: boolean }[],
conversationHistory?: { role: string; text: string }[],
context?: Record<string, unknown>,
): Promise<InputExtractionResult> {
const fieldsList = requiredFields
.map((f) => `- ${f.name} (${f.required ? 'required' : 'optional'}): ${f.description}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const contextInfo = context ? `\n\nAvailable context: ${JSON.stringify(context)}` : '';
const systemPrompt = `You are an input extraction assistant. Extract structured data from the user's message and conversation history.
Required fields for this process:
${fieldsList}${contextInfo}
Rules:
1. Extract as many fields as possible from the message and context
2. Only mark hasAllInputs=true if ALL required fields are present
3. If required fields are missing, generate a natural question to ask the user
4. Use context data when available (e.g., current page context)
Respond with JSON:
{
"hasAllInputs": true | false,
"extractedInputs": { "field1": "value1", ... },
"missingFields": ["field1", "field2"] or undefined,
"question": "natural language question" or undefined
}`;
const userPrompt = `User message: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<InputExtractionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
hasAllInputs: false,
extractedInputs: {},
missingFields: requiredFields.filter((f) => f.required).map((f) => f.name),
question: 'I need more information to proceed. Could you provide additional details?',
};
} catch (error: any) {
console.error('Input extraction error:', error);
return {
hasAllInputs: false,
extractedInputs: {},
question: 'I encountered an error processing your request. Please try again.',
};
}
}
/**
* Step 3: Generate a friendly response explaining what will happen
*/
async generateStartMessage(
processName: string,
extractedInputs: Record<string, unknown>,
): Promise<string> {
const systemPrompt = `You are a friendly assistant explaining what process will be executed. Be concise and clear.`;
const userPrompt = `Generate a brief message (1-2 sentences) confirming that you will execute the "${processName}" process with these inputs: ${JSON.stringify(extractedInputs)}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
return (response.content as string).trim();
} catch (error) {
return `I'll execute the ${processName} process with your provided information.`;
}
}
}

View File

@@ -1,173 +0,0 @@
import { ProcessGraphDefinition } from './ai-processes.types';
export const demoRegisterNewPetProcess: ProcessGraphDefinition = {
id: 'register_new_pet',
name: 'Register New Pet',
description: 'Resolve account/contact then create pet.',
allowCycles: false,
nodes: [
{
id: 'start',
type: 'Start',
data: { label: 'Start' },
},
{
id: 'decide_account',
type: 'LLMDecisionNode',
data: {
label: 'Decide Account Action',
promptTemplate:
'Decide whether to find or create an account. Return JSON {"accountAction":"find|create","accountName":"string"}.',
inputKeys: ['accountName'],
outputSchema: {
type: 'object',
required: ['accountAction', 'accountName'],
properties: {
accountAction: { type: 'string', enum: ['find', 'create'] },
accountName: { type: 'string' },
},
additionalProperties: false,
},
model: { name: 'gpt-4o-mini', temperature: 0 },
},
},
{
id: 'find_account',
type: 'ToolNode',
data: {
label: 'Find Account',
toolName: 'findAccount',
argsTemplate: { accountName: '{{state.accountName}}' },
outputMapping: { accountId: 'accountId', found: 'accountFound' },
},
},
{
id: 'create_account',
type: 'ToolNode',
data: {
label: 'Create Account',
toolName: 'createAccount',
argsTemplate: { accountName: '{{state.accountName}}' },
outputMapping: { accountId: 'accountId' },
},
},
{
id: 'decide_contact',
type: 'LLMDecisionNode',
data: {
label: 'Decide Contact Action',
promptTemplate:
'Decide whether to find or create a contact. Return JSON {"contactAction":"find|create","firstName":"string","lastName":"string"}.',
inputKeys: ['firstName', 'lastName'],
outputSchema: {
type: 'object',
required: ['contactAction', 'firstName', 'lastName'],
properties: {
contactAction: { type: 'string', enum: ['find', 'create'] },
firstName: { type: 'string' },
lastName: { type: 'string' },
},
additionalProperties: false,
},
model: { name: 'gpt-4o-mini', temperature: 0 },
},
},
{
id: 'find_contact',
type: 'ToolNode',
data: {
label: 'Find Contact',
toolName: 'findContact',
argsTemplate: {
accountId: '{{state.accountId}}',
firstName: '{{state.firstName}}',
lastName: '{{state.lastName}}',
},
outputMapping: { contactId: 'contactId', found: 'contactFound' },
},
},
{
id: 'create_contact',
type: 'ToolNode',
data: {
label: 'Create Contact',
toolName: 'createContact',
argsTemplate: {
accountId: '{{state.accountId}}',
firstName: '{{state.firstName}}',
lastName: '{{state.lastName}}',
},
outputMapping: { contactId: 'contactId' },
},
},
{
id: 'need_pet',
type: 'HumanInputNode',
data: {
label: 'Collect Pet Info',
promptToUser: 'What is the pet name and type?',
requiredFieldsSchema: {
type: 'object',
required: ['petName', 'petType'],
properties: {
petName: { type: 'string' },
petType: { type: 'string' },
},
additionalProperties: false,
},
},
},
{
id: 'create_pet',
type: 'ToolNode',
data: {
label: 'Create Pet',
toolName: 'createPet',
argsTemplate: {
contactId: '{{state.contactId}}',
petName: '{{state.petName}}',
petType: '{{state.petType}}',
},
outputMapping: { petId: 'petId' },
},
},
{
id: 'end',
type: 'End',
data: { label: 'End' },
},
],
edges: [
{ id: 'e_start_account', source: 'start', target: 'decide_account' },
{
id: 'e_account_find',
source: 'decide_account',
target: 'find_account',
condition: { '==': [{ var: 'accountAction' }, 'find'] },
},
{
id: 'e_account_create',
source: 'decide_account',
target: 'create_account',
condition: { '==': [{ var: 'accountAction' }, 'create'] },
},
{ id: 'e_account_to_contact', source: 'find_account', target: 'decide_contact' },
{ id: 'e_create_account_to_contact', source: 'create_account', target: 'decide_contact' },
{
id: 'e_contact_find',
source: 'decide_contact',
target: 'find_contact',
condition: { '==': [{ var: 'contactAction' }, 'find'] },
},
{
id: 'e_contact_create',
source: 'decide_contact',
target: 'create_contact',
condition: { '==': [{ var: 'contactAction' }, 'create'] },
},
{ id: 'e_contact_to_pet', source: 'find_contact', target: 'need_pet' },
{ id: 'e_create_contact_to_pet', source: 'create_contact', target: 'need_pet' },
{ id: 'e_need_pet_to_create', source: 'need_pet', target: 'create_pet' },
{ id: 'e_pet_to_end', source: 'create_pet', target: 'end' },
],
};

View File

@@ -1,28 +0,0 @@
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateChatSessionDto {
@IsOptional()
@IsString()
context?: string;
}
export class SendChatMessageDto {
@IsString()
message!: string;
@IsOptional()
@IsArray()
history?: { role: string; text: string }[];
@IsOptional()
@IsObject()
context?: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
@IsOptional()
@IsString()
processId?: string;
}

View File

@@ -1,24 +0,0 @@
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
import { ProcessGraphDefinition } from '../ai-processes.types';
export class CreateAiProcessDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsObject()
graph!: ProcessGraphDefinition;
}
export class UpdateAiProcessDto {
@IsObject()
graph!: ProcessGraphDefinition;
}
export class AiProcessListResponseDto {
@IsArray()
items!: Record<string, unknown>[];
}

View File

@@ -1,19 +0,0 @@
import { IsObject, IsOptional, IsString } from 'class-validator';
export class CreateAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}
export class ResumeAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}

View File

@@ -1,226 +0,0 @@
import { ToolContext, ToolHandler } from './tool-registry';
import { Account } from '../../models/account.model';
import { Contact } from '../../models/contact.model';
import { randomUUID } from 'crypto';
/**
* Demo tools that wrap ObjectService operations
* These tools provide structured access to CRM entities
*/
export const findAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findAccount');
}
const { name } = args as { name?: string };
if (!name) {
return { found: false, accountId: null, message: 'Name required' };
}
try {
const query = Account.query(ctx.knex).where('name', 'like', `%${name}%`);
const account = await query.first();
if (account) {
return {
found: true,
accountId: account.id,
account: {
id: account.id,
name: account.name,
},
};
}
return { found: false, accountId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createAccount');
}
const { name, email, phone, industry } = args as {
name: string;
email?: string;
phone?: string;
industry?: string;
};
if (!name) {
throw new Error('Account name is required');
}
try {
const accountId = randomUUID();
await ctx.knex('accounts').insert({
id: accountId,
name,
phone,
industry,
ownerId: ctx.userId,
});
return {
success: true,
accountId,
account: {
id: accountId,
name,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const findContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findContact');
}
const { firstName, lastName, accountId } = args as {
firstName?: string;
lastName?: string;
accountId?: string;
};
if (!firstName && !lastName) {
return {
found: false,
contactId: null,
message: 'First name or last name required',
};
}
try {
let query = Contact.query(ctx.knex);
if (firstName) {
query = query.where('firstName', 'like', `%${firstName}%`);
}
if (lastName) {
query = query.where('lastName', 'like', `%${lastName}%`);
}
if (accountId) {
query = query.where('accountId', accountId);
}
const contact = await query.first();
if (contact) {
return {
found: true,
contactId: contact.id,
contact: {
id: contact.id,
firstName: contact.firstName,
lastName: contact.lastName,
accountId: contact.accountId,
},
};
}
return { found: false, contactId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createContact');
}
const { firstName, lastName, email, phone, accountId } = args as {
firstName: string;
lastName: string;
email?: string;
phone?: string;
accountId?: string;
};
if (!firstName || !lastName) {
throw new Error('First name and last name are required');
}
try {
const contactId = randomUUID();
await ctx.knex('contacts').insert({
id: contactId,
firstName,
lastName,
accountId,
ownerId: ctx.userId,
});
return {
success: true,
contactId,
contact: {
id: contactId,
firstName,
lastName,
accountId,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const createPet: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createPet');
}
const { name, species, breed, age, ownerId } = args as {
name: string;
species: string;
breed?: string;
age?: number;
ownerId: string; // Contact ID
};
if (!name || !ownerId) {
throw new Error('Pet name and owner (contact) are required');
}
try {
const petId = randomUUID();
// Get the accountId from the contact
const contact = await ctx.knex('contacts').where('id', ownerId).first();
// Insert into dogs table
await ctx.knex('dogs').insert({
id: petId,
name,
ownerId,
accountId: contact?.accountId,
});
return {
success: true,
petId,
pet: { id: petId, name, ownerId, accountId: contact?.accountId },
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
// Export all demo tools
export const demoTools = {
findAccount,
createAccount,
findContact,
createContact,
createPet,
};

View File

@@ -1,89 +0,0 @@
import { Knex } from 'knex';
import { AiToolConfig } from '../../models/ai-process.model';
export interface ToolContext {
tenantId: string;
userId: string;
knex?: Knex;
authScopes?: string[];
}
export type ToolHandler = (
ctx: ToolContext,
args: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
export interface ToolDefinition {
name: string;
description: string;
handler: ToolHandler;
inputSchema?: Record<string, unknown>;
}
const defaultTools: Record<string, ToolHandler> = {
findAccount: async () => ({ accountId: null, found: false }),
createAccount: async (_ctx, args) => ({ accountId: `acc_${Date.now()}`, args }),
findContact: async () => ({ contactId: null, found: false }),
createContact: async (_ctx, args) => ({ contactId: `con_${Date.now()}`, args }),
createPet: async (_ctx, args) => ({ petId: `pet_${Date.now()}`, args }),
};
const tenantAllowlist: Record<string, string[]> = {
default: Object.keys(defaultTools),
};
export class ToolRegistry {
private tools: Record<string, ToolHandler>;
private allowlist: Record<string, string[]>;
private dbAllowlistCache: Map<string, Set<string>> = new Map();
constructor(
tools: Record<string, ToolHandler> = defaultTools,
allowlist: Record<string, string[]> = tenantAllowlist,
) {
this.tools = tools;
this.allowlist = allowlist;
}
registerTool(name: string, handler: ToolHandler) {
this.tools[name] = handler;
}
async loadTenantAllowlist(tenantId: string, knex: Knex) {
const configs = await AiToolConfig.query(knex)
.where('enabled', true);
const allowed = new Set(configs.map((c) => c.toolName));
this.dbAllowlistCache.set(tenantId, allowed);
return allowed;
}
async isToolAllowed(tenantId: string, toolName: string, knex?: Knex) {
// Check database cache first
if (this.dbAllowlistCache.has(tenantId)) {
return this.dbAllowlistCache.get(tenantId)!.has(toolName);
}
// Load from database if knex provided
if (knex) {
const allowed = await this.loadTenantAllowlist(tenantId, knex);
return allowed.has(toolName);
}
// Fallback to static allowlist
const allowed = this.allowlist[tenantId] || this.allowlist.default || [];
return allowed.includes(toolName);
}
getTool(toolName: string): ToolHandler {
const tool = this.tools[toolName];
if (!tool) {
throw new Error(`Tool ${toolName} is not registered.`);
}
return tool;
}
getAllToolNames(): string[] {
return Object.keys(this.tools);
}
}

View File

@@ -10,14 +10,14 @@ export class AppBuilderService {
// Runtime endpoints
async getApps(tenantId: string, userId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
// For now, return all apps
// In production, you'd filter by user permissions
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
}
async getApp(tenantId: string, slug: string, userId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await App.query(knex)
.findOne({ slug })
.withGraphFetched('pages');
@@ -35,7 +35,7 @@ export class AppBuilderService {
pageSlug: string,
userId: string,
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getApp(tenantId, appSlug, userId);
const page = await AppPage.query(knex).findOne({
@@ -52,12 +52,12 @@ export class AppBuilderService {
// Setup endpoints
async getAllApps(tenantId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
}
async getAppForSetup(tenantId: string, slug: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await App.query(knex)
.findOne({ slug })
.withGraphFetched('pages');
@@ -77,7 +77,7 @@ export class AppBuilderService {
description?: string;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
return App.query(knex).insert({
...data,
displayOrder: 0,
@@ -92,7 +92,7 @@ export class AppBuilderService {
description?: string;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, slug);
return App.query(knex).patchAndFetchById(app.id, data);
@@ -109,7 +109,7 @@ export class AppBuilderService {
sortOrder?: number;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug);
return AppPage.query(knex).insert({
@@ -133,7 +133,7 @@ export class AppBuilderService {
sortOrder?: number;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug);
const page = await AppPage.query(knex).findOne({

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from './prisma/prisma.module';
import { TenantModule } from './tenant/tenant.module';
import { AuthModule } from './auth/auth.module';
@@ -9,13 +10,20 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
import { PageLayoutModule } from './page-layout/page-layout.module';
import { VoiceModule } from './voice/voice.module';
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
import { AiProcessesModule } from './ai-processes/ai-processes.module';
import { SavedListViewModule } from './saved-list-view/saved-list-view.module';
import { KnowledgeModule } from './knowledge/knowledge.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST || 'platform-redis',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
}),
PrismaModule,
TenantModule,
AuthModule,
@@ -25,7 +33,8 @@ import { AiProcessesModule } from './ai-processes/ai-processes.module';
PageLayoutModule,
VoiceModule,
AiAssistantModule,
AiProcessesModule,
SavedListViewModule,
KnowledgeModule,
],
})
export class AppModule {}

View File

@@ -1,15 +1,19 @@
import {
Controller,
Post,
Get,
Body,
UnauthorizedException,
HttpCode,
HttpStatus,
Req,
UseGuards,
} from '@nestjs/common';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { AuthService } from './auth.service';
import { TenantId } from '../tenant/tenant.decorator';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
class LoginDto {
@IsEmail()
@@ -111,4 +115,15 @@ export class AuthController {
// This endpoint exists for consistency and potential future enhancements
return { message: 'Logged out successfully' };
}
@UseGuards(JwtAuthGuard)
@Get('me')
async me(@CurrentUser() user: any, @TenantId() tenantId: string) {
// Return the current authenticated user info
return {
id: user.userId,
email: user.email,
tenantId: tenantId || user.tenantId,
};
}
}

View File

@@ -29,7 +29,7 @@ export class AuthService {
}
// Otherwise, validate as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId);
const user = await tenantDb('users')
.where({ email })
@@ -113,7 +113,7 @@ export class AuthService {
}
// Otherwise, register as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId);
const hashedPassword = await bcrypt.hash(password, 10);

View File

@@ -0,0 +1,89 @@
export type SemanticProjectionInput = {
objectApiName: string;
record: Record<string, any>;
objectDefinition?: any;
comments: Array<{ id: string; content: string; author_user_id: string; created_at?: string }>;
};
export type SemanticProjection = {
entityType: string;
entityId: string;
title: string;
narrative: string;
/** Plain text used for embedding — no 'key: value' labels, no comments (chunker handles those separately). */
embeddingNarrative: string;
metadata: Record<string, any>;
sourceSummary: {
includedFieldCount: number;
includedCommentCount: number;
includesComments: boolean;
};
};
export interface SemanticProjectionAdapter {
supports(objectApiName: string): boolean;
buildProjection(input: SemanticProjectionInput): SemanticProjection;
}
const EXCLUDED_FIELDS = new Set([
'id',
'created_at',
'updated_at',
'ownerId',
'owner_id',
'tenantId',
'tenant_id',
]);
export class DefaultSemanticProjectionAdapter implements SemanticProjectionAdapter {
supports(): boolean {
return true;
}
buildProjection(input: SemanticProjectionInput): SemanticProjection {
const fieldEntries = Object.entries(input.record || {}).filter(([key, value]) => {
if (EXCLUDED_FIELDS.has(key)) return false;
if (value === null || value === undefined || value === '') return false;
return ['string', 'number', 'boolean'].includes(typeof value);
});
const title =
input.record?.name ||
input.record?.title ||
input.record?.subject ||
`${input.objectApiName} ${input.record?.id || ''}`.trim();
const fieldNarrative = fieldEntries
.map(([key, value]) => `${key}: ${String(value)}`)
.join('\n');
const commentNarrative = (input.comments || [])
.map((comment, index) => `Comment ${index + 1}: ${comment.content}`)
.join('\n');
const narrative = [fieldNarrative, commentNarrative].filter(Boolean).join('\n\n');
// Plain values only — no 'key:' prefixes. Comments are handled separately by the chunker.
const embeddingNarrative = fieldEntries
.map(([, value]) => String(value))
.join('\n');
return {
entityType: input.objectApiName,
entityId: input.record.id,
title,
narrative,
embeddingNarrative,
metadata: {
objectApiName: input.objectApiName,
hasComments: (input.comments || []).length > 0,
},
sourceSummary: {
includedFieldCount: fieldEntries.length,
includedCommentCount: (input.comments || []).length,
includesComments: (input.comments || []).length > 0,
},
};
}
}

View File

@@ -0,0 +1,24 @@
import { IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
parentObjectApiName: string;
@IsString()
@IsNotEmpty()
parentRecordId: string;
@IsString()
@MinLength(1)
@MaxLength(10000)
content: string;
}
export class UpdateCommentDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(10000)
content?: string;
}

View File

@@ -0,0 +1,52 @@
import { IsIn, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
export const SEMANTIC_LINK_STATUSES = ['suggested', 'approved', 'rejected', 'dismissed'] as const;
export const SEMANTIC_LINK_ORIGINS = ['manual', 'semantic', 'llm', 'hybrid', 'rule_based'] as const;
export class ReviewSemanticLinkDto {
@IsString()
@IsIn(SEMANTIC_LINK_STATUSES)
status: (typeof SEMANTIC_LINK_STATUSES)[number];
}
export class UpsertSemanticLinkDto {
@IsString()
sourceEntityType: string;
@IsString()
sourceEntityId: string;
@IsString()
targetEntityType: string;
@IsString()
targetEntityId: string;
@IsOptional()
@IsString()
linkType?: string;
@IsOptional()
@IsString()
@IsIn(SEMANTIC_LINK_STATUSES)
status?: (typeof SEMANTIC_LINK_STATUSES)[number];
@IsOptional()
@IsString()
@IsIn(SEMANTIC_LINK_ORIGINS)
origin?: (typeof SEMANTIC_LINK_ORIGINS)[number];
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
confidence?: number;
@IsOptional()
@IsString()
reason?: string;
@IsOptional()
@IsObject()
evidence?: Record<string, any>;
}

View File

@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { CreateCommentDto, UpdateCommentDto } from './dto/comment.dto';
import { ReviewSemanticLinkDto } from './dto/semantic-link.dto';
import { CommentService } from './services/comment.service';
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
import { SemanticLinkService } from './services/semantic-link.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
@Controller('knowledge')
@UseGuards(JwtAuthGuard)
export class KnowledgeController {
constructor(
private readonly commentService: CommentService,
private readonly semanticOrchestratorService: SemanticOrchestratorService,
private readonly semanticLinkService: SemanticLinkService,
private readonly tenantDbService: TenantDatabaseService,
) {}
@Get('comments/:objectApiName/:recordId')
async getComments(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
) {
return this.commentService.listComments(tenantId, objectApiName, recordId);
}
@Post('comments')
async createComment(
@TenantId() tenantId: string,
@Body() dto: CreateCommentDto,
@CurrentUser() user: any,
) {
return this.commentService.createComment(tenantId, dto, user.userId);
}
@Patch('comments/:id')
async updateComment(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() dto: UpdateCommentDto,
@CurrentUser() user: any,
) {
return this.commentService.updateComment(tenantId, id, dto, user.userId);
}
@Delete('comments/:id')
async deleteComment(
@TenantId() tenantId: string,
@Param('id') id: string,
@CurrentUser() user: any,
) {
return this.commentService.deleteComment(tenantId, id, user.userId);
}
@Post('semantic/refresh/:objectApiName/:recordId')
async refreshSemantic(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@CurrentUser() user: any,
) {
return this.semanticOrchestratorService.refreshRecord(
tenantId,
objectApiName,
recordId,
user.userId,
'manual_refresh',
);
}
@Post('semantic/reindex/:objectApiName')
async reindexObject(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@CurrentUser() user: any,
@Query('limit') limit?: string,
) {
const parsedLimit = Number.isFinite(Number(limit)) ? Number(limit) : 250;
return this.semanticOrchestratorService.reindexObject(
tenantId,
objectApiName,
user.userId,
parsedLimit,
);
}
@Get('semantic/links/:objectApiName/:recordId')
async listLinks(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@Query('status') status?: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return this.semanticLinkService.listForRecord(knex, objectApiName, recordId, status);
}
@Patch('semantic/links/:id/review')
async reviewLink(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() dto: ReviewSemanticLinkDto,
@CurrentUser() user: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return this.semanticLinkService.reviewLink(knex, id, dto.status, user.userId);
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { KnowledgeController } from './knowledge.controller';
import { CommentService } from './services/comment.service';
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
import { SemanticChunkerService } from './services/semantic-chunker.service';
import { SemanticLinkService } from './services/semantic-link.service';
import { SemanticRefreshQueueService } from './services/semantic-refresh-queue.service';
import { SemanticRefreshProcessor } from './semantic-refresh.processor';
import { TenantModule } from '../tenant/tenant.module';
import { MeilisearchModule } from '../search/meilisearch.module';
import { SEMANTIC_REFRESH_QUEUE } from './semantic-refresh.constants';
@Module({
imports: [
TenantModule,
MeilisearchModule,
BullModule.registerQueue({ name: SEMANTIC_REFRESH_QUEUE }),
],
controllers: [KnowledgeController],
providers: [
CommentService,
SemanticOrchestratorService,
SemanticChunkerService,
SemanticLinkService,
SemanticRefreshQueueService,
SemanticRefreshProcessor,
],
exports: [SemanticOrchestratorService, SemanticRefreshQueueService],
})
export class KnowledgeModule {}

View File

@@ -0,0 +1,3 @@
export const SEMANTIC_REFRESH_QUEUE = 'semantic-refresh';
export const SEMANTIC_REFRESH_JOB = 'refresh-record';

View File

@@ -0,0 +1,45 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
import { SEMANTIC_REFRESH_QUEUE } from './semantic-refresh.constants';
export type SemanticRefreshJobData = {
tenantId: string;
objectApiName: string;
recordId: string;
userId?: string;
trigger: string;
};
@Processor(SEMANTIC_REFRESH_QUEUE)
export class SemanticRefreshProcessor extends WorkerHost {
private readonly logger = new Logger(SemanticRefreshProcessor.name);
constructor(
private readonly semanticOrchestratorService: SemanticOrchestratorService,
) {
super();
}
async process(job: Job<SemanticRefreshJobData>): Promise<void> {
const { tenantId, objectApiName, recordId, userId, trigger } = job.data;
this.logger.log(
`Processing semantic refresh: ${objectApiName}:${recordId} trigger=${trigger}`,
);
try {
await this.semanticOrchestratorService.refreshRecord(
tenantId,
objectApiName,
recordId,
userId,
trigger,
);
} catch (error) {
this.logger.error(
`Semantic refresh failed: ${objectApiName}:${recordId} trigger=${trigger} error=${error.message}`,
);
throw error; // Let BullMQ handle retries
}
}
}

View File

@@ -0,0 +1,115 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
import { CreateCommentDto, UpdateCommentDto } from '../dto/comment.dto';
import { SemanticRefreshQueueService } from './semantic-refresh-queue.service';
@Injectable()
export class CommentService {
constructor(
private readonly tenantDbService: TenantDatabaseService,
private readonly semanticRefreshQueue: SemanticRefreshQueueService,
) {}
async listComments(tenantId: string, parentObjectApiName: string, parentRecordId: string) {
const knex = await this.getKnex(tenantId);
return knex('comments')
.where({
parent_object_api_name: parentObjectApiName,
parent_record_id: parentRecordId,
})
.orderBy('created_at', 'desc');
}
async createComment(tenantId: string, dto: CreateCommentDto, userId: string) {
const knex = await this.getKnex(tenantId);
const [created] = await knex('comments')
.insert({
parent_object_api_name: dto.parentObjectApiName,
parent_record_id: dto.parentRecordId,
author_user_id: userId,
content: dto.content,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
})
.returning('*');
console.log(
`[Knowledge] Comment created: ${dto.parentObjectApiName}:${dto.parentRecordId} by ${userId}`,
);
await this.semanticRefreshQueue.enqueue(
tenantId,
dto.parentObjectApiName,
dto.parentRecordId,
userId,
'comment_created',
);
return created;
}
async updateComment(tenantId: string, commentId: string, dto: UpdateCommentDto, userId: string) {
const knex = await this.getKnex(tenantId);
const existing = await knex('comments').where({ id: commentId }).first();
if (!existing) {
throw new NotFoundException('Comment not found');
}
if (existing.author_user_id !== userId) {
throw new ForbiddenException('Only the author can edit this comment');
}
await knex('comments')
.where({ id: commentId })
.update({
...(dto.content ? { content: dto.content } : {}),
updated_at: knex.fn.now(),
});
console.log(
`[Knowledge] Comment updated: ${existing.parent_object_api_name}:${existing.parent_record_id} by ${userId}`,
);
await this.semanticRefreshQueue.enqueue(
tenantId,
existing.parent_object_api_name,
existing.parent_record_id,
userId,
'comment_updated',
);
return knex('comments').where({ id: commentId }).first();
}
async deleteComment(tenantId: string, commentId: string, userId: string) {
const knex = await this.getKnex(tenantId);
const existing = await knex('comments').where({ id: commentId }).first();
if (!existing) {
throw new NotFoundException('Comment not found');
}
if (existing.author_user_id !== userId) {
throw new ForbiddenException('Only the author can delete this comment');
}
await knex('comments').where({ id: commentId }).delete();
console.log(
`[Knowledge] Comment deleted: ${existing.parent_object_api_name}:${existing.parent_record_id} by ${userId}`,
);
await this.semanticRefreshQueue.enqueue(
tenantId,
existing.parent_object_api_name,
existing.parent_record_id,
userId,
'comment_deleted',
);
return { success: true };
}
private async getKnex(tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
return this.tenantDbService.getTenantKnexById(resolvedTenantId);
}
}

View File

@@ -0,0 +1,20 @@
import { SemanticChunkerService } from './semantic-chunker.service';
describe('SemanticChunkerService', () => {
let service: SemanticChunkerService;
beforeEach(() => {
service = new SemanticChunkerService();
});
it('creates chunks from base narrative and comments', () => {
const chunks = service.chunkText('Intro paragraph\n\nSecond paragraph', [
{ id: 'c-1', content: 'Comment body' },
]);
expect(chunks).toHaveLength(3);
expect(chunks[0].sourceKind).toBe('base_record');
expect(chunks[2].sourceKind).toBe('comment');
expect(chunks[2].sourceRefId).toBe('c-1');
});
});

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
export type SemanticChunk = {
chunkIndex: number;
sourceKind: 'base_record' | 'comment' | 'mixed';
sourceRefId: string | null;
text: string;
metadata: Record<string, any>;
};
@Injectable()
export class SemanticChunkerService {
chunkText(
baseNarrative: string,
comments: Array<{ id: string; content: string }>,
): SemanticChunk[] {
const chunks: SemanticChunk[] = [];
const baseParts = this.splitText(baseNarrative);
for (const [index, text] of baseParts.entries()) {
chunks.push({
chunkIndex: chunks.length,
sourceKind: 'base_record',
sourceRefId: null,
text,
metadata: { section: 'base', localIndex: index },
});
}
for (const comment of comments || []) {
const commentParts = this.splitText(comment.content);
for (const [index, text] of commentParts.entries()) {
chunks.push({
chunkIndex: chunks.length,
sourceKind: 'comment',
sourceRefId: comment.id,
text,
metadata: { section: 'comment', localIndex: index, commentId: comment.id },
});
}
}
return chunks;
}
private splitText(text: string): string[] {
const normalized = (text || '').trim();
if (!normalized) return [];
const paragraphs = normalized
.split(/\n{2,}/)
.map((part) => part.trim())
.filter(Boolean);
const chunks: string[] = [];
for (const paragraph of paragraphs) {
if (paragraph.length <= 500) {
chunks.push(paragraph);
continue;
}
let cursor = 0;
while (cursor < paragraph.length) {
chunks.push(paragraph.slice(cursor, cursor + 500).trim());
cursor += 500;
}
}
return chunks.filter(Boolean);
}
}

View File

@@ -0,0 +1,20 @@
import { SemanticLinkService } from './semantic-link.service';
describe('SemanticLinkService', () => {
let service: SemanticLinkService;
beforeEach(() => {
service = new SemanticLinkService();
});
it('normalizes undirected pairs in deterministic order', () => {
const normalized = service.normalizeUndirectedPair('Contact', 'b-id', 'Account', 'a-id');
expect(normalized).toEqual({
sourceEntityType: 'Account',
sourceEntityId: 'a-id',
targetEntityType: 'Contact',
targetEntityId: 'b-id',
});
});
});

View File

@@ -0,0 +1,186 @@
import { Injectable, NotFoundException } from '@nestjs/common';
export type SemanticLinkUpsertInput = {
sourceEntityType: string;
sourceEntityId: string;
targetEntityType: string;
targetEntityId: string;
linkType?: string;
status?: string;
origin?: string;
confidence?: number;
reason?: string;
evidence?: Record<string, any>;
suggestedByUserId?: string | null;
};
@Injectable()
export class SemanticLinkService {
normalizeUndirectedPair(
sourceEntityType: string,
sourceEntityId: string,
targetEntityType: string,
targetEntityId: string,
) {
const sourceKey = `${sourceEntityType}:${sourceEntityId}`;
const targetKey = `${targetEntityType}:${targetEntityId}`;
if (sourceKey <= targetKey) {
return {
sourceEntityType,
sourceEntityId,
targetEntityType,
targetEntityId,
};
}
return {
sourceEntityType: targetEntityType,
sourceEntityId: targetEntityId,
targetEntityType: sourceEntityType,
targetEntityId: sourceEntityId,
};
}
async upsertSuggestedLink(knex: any, input: SemanticLinkUpsertInput) {
const normalized = this.normalizeUndirectedPair(
input.sourceEntityType,
input.sourceEntityId,
input.targetEntityType,
input.targetEntityId,
);
const payload = {
source_entity_type: normalized.sourceEntityType,
source_entity_id: normalized.sourceEntityId,
target_entity_type: normalized.targetEntityType,
target_entity_id: normalized.targetEntityId,
link_type: input.linkType || 'related_to',
status: input.status || 'suggested',
origin: input.origin || 'semantic',
confidence: input.confidence ?? 0,
reason: input.reason || null,
evidence: input.evidence ? JSON.stringify(input.evidence) : null,
suggested_by_user_id: input.suggestedByUserId || null,
updated_at: knex.fn.now(),
created_at: knex.fn.now(),
};
await knex('semantic_links')
.insert(payload)
.onConflict([
'source_entity_type',
'source_entity_id',
'target_entity_type',
'target_entity_id',
'link_type',
])
.merge({
status: knex.raw("IF(status = 'approved', status, VALUES(status))"),
origin: payload.origin,
confidence: knex.raw('GREATEST(confidence, VALUES(confidence))'),
reason: payload.reason,
evidence: payload.evidence,
updated_at: knex.fn.now(),
});
}
async listForRecord(knex: any, entityType: string, entityId: string, status?: string) {
const query = knex('semantic_links')
.where((builder: any) => {
builder
.where({ source_entity_type: entityType, source_entity_id: entityId })
.orWhere({ target_entity_type: entityType, target_entity_id: entityId });
})
.orderBy('updated_at', 'desc');
if (status) {
query.andWhere({ status });
}
const links = await query;
if (!links.length) return links;
const typeSet = new Set<string>();
for (const link of links) {
typeSet.add(link.source_entity_type);
typeSet.add(link.target_entity_type);
}
const definitions = await knex('object_definitions')
.whereIn('apiName', Array.from(typeSet))
.select('apiName', 'label', 'pluralLabel', 'tableName', 'fields');
const definitionByType = new Map<string, any>(
definitions.map((item: any) => [item.apiName, item]),
);
const displayNameCache = new Map<string, string>();
const getDisplayField = (definition: any) => {
let fields = [];
if (Array.isArray(definition?.fields)) {
fields = definition.fields;
} else if (typeof definition?.fields === 'string') {
try {
fields = JSON.parse(definition.fields);
} catch {
fields = [];
}
}
if (fields.some((field: any) => field?.apiName === 'name')) return 'name';
const textField = fields.find((field: any) =>
['STRING', 'TEXT', 'EMAIL'].includes(String(field?.type || '').toUpperCase()),
);
return textField?.apiName || 'id';
};
const resolveTableName = (definition: any) => {
if (definition?.tableName) return definition.tableName;
if (definition?.pluralLabel) {
return String(definition.pluralLabel).toLowerCase().replace(/[^a-z0-9]+/g, '_');
}
return `${String(definition?.apiName || '').toLowerCase()}s`;
};
const loadDisplayName = async (type: string, id: string) => {
const cacheKey = `${type}:${id}`;
if (displayNameCache.has(cacheKey)) return displayNameCache.get(cacheKey);
const definition = definitionByType.get(type);
if (!definition) {
displayNameCache.set(cacheKey, id);
return id;
}
const tableName = resolveTableName(definition);
const displayField = getDisplayField(definition);
const record = await knex(tableName).where({ id }).first();
const display = record?.[displayField] ? String(record[displayField]) : id;
displayNameCache.set(cacheKey, display);
return display;
};
for (const link of links) {
link.source_entity_label = definitionByType.get(link.source_entity_type)?.label || link.source_entity_type;
link.target_entity_label = definitionByType.get(link.target_entity_type)?.label || link.target_entity_type;
link.source_entity_name = await loadDisplayName(link.source_entity_type, link.source_entity_id);
link.target_entity_name = await loadDisplayName(link.target_entity_type, link.target_entity_id);
}
return links;
}
async reviewLink(knex: any, linkId: string, status: string, reviewerUserId: string) {
const updated = await knex('semantic_links')
.where({ id: linkId })
.update({
status,
reviewed_by_user_id: reviewerUserId,
reviewed_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
if (!updated) {
throw new NotFoundException('Semantic link not found');
}
return knex('semantic_links').where({ id: linkId }).first();
}
}

View File

@@ -0,0 +1,540 @@
import { Injectable, Logger } from '@nestjs/common';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { ChatOpenAI } from '@langchain/openai';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
import { MeilisearchService } from '../../search/meilisearch.service';
import { getCentralPrisma } from '../../prisma/central-prisma.service';
import { OpenAIConfig } from '../../voice/interfaces/integration-config.interface';
import { randomUUID } from 'crypto';
import {
DefaultSemanticProjectionAdapter,
SemanticProjectionAdapter,
} from '../adapters/semantic-projection.adapter';
import { SemanticChunkerService } from './semantic-chunker.service';
import { SemanticLinkService } from './semantic-link.service';
@Injectable()
export class SemanticOrchestratorService {
private readonly logger = new Logger(SemanticOrchestratorService.name);
private readonly adapters: SemanticProjectionAdapter[] = [new DefaultSemanticProjectionAdapter()];
private readonly defaultEmbeddingModel =
process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small';
private readonly semanticEmbedderName = 'default';
private readonly MIN_CONFIDENCE_BASE = 0.7;
private readonly MIN_CONFIDENCE_COMMENT = 0.52;
private readonly defaultChatModel = process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini';
constructor(
private readonly tenantDbService: TenantDatabaseService,
private readonly meilisearchService: MeilisearchService,
private readonly chunkerService: SemanticChunkerService,
private readonly semanticLinkService: SemanticLinkService,
) {}
async refreshRecord(
tenantId: string,
objectApiName: string,
recordId: string,
userId?: string,
trigger: string = 'manual',
) {
this.logger.log(
`Semantic refresh start: ${objectApiName}:${recordId} (trigger=${trigger})`,
);
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const objectDefinition = await knex('object_definitions').where({ apiName: objectApiName }).first();
if (!objectDefinition) {
this.logger.warn(`Object definition ${objectApiName} not found. Skipping semantic refresh.`);
return { skipped: true };
}
const tableName = this.getTableName(objectDefinition);
const record = await knex(tableName).where({ id: recordId }).first();
if (!record) {
this.logger.warn(`Record not found for semantic refresh: ${objectApiName}:${recordId}`);
return { skipped: true };
}
const comments = await knex('comments')
.where({
parent_object_api_name: objectApiName,
parent_record_id: recordId,
})
.orderBy('created_at', 'asc');
this.logger.log(
`Semantic refresh source: ${objectApiName}:${recordId} comments=${comments.length}`,
);
const adapter = this.adapters.find((candidate) => candidate.supports(objectApiName))!;
const projection = adapter.buildProjection({
objectApiName,
record,
objectDefinition,
comments,
});
const documentId = await this.upsertSemanticDocument(knex, projection);
const chunks = this.chunkerService.chunkText(projection.embeddingNarrative, comments);
this.logger.log(
`Semantic refresh chunking: ${objectApiName}:${recordId} chunks=${chunks.length}`,
);
await this.replaceChunks(knex, documentId, chunks);
const openAiConfig = await this.getOpenAiConfig(resolvedTenantId);
const embedderReady = await this.indexChunks(resolvedTenantId, projection, chunks, openAiConfig);
await this.generateSuggestions(
resolvedTenantId,
projection,
chunks,
openAiConfig,
embedderReady,
userId,
trigger,
);
this.logger.log(
`Semantic refresh complete: ${objectApiName}:${recordId} document=${documentId}`,
);
return { documentId, chunkCount: chunks.length };
}
async reindexObject(tenantId: string, objectApiName: string, userId?: string, limit = 250) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const objectDefinition = await knex('object_definitions').where({ apiName: objectApiName }).first();
if (!objectDefinition) {
return { total: 0, processed: 0 };
}
const tableName = this.getTableName(objectDefinition);
const records = await knex(tableName).select('id').limit(limit);
let processed = 0;
for (const record of records) {
await this.refreshRecord(resolvedTenantId, objectApiName, record.id, userId, 'batch_reindex');
processed += 1;
}
return { total: records.length, processed };
}
private async upsertSemanticDocument(knex: any, projection: any): Promise<string> {
const existing = await knex('semantic_documents')
.where({ entity_type: projection.entityType, entity_id: projection.entityId })
.first();
if (existing) {
await knex('semantic_documents')
.where({ id: existing.id })
.update({
title: projection.title,
narrative: projection.narrative,
metadata: JSON.stringify(projection.metadata || {}),
source_summary: JSON.stringify(projection.sourceSummary || {}),
updated_at: knex.fn.now(),
});
return existing.id;
}
const newId = randomUUID();
const [created] = await knex('semantic_documents')
.insert({
id: newId,
entity_type: projection.entityType,
entity_id: projection.entityId,
title: projection.title,
narrative: projection.narrative,
metadata: JSON.stringify(projection.metadata || {}),
source_summary: JSON.stringify(projection.sourceSummary || {}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
})
.returning('id');
if (created && typeof created === 'object' && created.id) {
return created.id;
}
// MySQL may return a numeric insert id (often 0 for UUID PKs). Always trust the generated UUID.
return newId;
}
private async replaceChunks(knex: any, documentId: string, chunks: any[]) {
if (!documentId) {
this.logger.warn('Skipping chunk replace: missing semantic document id.');
return;
}
await knex('semantic_chunks').where({ semantic_document_id: documentId }).delete();
if (!chunks.length) return;
await knex('semantic_chunks').insert(
chunks.map((chunk) => ({
semantic_document_id: documentId,
chunk_index: chunk.chunkIndex,
source_kind: chunk.sourceKind,
source_ref_id: chunk.sourceRefId,
text: chunk.text,
metadata: JSON.stringify(chunk.metadata || {}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
})),
);
}
private async indexChunks(
tenantId: string,
projection: any,
chunks: any[],
openAiConfig: OpenAIConfig | null,
) {
if (!this.meilisearchService.isEnabled()) {
this.logger.warn('Meilisearch disabled; skipping semantic chunk indexing.');
return false;
}
const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId);
let embedderReady = false;
if (openAiConfig?.apiKey) {
embedderReady = await this.meilisearchService.ensureOpenAiEmbedder(indexName, {
embedderName: this.semanticEmbedderName,
apiKey: openAiConfig.apiKey,
model: openAiConfig.embeddingModel || this.defaultEmbeddingModel,
documentTemplate: '{{doc.title}}\n{{doc.text}}',
});
this.logger.log(
`Meilisearch embedder ensured: index=${indexName} model=${openAiConfig.embeddingModel || this.defaultEmbeddingModel}`,
);
} else {
this.logger.warn('OpenAI embedder not configured; semantic search will be lexical only.');
}
this.logger.log(`Indexing semantic chunks: index=${indexName} count=${chunks.length}`);
await this.meilisearchService.upsertDocuments(indexName, chunks.map((chunk) => ({
id: `${projection.entityType}_${projection.entityId}_${chunk.chunkIndex}`,
entityType: projection.entityType,
entityId: projection.entityId,
title: projection.title,
sourceKind: chunk.sourceKind,
sourceRefId: chunk.sourceRefId,
text: chunk.text,
})));
return embedderReady;
}
private async generateSuggestions(
tenantId: string,
projection: any,
chunks: any[],
openAiConfig: OpenAIConfig | null,
embedderReady: boolean,
userId?: string,
trigger: string = 'semantic_refresh',
) {
if (!this.meilisearchService.isEnabled() || !chunks.length) {
this.logger.warn(
`Skipping suggestion generation: meili=${this.meilisearchService.isEnabled()} chunks=${chunks.length}`,
);
return;
}
const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId);
// Build query from all chunks (base record + comments), prioritising comments
// since they carry the most distinctive semantic signal.
const commentChunks = chunks.filter((c) => c.sourceKind === 'comment');
const baseChunks = chunks.filter((c) => c.sourceKind !== 'comment');
const orderedChunks = [...commentChunks, ...baseChunks];
const queryText = orderedChunks.map((chunk) => chunk.text).join(' ').slice(0, 1200);
this.logger.log(
`Generating suggestions: index=${indexName} queryLen=${queryText.length} hybrid=${embedderReady}`,
);
const search = await this.meilisearchService.searchIndex(
indexName,
queryText,
20,
// semanticRatio:1.0 = pure vector search, no lexical component that would
// match on shared tokens like 'name:' or 'Comment 1:' across all records.
embedderReady ? { embedder: this.semanticEmbedderName, semanticRatio: 1.0 } : undefined,
);
this.logger.log(
`Meilisearch results: index=${indexName} hits=${search.hits?.length || 0} total=${search.total}`,
);
const candidates = new Map<string, { hit: any; confidence: number; rankingDetails?: any }>();
for (const hit of search.hits || []) {
// Skip self
if (hit.entityId === projection.entityId) continue;
const confidence = hit._semanticScore ?? hit._rankingScore ?? 0;
// Use a lower threshold for comment chunks (short, conversational text
// naturally produces lower cosine similarity than structured field values).
const isComment = hit.sourceKind === 'comment';
const threshold = isComment ? this.MIN_CONFIDENCE_COMMENT : this.MIN_CONFIDENCE_BASE;
this.logger.log(
`Suggestion candidate: ${hit.entityType}:${hit.entityId} confidence=${confidence.toFixed(4)} kind=${hit.sourceKind || 'base'} threshold=${threshold} text="${String(hit.text || '').substring(0, 60)}"`,
);
if (confidence < threshold) {
this.logger.log(
`Skipping low-confidence match: ${hit.entityType}:${hit.entityId} confidence=${confidence.toFixed(4)} < ${threshold} (${isComment ? 'comment' : 'base'})`,
);
continue;
}
const key = `${hit.entityType}:${hit.entityId}`;
const existing = candidates.get(key);
if (!existing || confidence > existing.confidence) {
candidates.set(key, {
hit,
confidence,
rankingDetails: hit._rankingScoreDetails || null,
});
}
}
this.logger.log(`Filtered suggestions: ${candidates.size} passed thresholds (base=${this.MIN_CONFIDENCE_BASE}, comment=${this.MIN_CONFIDENCE_COMMENT})`);
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
for (const [key, { hit, confidence, rankingDetails }] of candidates.entries()) {
const [targetType, targetId] = key.split(':');
const llmAssessment = await this.assessLinkWithLlm(
openAiConfig,
trigger,
projection,
chunks,
hit,
confidence,
rankingDetails,
);
const reason =
llmAssessment?.reason ||
this.humanizeTrigger(trigger) ||
'Suggested from semantic similarity.';
await this.semanticLinkService.upsertSuggestedLink(knex, {
sourceEntityType: projection.entityType,
sourceEntityId: projection.entityId,
targetEntityType: targetType,
targetEntityId: targetId,
linkType: llmAssessment?.linkType || 'related',
status: 'suggested',
origin: 'semantic',
confidence,
reason,
evidence: this.buildEvidencePayload(
trigger,
chunks,
hit,
confidence,
rankingDetails,
llmAssessment,
),
suggestedByUserId: userId || null,
});
}
}
private buildEvidencePayload(
trigger: string,
chunks: any[],
hit: any,
confidence: number,
rankingDetails: any,
llmAssessment?: {
reason?: string;
explanation?: string;
matchedSignals?: string[];
} | null,
) {
return {
trigger,
explanation:
llmAssessment?.explanation ||
llmAssessment?.reason ||
'Suggested using semantic similarity and ranked chunk evidence.',
sourceSignals: chunks.slice(0, 2).map((chunk) => ({
sourceKind: chunk.sourceKind,
text: chunk.text.slice(0, 220),
})),
matchedSignals: llmAssessment?.matchedSignals || [],
matchedChunks: [
{
sourceKind: hit.sourceKind,
text: String(hit.text || '').slice(0, 220),
score: confidence,
rankingDetails: rankingDetails || null,
},
],
};
}
private async assessLinkWithLlm(
openAiConfig: OpenAIConfig | null,
trigger: string,
projection: any,
chunks: any[],
hit: any,
confidence: number,
rankingDetails: any,
): Promise<{ linkType: string; reason?: string; explanation?: string; matchedSignals?: string[] } | null> {
if (!openAiConfig?.apiKey) {
return null;
}
const promptPayload = {
trigger,
source: {
entityType: projection.entityType,
title: projection.title,
narrative: String(projection.narrative || '').slice(0, 900),
keySignals: chunks.slice(0, 3).map((chunk) => ({
sourceKind: chunk.sourceKind,
text: String(chunk.text || '').slice(0, 220),
})),
},
target: {
entityType: hit.entityType,
title: hit.title,
sourceKind: hit.sourceKind,
text: String(hit.text || '').slice(0, 300),
},
confidence,
rankingDetails: rankingDetails || {},
allowedLinkTypes: [
'related',
'supports',
'contradicts',
'expands',
'duplicate_of',
'references',
'depends_on',
],
};
try {
const model = new ChatOpenAI({
apiKey: openAiConfig.apiKey,
model: openAiConfig.model || this.defaultChatModel,
temperature: 0.1,
});
const response = await model.invoke([
new SystemMessage(
'Classify semantic relationship. Return valid JSON only with keys: linkType, reason, explanation, matchedSignals. linkType must be one of related|supports|contradicts|expands|duplicate_of|references|depends_on.',
),
new HumanMessage(JSON.stringify(promptPayload)),
]);
const content = typeof response.content === 'string'
? response.content
: Array.isArray(response.content)
? response.content.map((part: any) => (typeof part === 'string' ? part : part?.text || '')).join('')
: '';
const normalized = this.extractJsonObject(content);
if (!normalized) return null;
const linkType = this.normalizeLinkType(normalized.linkType);
return {
linkType,
reason: typeof normalized.reason === 'string' ? normalized.reason.trim() : undefined,
explanation:
typeof normalized.explanation === 'string' ? normalized.explanation.trim() : undefined,
matchedSignals: Array.isArray(normalized.matchedSignals)
? normalized.matchedSignals
.map((item: any) => String(item || '').trim())
.filter(Boolean)
.slice(0, 3)
: undefined,
};
} catch (error) {
this.logger.warn(`Semantic LLM assessment failed: ${error.message}`);
return null;
}
}
private extractJsonObject(raw: string): Record<string, any> | null {
if (!raw) return null;
const trimmed = raw.trim();
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}
}
private normalizeLinkType(value: any): string {
const supported = new Set([
'related',
'supports',
'contradicts',
'expands',
'duplicate_of',
'references',
'depends_on',
]);
const normalized = String(value || '').trim().toLowerCase();
if (supported.has(normalized)) return normalized;
return 'related';
}
private humanizeTrigger(trigger: string): string {
if (!trigger) return 'Suggested from semantic similarity.';
const map: Record<string, string> = {
comment_created: 'Suggested based on a comment added to the record.',
comment_updated: 'Suggested based on a comment update.',
manual_refresh: 'Suggested after a manual semantic refresh.',
batch_reindex: 'Suggested during semantic reindexing.',
};
return map[trigger] || 'Suggested from semantic similarity.';
}
private getTableName(objectDefinition: any): string {
if (objectDefinition.tableName) return objectDefinition.tableName;
if (objectDefinition.pluralLabel) {
return objectDefinition.pluralLabel.toLowerCase().replace(/[^a-z0-9]+/g, '_');
}
return `${objectDefinition.apiName.toLowerCase()}s`;
}
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: resolvedTenantId },
select: { integrationsConfig: true },
});
let config = tenant?.integrationsConfig
? typeof tenant.integrationsConfig === 'string'
? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig)
: tenant.integrationsConfig
: null;
if (!config?.openai && process.env.OPENAI_API_KEY) {
config = {
...(config || {}),
openai: {
apiKey: process.env.OPENAI_API_KEY,
embeddingModel: this.defaultEmbeddingModel,
},
};
}
if (config?.openai?.apiKey) {
return {
apiKey: config.openai.apiKey,
embeddingModel: config.openai.embeddingModel || this.defaultEmbeddingModel,
};
}
return null;
}
}

View File

@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import {
SEMANTIC_REFRESH_QUEUE,
SEMANTIC_REFRESH_JOB,
} from '../semantic-refresh.constants';
import { SemanticRefreshJobData } from '../semantic-refresh.processor';
@Injectable()
export class SemanticRefreshQueueService {
private readonly logger = new Logger(SemanticRefreshQueueService.name);
constructor(
@InjectQueue(SEMANTIC_REFRESH_QUEUE) private readonly queue: Queue,
) {}
async enqueue(
tenantId: string,
objectApiName: string,
recordId: string,
userId?: string,
trigger: string = 'manual',
): Promise<void> {
const data: SemanticRefreshJobData = {
tenantId,
objectApiName,
recordId,
userId,
trigger,
};
await this.queue.add(SEMANTIC_REFRESH_JOB, data, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50,
});
this.logger.debug(
`Enqueued semantic refresh: ${objectApiName}:${recordId} trigger=${trigger}`,
);
}
}

View File

@@ -1,63 +0,0 @@
import { randomUUID } from 'crypto';
import { snakeCaseMappers } from 'objection';
import { BaseModel } from './base.model';
export class AiChatSession extends BaseModel {
static tableName = 'ai_chat_sessions';
static columnNameMappers = snakeCaseMappers();
id!: string;
userId!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
messages: {
relation: BaseModel.HasManyRelation,
modelClass: AiChatMessage,
join: {
from: 'ai_chat_sessions.id',
to: 'ai_chat_messages.session_id',
},
},
};
}
}
export class AiChatMessage extends BaseModel {
static tableName = 'ai_chat_messages';
static columnNameMappers = snakeCaseMappers();
id!: string;
sessionId!: string;
role!: string;
content!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
session: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiChatSession,
join: {
from: 'ai_chat_messages.session_id',
to: 'ai_chat_sessions.id',
},
},
};
}
}

View File

@@ -1,164 +0,0 @@
import { randomUUID } from 'crypto';
import { QueryContext, snakeCaseMappers } from 'objection';
import { BaseModel } from './base.model';
export class AiProcess extends BaseModel {
static tableName = 'ai_processes';
static columnNameMappers = snakeCaseMappers();
id!: string;
name!: string;
description?: string;
latestVersion!: number;
createdBy!: string;
createdAt!: Date;
updatedAt!: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
super.$beforeInsert(queryContext);
}
static get relationMappings() {
return {
versions: {
relation: BaseModel.HasManyRelation,
modelClass: AiProcessVersion,
join: {
from: 'ai_processes.id',
to: 'ai_process_versions.process_id',
},
},
runs: {
relation: BaseModel.HasManyRelation,
modelClass: AiProcessRun,
join: {
from: 'ai_processes.id',
to: 'ai_process_runs.process_id',
},
},
};
}
}
export class AiProcessVersion extends BaseModel {
static tableName = 'ai_process_versions';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['graphJson', 'compiledJson'];
id!: string;
processId!: string;
version!: number;
graphJson!: Record<string, unknown>;
compiledJson!: Record<string, unknown>;
createdBy!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
process: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcess,
join: {
from: 'ai_process_versions.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiProcessRun extends BaseModel {
static tableName = 'ai_process_runs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['inputJson', 'outputJson', 'errorJson', 'stateJson'];
id!: string;
processId!: string;
version!: number;
status!: string;
inputJson!: Record<string, unknown>;
outputJson?: Record<string, unknown> | null;
errorJson?: Record<string, unknown> | null;
stateJson?: Record<string, unknown>;
currentNodeId?: string | null;
startedAt?: Date;
endedAt?: Date | null;
$beforeInsert() {
this.id = this.id || randomUUID();
this.startedAt = this.startedAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
process: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcess,
join: {
from: 'ai_process_runs.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiAuditEvent extends BaseModel {
static tableName = 'ai_audit_events';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['payloadJson'];
id!: string;
runId!: string;
eventType!: string;
payloadJson!: Record<string, unknown>;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
run: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcessRun,
join: {
from: 'ai_audit_events.run_id',
to: 'ai_process_runs.id',
},
},
};
}
}
export class AiToolConfig extends BaseModel {
static tableName = 'ai_tool_configs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['configJson'];
id!: string;
toolName!: string;
enabled!: boolean;
configJson?: Record<string, unknown>;
createdAt!: Date;
updatedAt!: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
super.$beforeInsert(queryContext);
}
}

View File

@@ -1,4 +1,5 @@
import { BaseModel } from './base.model';
import { ModelOptions, QueryContext } from 'objection';
export class User extends BaseModel {
static tableName = 'users';
@@ -8,6 +9,8 @@ export class User extends BaseModel {
password: string;
firstName?: string;
lastName?: string;
alias?: string;
name?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
@@ -22,11 +25,37 @@ export class User extends BaseModel {
password: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
alias: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
/**
* Compute the `name` column before insert/update so lookup fields
* referencing User.name always have a value.
*/
private computeName() {
if (this.alias) {
this.name = this.alias;
} else if (this.firstName || this.lastName) {
this.name = [this.firstName, this.lastName].filter(Boolean).join(' ');
} else if (this.email) {
this.name = this.email;
}
}
$beforeInsert(queryContext: QueryContext) {
super.$beforeInsert(queryContext);
this.computeName();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
super.$beforeUpdate(opt, queryContext);
this.computeName();
}
static get relationMappings() {
const { UserRole } = require('./user-role.model');
const { Role } = require('./role.model');

View File

@@ -79,6 +79,10 @@ export class FieldMapperService {
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
// Hide 'id' field from list view by default
const isIdField = field.apiName === 'id';
const defaultShowOnList = isIdField ? false : true;
return {
id: field.id,
apiName: field.apiName,
@@ -95,7 +99,7 @@ export class FieldMapperService {
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
// View visibility
showOnList: uiMetadata.showOnList !== false,
showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList,
showOnDetail: uiMetadata.showOnDetail !== false,
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
sortable: uiMetadata.sortable !== false,
@@ -141,12 +145,14 @@ export class FieldMapperService {
'boolean': 'boolean',
'date': 'date',
'datetime': 'datetime',
'date_time': 'datetime',
'time': 'time',
'email': 'email',
'url': 'url',
'phone': 'text',
'picklist': 'select',
'multipicklist': 'multiSelect',
'multi_picklist': 'multiSelect',
'lookup': 'belongsTo',
'master-detail': 'belongsTo',
'currency': 'currency',

View File

@@ -10,9 +10,10 @@ import { RbacModule } from '../rbac/rbac.module';
import { ModelRegistry } from './models/model.registry';
import { ModelService } from './models/model.service';
import { MeilisearchModule } from '../search/meilisearch.module';
import { KnowledgeModule } from '../knowledge/knowledge.module';
@Module({
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule, KnowledgeModule],
providers: [
ObjectService,
SchemaManagementService,

View File

@@ -9,6 +9,7 @@ import { FieldDefinition } from '../models/field-definition.model';
import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory';
import { MeilisearchService } from '../search/meilisearch.service';
import { SemanticRefreshQueueService } from '../knowledge/services/semantic-refresh-queue.service';
type SearchFilter = {
field: string;
@@ -39,6 +40,7 @@ export class ObjectService {
private modelService: ModelService,
private authService: AuthorizationService,
private meilisearchService: MeilisearchService,
private semanticRefreshQueue: SemanticRefreshQueueService,
) {}
// Setup endpoints - Object metadata management
@@ -336,13 +338,27 @@ export class ObjectService {
updated_at: knex.fn.now(),
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField || data.relationObjects || data.relationTypeField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
relationObjects: data.relationObjects,
relationTypeField: data.relationTypeField,
});
// Build UI metadata from all sources
const uiMetadataObj: any = {};
// Merge general uiMetadata (options, placeholder, helpText, etc.)
if (data.uiMetadata && typeof data.uiMetadata === 'object') {
Object.assign(uiMetadataObj, data.uiMetadata);
}
// Store relation-specific fields in UI metadata if provided
if (data.relationDisplayField) {
uiMetadataObj.relationDisplayField = data.relationDisplayField;
}
if (data.relationObjects) {
uiMetadataObj.relationObjects = data.relationObjects;
}
if (data.relationTypeField) {
uiMetadataObj.relationTypeField = data.relationTypeField;
}
if (Object.keys(uiMetadataObj).length > 0) {
fieldData.ui_metadata = JSON.stringify(uiMetadataObj);
}
await knex('field_definitions').insert(fieldData);
@@ -1114,6 +1130,13 @@ export class ObjectService {
);
const record = await boundModel.query().insert(normalizedRecordData);
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
await this.semanticRefreshQueue.enqueue(
resolvedTenantId,
objectApiName,
record.id,
userId,
'record_created',
);
return record;
}
@@ -1183,6 +1206,13 @@ export class ObjectService {
await boundModel.query().patch(normalizedEditableData).where({ id: recordId });
const record = await boundModel.query().where({ id: recordId }).first();
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
await this.semanticRefreshQueue.enqueue(
resolvedTenantId,
objectApiName,
recordId,
userId,
'record_updated',
);
return record;
}

View File

@@ -111,4 +111,33 @@ export class RuntimeObjectController {
user.userId,
);
}
/**
* Direct filter-based search — used when applying a saved list view.
* Bypasses the AI planning step; accepts pre-resolved structured filters.
*/
@Post(':objectApiName/records/search')
async searchRecords(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@CurrentUser() user: any,
@Body() body: {
filters?: Array<{ field: string; operator: string; value?: any; values?: any[]; from?: string; to?: string }>;
sort?: { field: string; direction: 'asc' | 'desc' } | null;
page?: number;
pageSize?: number;
},
) {
const page = Number.isFinite(Number(body?.page)) ? Number(body.page) : 1;
const pageSize = Number.isFinite(Number(body?.pageSize)) ? Number(body.pageSize) : 25;
return this.objectService.searchRecordsWithFilters(
tenantId,
objectApiName,
user.userId,
body?.filters || [],
{ page, pageSize },
body?.sort || undefined,
);
}
}

View File

@@ -1,4 +1,6 @@
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
export type PageLayoutType = 'detail' | 'list';
export class CreatePageLayoutDto {
@IsString()
@@ -7,18 +9,25 @@ export class CreatePageLayoutDto {
@IsUUID()
objectId: string;
@IsIn(['detail', 'list'])
@IsOptional()
layoutType?: PageLayoutType = 'detail';
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsObject()
layoutConfig: {
// For detail layouts: grid-based field positions
fields: Array<{
fieldId: string;
x: number;
y: number;
w: number;
h: number;
x?: number;
y?: number;
w?: number;
h?: number;
// For list layouts: field order (optional, defaults to array index)
order?: number;
}>;
relatedLists?: string[];
};
@@ -42,10 +51,11 @@ export class UpdatePageLayoutDto {
layoutConfig?: {
fields: Array<{
fieldId: string;
x: number;
y: number;
w: number;
h: number;
x?: number;
y?: number;
w?: number;
h?: number;
order?: number;
}>;
relatedLists?: string[];
};

View File

@@ -10,7 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { PageLayoutService } from './page-layout.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@@ -25,13 +25,21 @@ export class PageLayoutController {
}
@Get()
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
return this.pageLayoutService.findAll(tenantId, objectId);
findAll(
@TenantId() tenantId: string,
@Query('objectId') objectId?: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
}
@Get('default/:objectId')
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
findDefaultByObject(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
}
@Get(':id')

View File

@@ -1,24 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
@Injectable()
export class PageLayoutService {
constructor(private tenantDbService: TenantDatabaseService) {}
async create(tenantId: string, createDto: CreatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const layoutType = createDto.layoutType || 'detail';
// If this layout is set as default, unset other defaults for the same object
// If this layout is set as default, unset other defaults for the same object and layout type
if (createDto.isDefault) {
await knex('page_layouts')
.where({ object_id: createDto.objectId })
.where({ object_id: createDto.objectId, layout_type: layoutType })
.update({ is_default: false });
}
const [id] = await knex('page_layouts').insert({
name: createDto.name,
object_id: createDto.objectId,
layout_type: layoutType,
is_default: createDto.isDefault || false,
layout_config: JSON.stringify(createDto.layoutConfig),
description: createDto.description || null,
@@ -29,8 +31,8 @@ export class PageLayoutService {
return result;
}
async findAll(tenantId: string, objectId?: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
let query = knex('page_layouts');
@@ -38,12 +40,16 @@ export class PageLayoutService {
query = query.where({ object_id: objectId });
}
if (layoutType) {
query = query.where({ layout_type: layoutType });
}
const layouts = await query.orderByRaw('is_default DESC, name ASC');
return layouts;
}
async findOne(tenantId: string, id: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const layout = await knex('page_layouts').where({ id }).first();
@@ -54,27 +60,26 @@ export class PageLayoutService {
return layout;
}
async findDefaultByObject(tenantId: string, objectId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const layout = await knex('page_layouts')
.where({ object_id: objectId, is_default: true })
.where({ object_id: objectId, is_default: true, layout_type: layoutType })
.first();
return layout || null;
}
async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
// Check if layout exists
await this.findOne(tenantId, id);
// If setting as default, unset other defaults for the same object
if (updateDto.isDefault) {
const layout = await this.findOne(tenantId, id);
// If setting as default, unset other defaults for the same object and layout type
if (updateDto.isDefault) {
await knex('page_layouts')
.where({ object_id: layout.object_id })
.where({ object_id: layout.object_id, layout_type: layout.layout_type })
.whereNot({ id })
.update({ is_default: false });
}
@@ -107,7 +112,7 @@ export class PageLayoutService {
}
async remove(tenantId: string, id: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
await this.findOne(tenantId, id);

View File

@@ -39,7 +39,7 @@ export class SetupUsersController {
@Post()
async createUser(
@TenantId() tenantId: string,
@Body() data: { email: string; password: string; firstName?: string; lastName?: string },
@Body() data: { email: string; password: string; firstName?: string; lastName?: string; alias?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
@@ -52,6 +52,7 @@ export class SetupUsersController {
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
alias: data.alias,
isActive: true,
});
@@ -62,7 +63,7 @@ export class SetupUsersController {
async updateUser(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string },
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string; alias?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
@@ -72,6 +73,7 @@ export class SetupUsersController {
if (data.email) updateData.email = data.email;
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (data.alias !== undefined) updateData.alias = data.alias;
// Hash password if provided
if (data.password) {

View File

@@ -0,0 +1,53 @@
import { IsString, IsNotEmpty, IsArray, IsOptional } from 'class-validator';
export class CreateSavedViewDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
objectApiName: string;
@IsArray()
filters: Array<{
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
}>;
@IsOptional()
sort?: { field: string; direction: 'asc' | 'desc' } | null;
@IsOptional()
@IsString()
description?: string;
}
export class UpdateSavedViewDto {
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsArray()
filters?: Array<{
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
}>;
@IsOptional()
sort?: { field: string; direction: 'asc' | 'desc' } | null;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,92 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { SavedListViewService } from './saved-list-view.service';
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
import { CreateRecordShareDto } from '../rbac/dto/create-record-share.dto';
@Controller('saved-views')
@UseGuards(JwtAuthGuard)
export class SavedListViewController {
constructor(private readonly savedListViewService: SavedListViewService) {}
@Get(':objectApiName')
findByObject(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('objectApiName') objectApiName: string,
) {
return this.savedListViewService.findByObject(tenantId, user.userId, objectApiName);
}
@Post()
create(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() dto: CreateSavedViewDto,
) {
return this.savedListViewService.create(tenantId, user.userId, dto);
}
@Patch(':id')
update(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: UpdateSavedViewDto,
) {
return this.savedListViewService.update(tenantId, user.userId, id, dto);
}
@Delete(':id')
remove(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
return this.savedListViewService.remove(tenantId, user.userId, id);
}
// ── Sharing endpoints (reuse record_shares table) ────────────────────────
@Get(':id/shares')
getShares(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
return this.savedListViewService.getShares(tenantId, user.userId, id);
}
@Post(':id/shares')
createShare(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: CreateRecordShareDto,
) {
return this.savedListViewService.createShare(tenantId, user.userId, id, dto);
}
@Delete(':id/shares/:shareId')
removeShare(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Param('shareId') shareId: string,
) {
return this.savedListViewService.removeShare(tenantId, user.userId, id, shareId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SavedListViewService } from './saved-list-view.service';
import { SavedListViewController } from './saved-list-view.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [SavedListViewController],
providers: [SavedListViewService],
exports: [SavedListViewService],
})
export class SavedListViewModule {}

View File

@@ -0,0 +1,264 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
import { RecordShare } from '../models/record-share.model';
import { ObjectDefinition } from '../models/object-definition.model';
@Injectable()
export class SavedListViewService {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Resolves the system object_definition ID for SavedListView.
* This is needed to create record_shares rows for saved views.
*/
private async getSavedViewObjectDefId(knex: any): Promise<string> {
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: 'SavedListView' });
if (!objectDef) {
throw new BadRequestException(
'SavedListView system object not found. Please run migrations.',
);
}
return objectDef.id;
}
// ── CRUD ─────────────────────────────────────────────────────────────────
/**
* Returns all saved views visible to the user for a given object:
* - Views owned by the user
* - Views shared with the user via record_shares
*/
async findByObject(tenantId: string, userId: string, objectApiName: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const objectDefId = await this.getSavedViewObjectDefId(knex);
// IDs of views shared with this user via record_shares
const sharedViewIds = await RecordShare.query(knex)
.where({ objectDefinitionId: objectDefId, granteeUserId: userId })
.whereNull('revokedAt')
.where(builder => {
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
})
.select('recordId');
const sharedIds = sharedViewIds.map((s: any) => s.recordId);
const rows = await knex('saved_list_views')
.where({ object_api_name: objectApiName })
.andWhere(function () {
this.where({ user_id: userId });
if (sharedIds.length > 0) {
this.orWhereIn('id', sharedIds);
}
})
.orderBy('created_at', 'asc');
return rows.map((r: any) => this.deserialize(r, userId));
}
async create(tenantId: string, userId: string, dto: CreateSavedViewDto) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const id = require('crypto').randomUUID();
await knex('saved_list_views').insert({
id,
name: dto.name,
object_api_name: dto.objectApiName,
user_id: userId,
is_shared: false,
strategy: 'query',
filters: JSON.stringify(dto.filters || []),
sort: dto.sort ? JSON.stringify(dto.sort) : null,
description: dto.description || null,
});
const row = await knex('saved_list_views').where({ id }).first();
return this.deserialize(row, userId);
}
async update(tenantId: string, userId: string, id: string, dto: UpdateSavedViewDto) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const existing = await knex('saved_list_views').where({ id }).first();
if (!existing) throw new NotFoundException(`Saved view ${id} not found`);
if (existing.user_id !== userId) {
throw new ForbiddenException('You can only modify views you own');
}
const updates: Record<string, any> = { updated_at: knex.fn.now() };
if (dto.name !== undefined) updates.name = dto.name;
if (dto.filters !== undefined) updates.filters = JSON.stringify(dto.filters);
if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null;
if (dto.description !== undefined) updates.description = dto.description;
await knex('saved_list_views').where({ id }).update(updates);
const row = await knex('saved_list_views').where({ id }).first();
return this.deserialize(row, userId);
}
async remove(tenantId: string, userId: string, id: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const existing = await knex('saved_list_views').where({ id }).first();
if (!existing) throw new NotFoundException(`Saved view ${id} not found`);
if (existing.user_id !== userId) {
throw new ForbiddenException('You can only delete views you own');
}
// Also clean up any record_shares for this view
const objectDefId = await this.getSavedViewObjectDefId(knex);
await RecordShare.query(knex)
.where({ objectDefinitionId: objectDefId, recordId: id })
.delete();
await knex('saved_list_views').where({ id }).delete();
return { deleted: true };
}
// ── Sharing via record_shares ────────────────────────────────────────────
async getShares(tenantId: string, userId: string, viewId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const view = await knex('saved_list_views').where({ id: viewId }).first();
if (!view) throw new NotFoundException('Saved view not found');
if (view.user_id !== userId) {
throw new ForbiddenException('Only the view owner can manage sharing');
}
const objectDefId = await this.getSavedViewObjectDefId(knex);
const shares = await RecordShare.query(knex)
.where({ objectDefinitionId: objectDefId, recordId: viewId })
.whereNull('revokedAt')
.where(builder => {
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
})
.withGraphFetched('[granteeUser]')
.orderBy('createdAt', 'desc');
return shares;
}
async createShare(
tenantId: string,
userId: string,
viewId: string,
dto: { granteeUserId: string; canRead: boolean; canEdit: boolean; canDelete: boolean; expiresAt?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const view = await knex('saved_list_views').where({ id: viewId }).first();
if (!view) throw new NotFoundException('Saved view not found');
if (view.user_id !== userId) {
throw new ForbiddenException('Only the view owner can share it');
}
if (dto.granteeUserId === userId) {
throw new BadRequestException('Cannot share a view with yourself');
}
const objectDefId = await this.getSavedViewObjectDefId(knex);
// Upsert: if non-revoked share already exists for this grantee, update it
const existing = await RecordShare.query(knex)
.where({
objectDefinitionId: objectDefId,
recordId: viewId,
granteeUserId: dto.granteeUserId,
})
.whereNull('revokedAt')
.first();
if (existing) {
await RecordShare.query(knex)
.patchAndFetchById(existing.id, {
accessLevel: {
canRead: dto.canRead,
canEdit: dto.canEdit,
canDelete: dto.canDelete,
},
expiresAt: dto.expiresAt
? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any)
: null,
} as any);
return RecordShare.query(knex)
.findById(existing.id)
.withGraphFetched('[granteeUser]');
}
const share = await RecordShare.query(knex).insertAndFetch({
objectDefinitionId: objectDefId,
recordId: viewId,
granteeUserId: dto.granteeUserId,
grantedByUserId: userId,
accessLevel: {
canRead: dto.canRead,
canEdit: dto.canEdit,
canDelete: dto.canDelete,
},
expiresAt: dto.expiresAt
? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any)
: null,
} as any);
return RecordShare.query(knex)
.findById(share.id)
.withGraphFetched('[granteeUser]');
}
async removeShare(tenantId: string, userId: string, viewId: string, shareId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const view = await knex('saved_list_views').where({ id: viewId }).first();
if (!view) throw new NotFoundException('Saved view not found');
if (view.user_id !== userId) {
throw new ForbiddenException('Only the view owner can manage sharing');
}
const share = await RecordShare.query(knex).findById(shareId);
if (!share) throw new NotFoundException('Share not found');
// Soft-revoke
await RecordShare.query(knex)
.findById(shareId)
.patch({ revokedAt: knex.fn.now() } as any);
return { revoked: true };
}
// ── Serialisation ────────────────────────────────────────────────────────
private deserialize(row: any, currentUserId: string) {
return {
id: row.id,
name: row.name,
objectApiName: row.object_api_name,
userId: row.user_id,
isOwner: row.user_id === currentUserId,
isShared: Boolean(row.is_shared),
strategy: row.strategy,
filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : (row.filters ?? []),
sort: row.sort
? (typeof row.sort === 'string' ? JSON.parse(row.sort) : row.sort)
: null,
description: row.description,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@@ -8,9 +8,23 @@ type MeiliConfig = {
indexPrefix: string;
};
type HybridSearchOptions = {
embedder: string;
semanticRatio?: number;
};
type OpenAiEmbedderConfig = {
embedderName: string;
apiKey: string;
model: string;
documentTemplate: string;
};
@Injectable()
export class MeilisearchService {
private readonly logger = new Logger(MeilisearchService.name);
private readonly embedderCache = new Map<string, string>();
private vectorStoreEnabled = false;
isEnabled(): boolean {
return Boolean(this.getConfig());
@@ -158,6 +172,100 @@ export class MeilisearchService {
}
}
buildSemanticChunkIndexName(tenantId: string): string {
const config = this.getConfig();
const prefix = config?.indexPrefix || 'tenant_';
return `${prefix}${tenantId}_semantic_chunks`.toLowerCase();
}
async upsertDocuments(indexName: string, documents: Record<string, any>[]): Promise<void> {
const config = this.getConfig();
if (!config || !Array.isArray(documents) || documents.length === 0) return;
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents?primaryKey=id`;
try {
const response = await this.requestJson('POST', url, documents, this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(`Meilisearch document upsert failed for index ${indexName}: ${response.status}`);
return;
}
// Meilisearch indexes (and embeds) documents asynchronously. Wait for the task
// to complete so callers can immediately search and see the new documents.
const taskUid = response.body?.taskUid ?? response.body?.uid;
if (Number.isFinite(Number(taskUid))) {
const succeeded = await this.waitForTask(config, Number(taskUid), 30000);
if (!succeeded) {
this.logger.warn(`Meilisearch indexing task did not succeed within timeout: taskUid=${taskUid} index=${indexName}`);
}
}
} catch (error) {
this.logger.warn(`Meilisearch document upsert failed: ${error.message}`);
}
}
async searchIndex(
indexName: string,
query: string,
limit = 20,
hybrid?: HybridSearchOptions,
): Promise<{ hits: any[]; total: number }> {
const config = this.getConfig();
if (!config) return { hits: [], total: 0 };
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
try {
const response = await this.requestJson(
'POST',
url,
{
q: query,
limit,
showRankingScore: true,
...(hybrid ? { hybrid, showRankingScoreDetails: true } : {}),
},
this.buildHeaders(config),
);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch search failed for index ${indexName}: ${response.status}`,
);
this.logger.warn(
`Meilisearch search payload: ${JSON.stringify({ q: query, limit, hybrid })}`,
);
this.logger.warn(
`Meilisearch search error body: ${JSON.stringify(response.body)}`,
);
// If hybrid is invalid (embedder missing), retry once without hybrid
if (hybrid && response.body?.code === 'invalid_embedder') {
const fallback = await this.requestJson(
'POST',
url,
{ q: query, limit },
this.buildHeaders(config),
);
if (this.isSuccessStatus(fallback.status)) {
const hits = Array.isArray(fallback.body?.hits) ? fallback.body.hits : [];
const total =
fallback.body?.estimatedTotalHits ?? fallback.body?.nbHits ?? hits.length;
this.logger.warn(
`Meilisearch hybrid failed; fell back to lexical search for index ${indexName}.`,
);
return { hits, total };
}
}
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 search failed: ${error.message}`);
return { hits: [], total: 0 };
}
}
private getConfig(): MeiliConfig | null {
const host = process.env.MEILI_HOST || process.env.MEILISEARCH_HOST;
if (!host) return null;
@@ -198,7 +306,7 @@ export class MeilisearchService {
}
private requestJson(
method: 'POST' | 'DELETE',
method: 'POST' | 'DELETE' | 'PATCH' | 'GET',
url: string,
payload: any,
headers: Record<string, string>,
@@ -235,10 +343,141 @@ export class MeilisearchService {
);
request.on('error', reject);
if (payload !== undefined) {
if (payload !== undefined && method !== 'GET') {
request.write(JSON.stringify(payload));
}
request.end();
});
}
private async enableVectorStore(): Promise<void> {
// Temporarily disabled to avoid the overhead of checking on every save.
// Re-enable by removing the early return below.
return;
if (this.vectorStoreEnabled) return; // eslint-disable-line no-unreachable
const meiliConfig = this.getConfig();
if (!meiliConfig) return;
const url = `${meiliConfig.host}/experimental-features`;
try {
const response = await this.requestJson(
'PATCH',
url,
{ vectorStore: true },
this.buildHeaders(meiliConfig),
);
if (this.isSuccessStatus(response.status)) {
this.vectorStoreEnabled = true;
this.logger.log('Meilisearch vector store experimental feature enabled');
} else {
this.logger.warn(
`Failed to enable Meilisearch vector store: ${response.status} ${JSON.stringify(response.body)}`,
);
}
} catch (error) {
this.logger.warn(`Failed to enable Meilisearch vector store: ${error.message}`);
}
}
async ensureOpenAiEmbedder(
indexName: string,
config: OpenAiEmbedderConfig,
): Promise<boolean> {
const meiliConfig = this.getConfig();
if (!meiliConfig || !config?.apiKey) return false;
await this.enableVectorStore();
const signature = JSON.stringify({
embedderName: config.embedderName,
model: config.model,
documentTemplate: config.documentTemplate,
apiKey: config.apiKey,
});
const cacheKey = `${indexName}:${config.embedderName}`;
if (this.embedderCache.get(cacheKey) === signature) {
return true;
}
const url = `${meiliConfig.host}/indexes/${encodeURIComponent(indexName)}/settings/embedders`;
try {
const response = await this.requestJson(
'PATCH',
url,
{
[config.embedderName]: {
source: 'openAi',
model: config.model,
apiKey: config.apiKey,
documentTemplate: config.documentTemplate,
},
},
this.buildHeaders(meiliConfig),
);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch embedder update failed for index ${indexName}: ${response.status}`,
);
this.logger.warn(
`Meilisearch embedder error body: ${JSON.stringify(response.body)}`,
);
return false;
}
const taskUid = response.body?.taskUid ?? response.body?.uid;
if (Number.isFinite(Number(taskUid))) {
const succeeded = await this.waitForTask(meiliConfig, Number(taskUid), 8000);
if (!succeeded) {
this.logger.warn(`Meilisearch embedder task did not succeed: ${taskUid}`);
return false;
}
}
const hasEmbedder = await this.hasEmbedder(meiliConfig, indexName, config.embedderName);
if (!hasEmbedder) {
this.logger.warn(`Meilisearch embedder missing after update: ${config.embedderName}`);
return false;
}
this.embedderCache.set(cacheKey, signature);
return true;
} catch (error) {
this.logger.warn(`Meilisearch embedder update failed: ${error.message}`);
return false;
}
}
private async waitForTask(
config: MeiliConfig,
taskUid: number,
timeoutMs = 8000,
): Promise<boolean> {
const url = `${config.host}/tasks/${taskUid}`;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const response = await this.requestJson('GET', url, undefined, this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
return false;
}
const status = response.body?.status;
if (status === 'succeeded') return true;
if (status === 'failed' || status === 'canceled') {
this.logger.warn(`Meilisearch task ${taskUid} failed: ${JSON.stringify(response.body?.error)}`);
return false;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return false;
}
private async hasEmbedder(
config: MeiliConfig,
indexName: string,
embedderName: string,
): Promise<boolean> {
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/settings/embedders`;
const response = await this.requestJson('GET', url, undefined, this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
return false;
}
const embedders = response.body || {};
return Boolean(embedders && embedders[embedderName]);
}
}

View File

@@ -110,9 +110,8 @@ export class TenantDatabaseService {
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
*/
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
// Resolve tenant ID first, then get connection by ID
const tenantId = await this.resolveTenantId(tenantIdOrSlug);
return this.getTenantKnexById(tenantId);
// Assume it's a domain if it contains a dot
return this.getTenantKnexByDomain(tenantIdOrSlug);
}
/**

View File

@@ -16,26 +16,45 @@ import { TenantId } from './tenant.decorator';
export class TenantController {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
/**
* Helper to find tenant by ID or domain
*/
private async findTenant(identifier: string) {
const centralPrisma = getCentralPrisma();
// Check if identifier is a CUID (tenant ID) or a domain
const isCUID = /^c[a-z0-9]{24}$/i.test(identifier);
if (isCUID) {
// Look up by tenant ID directly
return centralPrisma.tenant.findUnique({
where: { id: identifier },
select: { id: true, integrationsConfig: true },
});
} else {
// Look up by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: identifier },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
return domainRecord?.tenant;
}
}
/**
* Get integrations configuration for the current tenant
*/
@Get('integrations')
async getIntegrationsConfig(@TenantId() domain: string) {
const centralPrisma = getCentralPrisma();
async getIntegrationsConfig(@TenantId() tenantIdentifier: string) {
const tenant = await this.findTenant(tenantIdentifier);
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
if (!domainRecord?.tenant || !domainRecord.tenant.integrationsConfig) {
if (!tenant || !tenant.integrationsConfig) {
return { data: null };
}
// Decrypt the config
const config = this.tenantDbService.decryptIntegrationsConfig(
domainRecord.tenant.integrationsConfig as any,
tenant.integrationsConfig as any,
);
// Return config with sensitive fields masked
@@ -49,31 +68,26 @@ export class TenantController {
*/
@Put('integrations')
async updateIntegrationsConfig(
@TenantId() domain: string,
@TenantId() tenantIdentifier: string,
@Body() body: { integrationsConfig: any },
) {
const { integrationsConfig } = body;
if (!domain) {
throw new Error('Domain is missing from request');
if (!tenantIdentifier) {
throw new Error('Tenant identifier is missing from request');
}
// Look up tenant by domain
const centralPrisma = getCentralPrisma();
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
const tenant = await this.findTenant(tenantIdentifier);
if (!domainRecord?.tenant) {
throw new Error(`Tenant with domain ${domain} not found`);
if (!tenant) {
throw new Error(`Tenant with identifier ${tenantIdentifier} not found`);
}
// Merge with existing config to preserve masked values
let finalConfig = integrationsConfig;
if (domainRecord.tenant.integrationsConfig) {
if (tenant.integrationsConfig) {
const existingConfig = this.tenantDbService.decryptIntegrationsConfig(
domainRecord.tenant.integrationsConfig as any,
tenant.integrationsConfig as any,
);
// Replace masked values with actual values from existing config
@@ -86,8 +100,9 @@ export class TenantController {
);
// Update in database
const centralPrisma = getCentralPrisma();
await centralPrisma.tenant.update({
where: { id: domainRecord.tenant.id },
where: { id: tenant.id },
data: {
integrationsConfig: encryptedConfig as any,
},

View File

@@ -14,23 +14,26 @@ export class TenantMiddleware implements NestMiddleware {
next: () => void,
) {
try {
// Extract subdomain from hostname
const host = req.headers.host || '';
const hostname = host.split(':')[0]; // Remove port if present
// Priority 1: Check x-tenant-subdomain header from Nitro BFF proxy
// This is the primary method when using the BFF architecture
let subdomain = req.headers['x-tenant-subdomain'] as string | null;
let tenantId = req.headers['x-tenant-id'] as string;
// Check Origin header to get frontend subdomain (for API calls)
if (subdomain) {
this.logger.log(`Using x-tenant-subdomain header: ${subdomain}`);
}
// Priority 2: Fall back to extracting subdomain from Origin/Host headers
// This supports direct backend access for development/testing
if (!subdomain && !tenantId) {
const host = req.headers.host || '';
const hostname = host.split(':')[0];
const origin = req.headers.origin as string;
const referer = req.headers.referer as string;
let parts = hostname.split('.');
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`);
// For local development, accept x-tenant-id header
let tenantId = req.headers['x-tenant-id'] as string;
let subdomain: string | null = null;
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}`);
// Try to extract subdomain from Origin header first (for API calls from frontend)
if (origin) {
@@ -42,7 +45,7 @@ export class TenantMiddleware implements NestMiddleware {
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
} else if (referer && !tenantId) {
} else if (referer) {
// Fallback to Referer if no Origin
try {
const refererUrl = new URL(referer);
@@ -55,20 +58,17 @@ export class TenantMiddleware implements NestMiddleware {
}
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
// For production domains with 3+ parts, extract first part as subdomain
if (parts.length >= 3) {
subdomain = parts[0];
// Ignore www subdomain
if (subdomain === 'www') {
subdomain = null;
}
}
// For development (e.g., tenant1.localhost), also check 2 parts
else if (parts.length === 2 && parts[1] === 'localhost') {
} else if (parts.length === 2 && parts[1] === 'localhost') {
subdomain = parts[0];
}
}
this.logger.log(`Extracted subdomain: ${subdomain}`);
this.logger.log(`Extracted subdomain: ${subdomain}, x-tenant-id: ${tenantId}`);
// Always attach subdomain to request if present
if (subdomain) {
@@ -122,7 +122,7 @@ export class TenantMiddleware implements NestMiddleware {
// Attach tenant info to request object
(req as any).tenantId = tenantId;
} else {
this.logger.warn(`No tenant identified from host: ${hostname}`);
this.logger.warn(`No tenant identified from host: ${subdomain}`);
}
next();

View File

@@ -11,6 +11,7 @@ export interface OpenAIConfig {
apiKey: string;
assistantId?: string;
model?: string;
embeddingModel?: string;
voice?: string;
}

View File

@@ -98,37 +98,75 @@ export class VoiceController {
/**
* TwiML for outbound calls from browser (Twilio Device)
* Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/outbound')
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
// Parse body - Twilio sends URL-encoded form data
let body = req.body as any;
// Handle case where body might be parsed as JSON key (URL-encoded string as key)
if (body && typeof body === 'object' && Object.keys(body).length === 1) {
const key = Object.keys(body)[0];
if (key.startsWith('{') || key.includes('=')) {
try {
// Try parsing as JSON if it looks like JSON
if (key.startsWith('{')) {
body = JSON.parse(key);
} else {
// Parse as URL-encoded
const params = new URLSearchParams(key);
body = Object.fromEntries(params.entries());
}
} catch (e) {
this.logger.warn(`Failed to re-parse body: ${e.message}`);
}
}
}
const to = body.To;
const from = body.From;
const from = body.From; // Format: "client:tenantId:userId"
const callSid = body.CallSid;
this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`);
this.logger.log(`Full body: ${JSON.stringify(body)}`);
this.logger.log(`CallSid: ${callSid}, From: ${from}, To: ${to}`);
try {
// Extract tenant domain from Host header
const host = req.headers.host || '';
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
// Extract tenant ID from the client identity
// Format: "client:tenantId:userId"
let tenantId: string | null = null;
if (from && from.startsWith('client:')) {
const parts = from.replace('client:', '').split(':');
if (parts.length >= 2) {
tenantId = parts[0]; // First part is tenantId
this.logger.log(`Extracted tenantId from client identity: ${tenantId}`);
}
}
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
if (!tenantId) {
this.logger.error(`Could not extract tenant from From: ${from}`);
throw new Error('Could not determine tenant from call');
}
// Look up tenant's Twilio phone number from config
let callerId = to; // Fallback (will cause error if not found)
let callerId: string | undefined;
try {
// Get Twilio config to find the phone number
const { config } = await this.voiceService['getTwilioClient'](tenantDomain);
const { config } = await this.voiceService['getTwilioClient'](tenantId);
callerId = config.phoneNumber;
this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`);
} catch (error: any) {
this.logger.error(`Failed to get Twilio config: ${error.message}`);
throw error;
}
const dialNumber = to;
if (!callerId) {
throw new Error('No caller ID configured for tenant');
}
const dialNumber = to?.trim();
if (!dialNumber) {
throw new Error('No destination number provided');
}
this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`);
@@ -145,10 +183,9 @@ export class VoiceController {
} catch (error: any) {
this.logger.error(`=== ERROR GENERATING TWIML ===`);
this.logger.error(`Error: ${error.message}`);
this.logger.error(`Stack: ${error.stack}`);
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>An error occurred while processing your call.</Say>
<Say>An error occurred while processing your call. ${error.message}</Say>
</Response>`;
res.type('text/xml').send(errorTwiml);
}
@@ -156,13 +193,33 @@ export class VoiceController {
/**
* TwiML for inbound calls
* Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/inbound')
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
// Parse body - Twilio sends URL-encoded form data
let body = req.body as any;
// Handle case where body might be parsed incorrectly
if (body && typeof body === 'object' && Object.keys(body).length === 1) {
const key = Object.keys(body)[0];
if (key.startsWith('{') || key.includes('=')) {
try {
if (key.startsWith('{')) {
body = JSON.parse(key);
} else {
const params = new URLSearchParams(key);
body = Object.fromEntries(params.entries());
}
} catch (e) {
this.logger.warn(`Failed to re-parse body: ${e.message}`);
}
}
}
const callSid = body.CallSid;
const fromNumber = body.From;
const toNumber = body.To;
const toNumber = body.To; // This is the Twilio phone number that was called
this.logger.log(`\n\n╔════════════════════════════════════════╗`);
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
@@ -170,19 +227,28 @@ export class VoiceController {
this.logger.log(`CallSid: ${callSid}`);
this.logger.log(`From: ${fromNumber}`);
this.logger.log(`To: ${toNumber}`);
this.logger.log(`Full body: ${JSON.stringify(body)}`);
try {
// Extract tenant domain from Host header
const host = req.headers.host || '';
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
// Look up tenant by the Twilio phone number that was called
const tenantInfo = await this.voiceService.findTenantByPhoneNumber(toNumber);
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
if (!tenantInfo) {
this.logger.error(`No tenant found for phone number: ${toNumber}`);
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Sorry, this number is not configured. Please contact support.</Say>
<Hangup/>
</Response>`;
return res.type('text/xml').send(twiml);
}
const tenantId = tenantInfo.tenantId;
this.logger.log(`Found tenant: ${tenantId}`);
// Get all connected users for this tenant
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain);
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantId);
this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`);
this.logger.log(`Connected users for tenant ${tenantId}: ${connectedUsers.length}`);
if (connectedUsers.length > 0) {
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
}
@@ -198,20 +264,22 @@ export class VoiceController {
return res.type('text/xml').send(twiml);
}
// Build TwiML to dial all connected clients with Media Streams for AI
const clientElements = connectedUsers.map(userId => ` <Client>${userId}</Client>`).join('\n');
// Build TwiML to dial all connected clients
// Client identity format is now: tenantId:userId
const clientElements = connectedUsers.map(userId => ` <Client>${tenantId}:${userId}</Client>`).join('\n');
// Use wss:// for secure WebSocket (Traefik handles HTTPS)
// Log the client identities being dialed
this.logger.log(`Client identities being dialed:`);
connectedUsers.forEach(userId => {
this.logger.log(` - ${tenantId}:${userId}`);
});
// Use wss:// for secure WebSocket
const host = req.headers.host || 'backend.routebox.co';
const streamUrl = `wss://${host}/api/voice/media-stream`;
this.logger.log(`Stream URL: ${streamUrl}`);
this.logger.log(`Dialing ${connectedUsers.length} client(s)...`);
this.logger.log(`Client IDs to dial: ${connectedUsers.join(', ')}`);
// Verify we have client IDs in proper format
if (connectedUsers.length > 0) {
this.logger.log(`First Client ID format check: "${connectedUsers[0]}" (length: ${connectedUsers[0].length})`);
}
// Notify connected users about incoming call via Socket.IO
connectedUsers.forEach(userId => {
@@ -219,7 +287,7 @@ export class VoiceController {
callSid,
fromNumber,
toNumber,
tenantDomain,
tenantId,
});
});
@@ -227,7 +295,7 @@ export class VoiceController {
<Response>
<Start>
<Stream url="${streamUrl}">
<Parameter name="tenantId" value="${tenantDomain}"/>
<Parameter name="tenantId" value="${tenantId}"/>
<Parameter name="userId" value="${connectedUsers[0]}"/>
</Stream>
</Start>
@@ -236,7 +304,7 @@ ${clientElements}
</Dial>
</Response>`;
this.logger.log(`✓ Returning inbound TwiML with Media Streams - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`✓ Returning inbound TwiML - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`Generated TwiML:\n${twiml}\n`);
res.type('text/xml').send(twiml);
} catch (error: any) {

View File

@@ -61,33 +61,41 @@ export class VoiceGateway
const payload = await this.jwtService.verifyAsync(token);
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
// The domains table stores just the subdomain part (e.g., "tenant1")
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
let domain = 'localhost';
let subdomain = 'localhost';
if (origin) {
try {
const url = new URL(origin);
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
// Extract first part of subdomain as domain
// tenant1.routebox.co -> tenant1
// localhost -> localhost
domain = hostname.split('.')[0];
const hostname = url.hostname;
subdomain = hostname.split('.')[0];
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
}
client.tenantId = domain; // Store the subdomain as tenantId
// Resolve the actual tenantId (UUID) from the subdomain
let tenantId: string | null = null;
try {
const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
if (tenant) {
tenantId = tenant.id;
this.logger.log(`Resolved tenant ${subdomain} -> ${tenantId}`);
}
} catch (error) {
this.logger.warn(`Failed to resolve tenant for subdomain ${subdomain}: ${error.message}`);
}
// Fall back to subdomain if tenant lookup fails
client.tenantId = tenantId || subdomain;
client.userId = payload.sub;
client.tenantSlug = domain; // Same as subdomain
client.tenantSlug = subdomain;
this.connectedUsers.set(client.userId, client);
this.logger.log(
`✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
`✓ Client connected: ${client.id} (User: ${client.userId}, TenantId: ${client.tenantId}, Subdomain: ${subdomain})`,
);
this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`);
this.logger.log(`Total connected users in tenant ${client.tenantId}: ${this.getConnectedUsers(client.tenantId).length}`);
// Send current call state if any active call
const activeCallSid = this.activeCallsByUser.get(client.userId);
@@ -303,13 +311,14 @@ export class VoiceGateway
/**
* Get connected users for a tenant
* @param tenantId - The tenant UUID to filter by
*/
getConnectedUsers(tenantDomain?: string): string[] {
getConnectedUsers(tenantId?: string): string[] {
const userIds: string[] = [];
for (const [userId, socket] of this.connectedUsers.entries()) {
// If tenantDomain specified, filter by tenant
if (!tenantDomain || socket.tenantSlug === tenantDomain) {
// If tenantId specified, filter by tenant
if (!tenantId || socket.tenantId === tenantId) {
userIds.push(userId);
}
}

View File

@@ -31,46 +31,46 @@ export class VoiceService {
/**
* Get Twilio client for a tenant
*/
private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
// Check cache first
if (this.twilioClients.has(tenantIdOrDomain)) {
if (this.twilioClients.has(tenantId)) {
const centralPrisma = getCentralPrisma();
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
// Look up tenant by ID
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },
});
const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
return {
client: this.twilioClients.get(tenantIdOrDomain),
client: this.twilioClients.get(tenantId),
config: config.twilio,
tenantId: domainRecord.tenant.id
tenantId: tenant.id
};
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
this.logger.log(`Looking up tenant: ${tenantId}`);
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },
});
this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
this.logger.log(`Tenant found: ${!!tenant}, Config: ${!!tenant?.integrationsConfig}`);
if (!domainRecord?.tenant) {
throw new Error(`Domain ${tenantIdOrDomain} not found`);
if (!tenant) {
throw new Error(`Tenant ${tenantId} not found`);
}
if (!domainRecord.tenant.integrationsConfig) {
if (!tenant.integrationsConfig) {
throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations');
}
const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any);
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`);
@@ -79,9 +79,9 @@ export class VoiceService {
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
this.twilioClients.set(tenantIdOrDomain, client);
this.twilioClients.set(tenantId, client);
return { client, config: config.twilio, tenantId: domainRecord.tenant.id };
return { client, config: config.twilio, tenantId: tenant.id };
}
/**
@@ -105,22 +105,64 @@ export class VoiceService {
return {};
}
/**
* Find tenant by their configured Twilio phone number
* Used for inbound call routing
*/
async findTenantByPhoneNumber(phoneNumber: string): Promise<{ tenantId: string; config: TwilioConfig } | null> {
const centralPrisma = getCentralPrisma();
// Normalize phone number (remove spaces, ensure + prefix for comparison)
const normalizedPhone = phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
this.logger.log(`Looking up tenant by phone number: ${normalizedPhone}`);
// Get all tenants with integrations config
const tenants = await centralPrisma.tenant.findMany({
where: {
integrationsConfig: { not: null },
},
select: { id: true, integrationsConfig: true },
});
for (const tenant of tenants) {
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
if (config.twilio?.phoneNumber) {
const tenantPhone = config.twilio.phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
if (tenantPhone === normalizedPhone) {
this.logger.log(`Found tenant ${tenant.id} for phone number ${normalizedPhone}`);
return { tenantId: tenant.id, config: config.twilio };
}
}
}
this.logger.warn(`No tenant found for phone number: ${normalizedPhone}`);
return null;
}
/**
* Generate Twilio access token for browser Voice SDK
*/
async generateAccessToken(tenantDomain: string, userId: string): Promise<string> {
const { config, tenantId } = await this.getTwilioClient(tenantDomain);
async generateAccessToken(tenantId: string, userId: string): Promise<string> {
const { config, tenantId: resolvedTenantId } = await this.getTwilioClient(tenantId);
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations');
}
// Include tenantId in the identity so we can extract it in TwiML webhooks
// Format: tenantId:userId
const identity = `${resolvedTenantId}:${userId}`;
this.logger.log(`Generating access token with identity: ${identity}`);
this.logger.log(` Input tenantId: ${tenantId}, Resolved tenantId: ${resolvedTenantId}, userId: ${userId}`);
// Create an access token
const token = new AccessToken(
config.accountSid,
config.apiKey,
config.apiSecret,
{ identity: userId, ttl: 3600 } // 1 hour expiry
{ identity, ttl: 3600 } // 1 hour expiry
);
// Create a Voice grant
@@ -436,20 +478,28 @@ export class VoiceService {
const { callSid, tenantId, userId } = params;
try {
// Get OpenAI config - tenantId might be a domain, so look it up
// Get OpenAI config - tenantId might be a domain or a tenant ID (UUID or CUID)
const centralPrisma = getCentralPrisma();
// Try to find tenant by domain first (if tenantId is like "tenant1")
// Detect if tenantId looks like an ID (UUID or CUID) or a domain name
// UUIDs: 8-4-4-4-12 hex format
// CUIDs: 25 character alphanumeric starting with 'c'
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(tenantId);
const isCUID = /^c[a-z0-9]{24}$/i.test(tenantId);
const isId = isUUID || isCUID;
let tenant;
if (!tenantId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-/i)) {
// Looks like a domain, not a UUID
if (!isId) {
// Looks like a domain, not an ID
this.logger.log(`Looking up tenant by domain: ${tenantId}`);
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantId },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
tenant = domainRecord?.tenant;
} else {
// It's a UUID
// It's an ID (UUID or CUID)
this.logger.log(`Looking up tenant by ID: ${tenantId}`);
tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Process Builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
{
"name": "ai-processes-editor",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vite build",
"preview": "vite preview --port 5174"
},
"dependencies": {
"@xyflow/react": "^12.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.2"
}
}

View File

@@ -1,153 +0,0 @@
import { useCallback, useEffect } from 'react'
import {
Background,
Controls,
MiniMap,
ReactFlow,
useEdgesState,
useNodesState,
addEdge,
Connection,
Edge,
Node,
Panel,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import './styles.css'
const nodeTypes = {
Start: { style: { background: '#22c55e', color: 'white', padding: 10, borderRadius: 5 } },
LLMDecisionNode: { style: { background: '#3b82f6', color: 'white', padding: 10, borderRadius: 5 } },
ToolNode: { style: { background: '#f59e0b', color: 'white', padding: 10, borderRadius: 5 } },
HumanInputNode: { style: { background: '#8b5cf6', color: 'white', padding: 10, borderRadius: 5 } },
End: { style: { background: '#ef4444', color: 'white', padding: 10, borderRadius: 5 } },
}
const initialNodes: Node[] = [
{
id: 'start-1',
type: 'default',
data: { label: '🟢 Start', type: 'Start' },
position: { x: 250, y: 50 },
style: nodeTypes.Start.style,
},
{
id: 'end-1',
type: 'default',
data: { label: '🔴 End', type: 'End' },
position: { x: 250, y: 400 },
style: nodeTypes.End.style,
},
]
const initialEdges: Edge[] = []
export const App = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
)
// Send graph updates to parent window
const notifyParent = useCallback(() => {
const graphData = {
id: 'process-graph',
name: 'Process',
nodes: nodes.map((node) => ({
id: node.id,
type: node.data.type || 'Start',
position: node.position,
data: node.data,
})),
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
condition: edge.data?.condition,
})),
}
window.parent.postMessage(
{
type: 'GRAPH_UPDATED',
payload: graphData,
},
'*'
)
}, [nodes, edges])
// Listen for graph load from parent
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'LOAD_GRAPH') {
const graph = event.data.payload
if (graph && graph.nodes && graph.edges) {
setNodes(
graph.nodes.map((node: any) => ({
id: node.id,
type: 'default',
data: { label: node.data.label || node.type, ...node.data },
position: node.position,
style: nodeTypes[node.type as keyof typeof nodeTypes]?.style || {},
}))
)
setEdges(
graph.edges.map((edge: any) => ({
id: edge.id,
source: edge.source,
target: edge.target,
data: edge.condition ? { condition: edge.condition } : undefined,
}))
)
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [setNodes, setEdges])
// Notify parent on changes
useEffect(() => {
notifyParent()
}, [nodes, edges, notifyParent])
const addNode = (type: string) => {
const newNode: Node = {
id: `${type.toLowerCase()}-${Date.now()}`,
type: 'default',
data: { label: `${type}`, type },
position: { x: Math.random() * 400 + 50, y: Math.random() * 300 + 100 },
style: nodeTypes[type as keyof typeof nodeTypes]?.style || {},
}
setNodes((nds) => nds.concat(newNode))
}
return (
<div className="editor-shell">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Panel position="top-left" className="node-palette">
<h3>Node Palette</h3>
<button onClick={() => addNode('LLMDecisionNode')}>🔵 LLM Decision</button>
<button onClick={() => addNode('ToolNode')}>🟠 Tool</button>
<button onClick={() => addNode('HumanInputNode')}>🟣 Human Input</button>
<button onClick={() => addNode('End')}>🔴 End</button>
</Panel>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
</div>
)
}

View File

@@ -1,7 +0,0 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.getElementById('root')
if (root) {
createRoot(root).render(<App />)
}

View File

@@ -1,71 +0,0 @@
body {
margin: 0;
font-family: 'Inter', sans-serif;
color: #0f172a;
}
#root {
width: 100%;
height: 100vh;
}
.editor-shell {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
}
.node-palette {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 150px;
}
.node-palette h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.node-palette button {
display: block;
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.node-palette button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.node-palette button:active {
background: #e2e8f0;
}
.react-flow__node {
font-size: 12px;
font-weight: 500;
}
.react-flow__edge-path {
stroke: #64748b;
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #3b82f6;
}

View File

@@ -1,9 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
},
})

View File

@@ -7,36 +7,15 @@ import {
InputGroupText,
} from '@/components/ui/input-group'
import { Separator } from '@/components/ui/separator'
import { ArrowUp, Loader2 } from 'lucide-vue-next'
import { ArrowUp } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import { useApi } from '@/composables/useApi'
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
text: string;
isStreaming?: boolean;
}
interface StreamEvent {
type: string;
data?: any;
processId?: string;
nodeId?: string;
toolName?: string;
}
const chatInput = ref('')
const messages = ref<ChatMessage[]>([])
const messages = ref<{ role: 'user' | 'assistant'; text: string }[]>([])
const sending = ref(false)
const route = useRoute()
const { api } = useApi()
const sessionId = ref<string | null>(null)
const eventSource = ref<EventSource | null>(null)
const getTenantId = () => {
if (!import.meta.client) return 'tenant1'
return localStorage.getItem('tenantId') || 'tenant1'
}
const buildContext = () => {
const recordId = route.params.recordId ? String(route.params.recordId) : undefined
@@ -54,97 +33,6 @@ const buildContext = () => {
}
}
const connectToStream = (sessionIdValue: string) => {
if (eventSource.value) {
eventSource.value.close()
}
const baseUrl = window.location.hostname === 'localhost'
? 'http://localhost:3000'
: `https://${window.location.hostname}`
eventSource.value = new EventSource(
`${baseUrl}/tenants/${getTenantId()}/ai-chat/stream?sessionId=${sessionIdValue}`
)
eventSource.value.onmessage = (event) => {
try {
const payload: StreamEvent = JSON.parse(event.data)
handleStreamEvent(payload)
} catch (error) {
console.error('Failed to parse stream event:', error)
}
}
eventSource.value.onerror = () => {
eventSource.value?.close()
eventSource.value = null
}
}
const handleStreamEvent = (event: StreamEvent) => {
switch (event.type) {
case 'agent_started':
// Agent is thinking
break
case 'processes_listed':
// Processes discovered
break
case 'process_selected':
messages.value.push({
role: 'system',
text: `🔄 Selected process: ${event.data?.processName || 'Process'}`,
})
break
case 'agent_message':
messages.value.push({
role: 'assistant',
text: event.data?.message || '',
})
break
case 'node_started':
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) {
lastMsg.text += `\n⚙ Executing step...`
}
break
case 'tool_called':
const lastToolMsg = messages.value[messages.value.length - 1]
if (lastToolMsg?.isStreaming) {
lastToolMsg.text += `\n🔧 Using tool: ${event.toolName}`
}
break
case 'need_input':
messages.value.push({
role: 'assistant',
text: event.data?.prompt || 'I need some additional information from you.',
})
sending.value = false
break
case 'final':
if (event.data?.output) {
messages.value.push({
role: 'assistant',
text: event.data.message || '✅ Process completed successfully!',
})
} else if (event.data?.reply) {
messages.value.push({
role: 'assistant',
text: event.data.reply,
})
}
sending.value = false
break
case 'error':
messages.value.push({
role: 'assistant',
text: `❌ Error: ${event.data?.error || 'An error occurred'}`,
})
sending.value = false
break
}
}
const handleSend = async () => {
if (!chatInput.value.trim()) return
@@ -153,53 +41,31 @@ const handleSend = async () => {
chatInput.value = ''
sending.value = true
// Add a streaming message placeholder
messages.value.push({
role: 'assistant',
text: '🤔 Thinking...',
isStreaming: true
})
try {
const history = messages.value
.filter(m => m.role !== 'system' && !m.isStreaming)
.slice(0, -1)
.slice(-6)
.map(m => ({ role: m.role, text: m.text }))
const response = await api.post(`/tenants/${getTenantId()}/ai-chat/messages`, {
const history = messages.value.slice(0, -1).slice(-6)
const response = await api.post('/ai/chat', {
message,
history,
context: buildContext(),
sessionId: sessionId.value || undefined,
})
if (response.sessionId && !sessionId.value) {
sessionId.value = response.sessionId
connectToStream(response.sessionId)
}
// Remove streaming placeholder and add response
messages.value = messages.value.filter(m => !m.isStreaming)
if (response.reply) {
messages.value.push({
role: 'assistant',
text: response.reply,
text: response.reply || 'Let me know what else you need.',
})
}
// If process is running, stream will handle updates
if (response.runId) {
messages.value.push({
role: 'assistant',
text: '⏳ Processing workflow...',
isStreaming: true,
})
if (response.action === 'create_record') {
window.dispatchEvent(
new CustomEvent('ai-record-created', {
detail: {
objectApiName: buildContext().objectApiName,
record: response.record,
},
}),
)
}
} catch (error: any) {
console.error('Failed to send AI chat message:', error)
messages.value = messages.value.filter(m => !m.isStreaming)
messages.value.push({
role: 'assistant',
text: error.message || 'Sorry, I ran into an error. Please try again.',
@@ -208,17 +74,11 @@ const handleSend = async () => {
sending.value = false
}
}
onUnmounted(() => {
if (eventSource.value) {
eventSource.value.close()
}
})
</script>
<template>
<div class="ai-chat-area w-full border-t border-border p-4 bg-neutral-50">
<div class="ai-chat-messages mb-4 space-y-3 max-h-[400px] overflow-y-auto">
<div class="ai-chat-messages mb-4 space-y-3">
<div
v-for="(message, index) in messages"
:key="`${message.role}-${index}`"
@@ -226,19 +86,14 @@ onUnmounted(() => {
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-line"
:class="{
'bg-primary text-primary-foreground': message.role === 'user',
'bg-white border border-border text-foreground': message.role === 'assistant',
'bg-blue-50 border border-blue-200 text-blue-900 text-xs': message.role === 'system',
}"
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-white border border-border text-foreground'"
>
<Loader2 v-if="message.isStreaming" class="inline-block size-3 animate-spin mr-1" />
{{ message.text }}
</div>
</div>
<p v-if="messages.length === 0" class="text-sm text-muted-foreground">
Ask the assistant to execute business processes, add records, or answer questions.
Ask the assistant to add records, filter lists, or summarize the page.
</p>
</div>
<InputGroup>

View File

@@ -0,0 +1,264 @@
<template>
<div class="list-view-layout-editor">
<div class="flex h-full">
<!-- Selected Fields Area -->
<div class="flex-1 p-4 overflow-auto">
<div class="mb-4 flex justify-between items-center">
<h3 class="text-lg font-semibold">{{ layoutName || 'List View Layout' }}</h3>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="handleClear">
Clear All
</Button>
<Button size="sm" @click="handleSave">
Save Layout
</Button>
</div>
</div>
<div class="border rounded-lg bg-slate-50 dark:bg-slate-900 p-4 min-h-[400px]">
<p class="text-sm text-muted-foreground mb-4">
Drag fields to reorder them. Fields will appear in the list view in this order.
</p>
<div v-if="selectedFields.length === 0" class="text-center py-8 text-muted-foreground">
<p>No fields selected.</p>
<p class="text-sm">Click or drag fields from the right panel to add them.</p>
</div>
<div
v-else
ref="sortableContainer"
class="space-y-2"
>
<div
v-for="(field, index) in selectedFields"
:key="field.id"
class="p-3 border rounded cursor-move bg-white dark:bg-slate-800 hover:border-primary transition-colors flex items-center justify-between"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragover.prevent="handleDragOver($event, index)"
@drop="handleDrop($event, index)"
>
<div class="flex items-center gap-3">
<span class="text-muted-foreground cursor-grab">
<GripVertical class="w-4 h-4" />
</span>
<div>
<div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">
{{ field.apiName }} {{ formatFieldType(field.type) }}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
@click="removeField(field.id)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
<!-- Available Fields Sidebar -->
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to list</p>
<div v-if="availableFields.length === 0" class="text-center py-4 text-muted-foreground text-sm">
All fields have been added to the layout.
</div>
<div v-else class="space-y-2">
<div
v-for="field in availableFields"
:key="field.id"
class="p-3 border rounded cursor-pointer bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
draggable="true"
@dragstart="handleAvailableFieldDragStart($event, field)"
@click="addField(field)"
>
<div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">
{{ field.apiName }} {{ formatFieldType(field.type) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { GripVertical, X } from 'lucide-vue-next'
import type { FieldLayoutItem } from '~/types/page-layout'
import type { FieldConfig } from '~/types/field-types'
import { Button } from '@/components/ui/button'
const props = defineProps<{
fields: FieldConfig[]
initialLayout?: FieldLayoutItem[]
layoutName?: string
}>()
const emit = defineEmits<{
save: [layout: { fields: FieldLayoutItem[] }]
}>()
// Selected fields in order
const selectedFieldIds = ref<string[]>([])
const draggedIndex = ref<number | null>(null)
const draggedAvailableField = ref<FieldConfig | null>(null)
// Initialize with initial layout
watch(() => props.initialLayout, (layout) => {
if (layout && layout.length > 0) {
// Sort by order if available, otherwise use array order
const sorted = [...layout].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
selectedFieldIds.value = sorted.map(item => item.fieldId)
}
}, { immediate: true })
// Computed selected fields in order
const selectedFields = computed(() => {
return selectedFieldIds.value
.map(id => props.fields.find(f => f.id === id))
.filter((f): f is FieldConfig => f !== undefined)
})
// Available fields (not selected)
const availableFields = computed(() => {
const selectedSet = new Set(selectedFieldIds.value)
return props.fields.filter(field => !selectedSet.has(field.id))
})
const formatFieldType = (type: string): string => {
const typeNames: Record<string, string> = {
'TEXT': 'Text',
'LONG_TEXT': 'Textarea',
'EMAIL': 'Email',
'PHONE': 'Phone',
'NUMBER': 'Number',
'CURRENCY': 'Currency',
'PERCENT': 'Percent',
'PICKLIST': 'Picklist',
'MULTI_PICKLIST': 'Multi-select',
'BOOLEAN': 'Checkbox',
'DATE': 'Date',
'DATE_TIME': 'DateTime',
'TIME': 'Time',
'URL': 'URL',
'LOOKUP': 'Lookup',
'FILE': 'File',
'IMAGE': 'Image',
'JSON': 'JSON',
'text': 'Text',
'textarea': 'Textarea',
'email': 'Email',
'number': 'Number',
'currency': 'Currency',
'select': 'Picklist',
'multiSelect': 'Multi-select',
'boolean': 'Checkbox',
'date': 'Date',
'datetime': 'DateTime',
'url': 'URL',
'lookup': 'Lookup',
'belongsTo': 'Lookup',
}
return typeNames[type] || type
}
const addField = (field: FieldConfig) => {
if (!selectedFieldIds.value.includes(field.id)) {
selectedFieldIds.value.push(field.id)
}
}
const removeField = (fieldId: string) => {
selectedFieldIds.value = selectedFieldIds.value.filter(id => id !== fieldId)
}
// Drag and drop for reordering
const handleDragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index
draggedAvailableField.value = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const handleDragOver = (event: DragEvent, index: number) => {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
const handleDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault()
// Handle drop from available fields
if (draggedAvailableField.value) {
addField(draggedAvailableField.value)
// Move the newly added field to the target position
const newFieldId = draggedAvailableField.value.id
const currentIndex = selectedFieldIds.value.indexOf(newFieldId)
if (currentIndex !== -1 && currentIndex !== targetIndex) {
const ids = [...selectedFieldIds.value]
ids.splice(currentIndex, 1)
ids.splice(targetIndex, 0, newFieldId)
selectedFieldIds.value = ids
}
draggedAvailableField.value = null
return
}
// Handle reordering within selected fields
if (draggedIndex.value === null || draggedIndex.value === targetIndex) {
draggedIndex.value = null
return
}
const ids = [...selectedFieldIds.value]
const [removed] = ids.splice(draggedIndex.value, 1)
ids.splice(targetIndex, 0, removed)
selectedFieldIds.value = ids
draggedIndex.value = null
}
// Drag from available fields
const handleAvailableFieldDragStart = (event: DragEvent, field: FieldConfig) => {
draggedAvailableField.value = field
draggedIndex.value = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy'
}
}
const handleClear = () => {
if (confirm('Are you sure you want to clear all fields from the layout?')) {
selectedFieldIds.value = []
}
}
const handleSave = () => {
const layout: FieldLayoutItem[] = selectedFieldIds.value.map((fieldId, index) => ({
fieldId,
order: index,
}))
emit('save', { fields: layout })
}
</script>
<style scoped>
.list-view-layout-editor {
height: calc(100vh - 300px);
min-height: 500px;
}
</style>

View File

@@ -3,90 +3,32 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const config = useRuntimeConfig()
const router = useRouter()
const { toast } = useToast()
const { login, isLoading } = useAuth()
// Cookie for server-side auth check
const tokenCookie = useCookie('token')
// Extract subdomain from hostname (e.g., tenant1.localhost → tenant1)
const getSubdomain = () => {
if (!import.meta.client) return null
const hostname = window.location.hostname
const parts = hostname.split('.')
console.log('Extracting subdomain from:', hostname, 'parts:', parts)
// For localhost development: tenant1.localhost or localhost
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return null // Use default tenant for plain localhost
}
// For subdomains like tenant1.routebox.co or tenant1.localhost
if (parts.length >= 2 && parts[0] !== 'www') {
console.log('Using subdomain:', parts[0])
return parts[0] // Return subdomain
}
return null
}
const subdomain = ref(getSubdomain())
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
try {
loading.value = true
error.value = ''
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Only send x-tenant-id if we have a subdomain
if (subdomain.value) {
headers['x-tenant-id'] = subdomain.value
}
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, {
method: 'POST',
headers,
body: JSON.stringify({
email: email.value,
password: password.value,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Login failed')
}
const data = await response.json()
// Store credentials in localStorage
// Store the tenant ID that was used for login
const tenantToStore = subdomain.value || data.user?.tenantId || 'tenant1'
localStorage.setItem('tenantId', tenantToStore)
localStorage.setItem('token', data.access_token)
localStorage.setItem('user', JSON.stringify(data.user))
// Also store token in cookie for server-side auth check
tokenCookie.value = data.access_token
// Use the BFF login endpoint via useAuth
const result = await login(email.value, password.value)
if (result.success) {
toast.success('Login successful!')
// Redirect to home
router.push('/')
} else {
error.value = result.error || 'Login failed'
toast.error(result.error || 'Login failed')
}
} catch (e: any) {
error.value = e.message || 'Login failed'
toast.error(e.message || 'Login failed')
} finally {
loading.value = false
}
}
</script>
@@ -118,8 +60,8 @@ const handleLogin = async () => {
</div>
<Input id="password" v-model="password" type="password" required />
</div>
<Button type="submit" class="w-full" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
<Button type="submit" class="w-full" :disabled="isLoading">
{{ isLoading ? 'Logging in...' : 'Login' }}
</Button>
</div>
<div class="text-center text-sm">

View File

@@ -186,6 +186,8 @@ interface Props {
objectApiName: string;
recordId: string;
ownerId?: string;
/** Optional base URL override for shares API. Defaults to /runtime/objects/{objectApiName}/records/{recordId}/shares */
basePath?: string;
}
const props = defineProps<Props>();
@@ -193,6 +195,11 @@ const props = defineProps<Props>();
const { api } = useApi();
const { toast } = useToast();
/** Computed base path for all share API calls */
const sharesBasePath = computed(() =>
props.basePath || `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
);
const loading = ref(true);
const sharing = ref(false);
const removing = ref<string | null>(null);
@@ -236,9 +243,7 @@ const loadShares = async () => {
try {
loading.value = true;
error.value = null;
const response = await api.get(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
);
const response = await api.get(sharesBasePath.value);
shares.value = response || [];
} catch (e: any) {
console.error('Failed to load shares:', e);
@@ -286,7 +291,7 @@ const createShare = async () => {
console.log('Final payload:', payload);
await api.post(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`,
sharesBasePath.value,
payload
);
toast.success('Record shared successfully');
@@ -313,7 +318,7 @@ const removeShare = async (shareId: string) => {
try {
removing.value = shareId;
await api.delete(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}`
`${sharesBasePath.value}/${shareId}`
);
toast.success('Share removed successfully');
await loadShares();

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Pencil, Trash2, Users, Check, X, ChevronLeft } from 'lucide-vue-next'
import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews'
interface Props {
open: boolean
views: SavedView[]
objectLabel: string
activeViewId?: string | null
}
const props = withDefaults(defineProps<Props>(), {
activeViewId: null,
})
const emit = defineEmits<{
'update:open': [value: boolean]
'apply-view': [view: SavedView]
'update-view': [id: string, payload: UpdateSavedViewPayload]
'delete-view': [view: SavedView]
}>()
const editingId = ref<string | null>(null)
const editName = ref('')
const deletingId = ref<string | null>(null)
// Sharing sub-view: when set, renders RecordSharing for this view
const sharingView = ref<SavedView | null>(null)
const ownViews = computed(() => props.views.filter(v => v.isOwner))
const sharedViews = computed(() => props.views.filter(v => !v.isOwner))
function startEdit(view: SavedView) {
editingId.value = view.id
editName.value = view.name
}
function cancelEdit() {
editingId.value = null
editName.value = ''
}
function commitEdit(view: SavedView) {
const name = editName.value.trim()
if (name && name !== view.name) {
emit('update-view', view.id, { name })
}
cancelEdit()
}
function openSharing(view: SavedView) {
sharingView.value = view
}
function closeSharing() {
sharingView.value = null
}
function confirmDelete(view: SavedView) {
deletingId.value = view.id
}
function cancelDelete() {
deletingId.value = null
}
function executeDelete(view: SavedView) {
emit('delete-view', view)
deletingId.value = null
}
</script>
<template>
<Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent class="w-[420px] sm:w-[520px] overflow-y-auto">
<!-- Sharing sub-view -->
<template v-if="sharingView">
<SheetHeader class="mb-4">
<div class="flex items-center gap-2">
<Button size="icon" variant="ghost" class="h-7 w-7 -ml-1" @click="closeSharing">
<ChevronLeft class="h-4 w-4" />
</Button>
<div>
<SheetTitle>Share "{{ sharingView.name }}"</SheetTitle>
<SheetDescription>
Grant access to specific users for this saved view.
</SheetDescription>
</div>
</div>
</SheetHeader>
<RecordSharing
object-api-name="SavedListView"
:record-id="sharingView.id"
:owner-id="sharingView.userId"
:base-path="`/saved-views/${sharingView.id}/shares`"
/>
</template>
<!-- Main view list -->
<template v-else>
<SheetHeader class="mb-4">
<SheetTitle>{{ objectLabel }} Saved Views</SheetTitle>
<SheetDescription>
Manage your saved searches. Share views with specific users from your workspace.
</SheetDescription>
</SheetHeader>
<!-- Own Views -->
<section>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
My Views
</p>
<div v-if="ownViews.length === 0" class="text-sm text-muted-foreground py-3">
You have no saved views yet. Run a search and click <strong>Save view</strong>.
</div>
<ul class="space-y-1">
<li
v-for="view in ownViews"
:key="view.id"
class="group rounded-md border bg-card px-3 py-2"
>
<!-- Confirm delete row -->
<div v-if="deletingId === view.id" class="flex items-center gap-2">
<span class="flex-1 text-sm text-destructive">Delete "{{ view.name }}"?</span>
<Button size="sm" variant="destructive" @click="executeDelete(view)">Delete</Button>
<Button size="sm" variant="outline" @click="cancelDelete">Cancel</Button>
</div>
<!-- Edit name row -->
<div v-else-if="editingId === view.id" class="flex items-center gap-2">
<Input
v-model="editName"
class="h-7 flex-1 text-sm"
@keyup.enter="commitEdit(view)"
@keyup.escape="cancelEdit"
autofocus
/>
<Button size="icon" variant="ghost" class="h-7 w-7" @click="commitEdit(view)">
<Check class="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" class="h-7 w-7" @click="cancelEdit">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<!-- Normal row -->
<div v-else class="flex items-center gap-2 min-h-[28px]">
<!-- View name (click to apply) -->
<button
class="flex-1 text-left text-sm truncate hover:text-primary transition-colors"
:class="{ 'font-medium text-primary': activeViewId === view.id }"
@click="emit('apply-view', view); emit('update:open', false)"
>
{{ view.name }}
</button>
<!-- Actions (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="icon" variant="ghost" class="h-6 w-6" title="Rename" @click="startEdit(view)">
<Pencil class="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
class="h-6 w-6"
title="Share"
@click="openSharing(view)"
>
<Users class="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" class="h-6 w-6 text-destructive hover:text-destructive" title="Delete" @click="confirmDelete(view)">
<Trash2 class="h-3 w-3" />
</Button>
</div>
</div>
<!-- Description -->
<p
v-if="view.description && editingId !== view.id && deletingId !== view.id"
class="text-xs text-muted-foreground mt-1 truncate"
>
{{ view.description }}
</p>
</li>
</ul>
</section>
<!-- Shared views by others -->
<template v-if="sharedViews.length > 0">
<Separator class="my-4" />
<section>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Shared with me
</p>
<ul class="space-y-1">
<li
v-for="view in sharedViews"
:key="view.id"
class="rounded-md border bg-card px-3 py-2"
>
<button
class="w-full text-left text-sm truncate hover:text-primary transition-colors"
:class="{ 'font-medium text-primary': activeViewId === view.id }"
@click="emit('apply-view', view); emit('update:open', false)"
>
{{ view.name }}
</button>
<p v-if="view.description" class="text-xs text-muted-foreground mt-1 truncate">
{{ view.description }}
</p>
</li>
</ul>
</section>
</template>
</template>
</SheetContent>
</Sheet>
</template>

View File

@@ -1,52 +0,0 @@
<template>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="mb-3 text-sm font-semibold text-amber-900">{{ prompt }}</p>
<form class="space-y-3" @submit.prevent="submit">
<div v-for="field in fields" :key="field.name" class="space-y-1">
<label class="text-xs font-medium text-slate-600">
{{ field.label }}
</label>
<input
v-model="form[field.name]"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm"
:type="field.type === 'number' ? 'number' : 'text'"
:required="field.required"
/>
</div>
<button
type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-semibold text-white"
>
Submit
</button>
</form>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
schema: Record<string, any>
prompt: string
}>()
const emit = defineEmits<{
(event: 'submit', payload: Record<string, unknown>): void
}>()
const form = reactive<Record<string, string>>({})
const fields = computed(() => {
const properties = props.schema?.properties || {}
const required = props.schema?.required || []
return Object.entries(properties).map(([name, config]: [string, any]) => ({
name,
label: config.title || name,
type: config.type || 'string',
required: required.includes(name),
}))
})
const submit = () => {
emit('submit', { ...form })
}
</script>

View File

@@ -1,19 +0,0 @@
<template>
<div class="rounded-lg border border-slate-200 bg-white shadow">
<div class="border-b border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700">
Process Graph Editor
</div>
<iframe
class="h-[640px] w-full"
:src="editorUrl"
title="AI Process Builder"
/>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const editorUrl = computed(() =>
config.public.aiProcessEditorUrl || 'http://localhost:5174'
)
</script>

View File

@@ -85,9 +85,31 @@ const formatValue = (val: any): string => {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
try {
const date = val instanceof Date ? val : new Date(val)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
} catch {
return String(val)
}
case FieldType.DATETIME:
return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString()
try {
const date = val instanceof Date ? val : new Date(val)
return date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
} catch {
return String(val)
}
case FieldType.BOOLEAN:
return val ? 'Yes' : 'No'
case FieldType.CURRENCY:
@@ -227,6 +249,51 @@ const handleRelationTypeUpdate = (value: string | null) => {
</SelectContent>
</Select>
<!-- Multi-Select -->
<div v-else-if="field.type === FieldType.MULTI_SELECT" class="space-y-2">
<div class="flex flex-wrap gap-1 min-h-[36px] rounded-md border border-input bg-background px-3 py-2">
<Badge
v-for="selectedVal in (Array.isArray(value) ? value : [])"
:key="String(selectedVal)"
variant="secondary"
class="gap-1 cursor-pointer"
@click="value = (value || []).filter((v: any) => v !== selectedVal)"
>
{{ field.options?.find(o => o.value === selectedVal)?.label || selectedVal }}
<span class="text-xs ml-1">&times;</span>
</Badge>
<span v-if="!value || (Array.isArray(value) && value.length === 0)" class="text-sm text-muted-foreground">
{{ field.placeholder || 'Select options...' }}
</span>
</div>
<div class="space-y-1">
<div
v-for="option in field.options"
:key="String(option.value)"
class="flex items-center gap-2"
>
<Checkbox
:id="`${field.id}-${option.value}`"
:checked="Array.isArray(value) && value.includes(option.value)"
@update:checked="(checked: boolean) => {
const current = Array.isArray(value) ? [...value] : []
if (checked) {
current.push(option.value)
} else {
const idx = current.indexOf(option.value)
if (idx > -1) current.splice(idx, 1)
}
value = current
}"
:disabled="field.isReadOnly"
/>
<Label :for="`${field.id}-${option.value}`" class="text-sm font-normal cursor-pointer">
{{ option.label }}
</Label>
</div>
</div>
</div>
<!-- Boolean - Checkbox -->
<div v-else-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
<Checkbox :id="field.id" v-model:checked="value" :disabled="field.isReadOnly" />

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import { useApi } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
type CommentRecord = {
id: string
content: string
author_user_id: string
created_at: string
updated_at: string
}
interface Props {
objectApiName: string
recordId: string
}
const props = defineProps<Props>()
const { api } = useApi()
const { user } = useAuth()
const comments = ref<CommentRecord[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const newComment = ref('')
const saving = ref(false)
const editingId = ref<string | null>(null)
const editContent = ref('')
const isOwner = (comment: CommentRecord) => comment.author_user_id === user.value?.id
const formatDate = (value?: string) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const canSubmit = computed(() => newComment.value.trim().length > 0 && !saving.value)
const canSaveEdit = computed(() => editContent.value.trim().length > 0 && !saving.value)
const fetchComments = async () => {
if (!props.objectApiName || !props.recordId) return
loading.value = true
error.value = null
try {
const data = await api.get(`/knowledge/comments/${props.objectApiName}/${props.recordId}`)
comments.value = Array.isArray(data) ? data : []
} catch (e: any) {
error.value = e.message || 'Failed to load comments'
} finally {
loading.value = false
}
}
const addComment = async () => {
if (!canSubmit.value) return
saving.value = true
try {
await api.post('/knowledge/comments', {
parentObjectApiName: props.objectApiName,
parentRecordId: props.recordId,
content: newComment.value.trim(),
})
newComment.value = ''
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to add comment'
} finally {
saving.value = false
}
}
const startEdit = (comment: CommentRecord) => {
editingId.value = comment.id
editContent.value = comment.content
}
const cancelEdit = () => {
editingId.value = null
editContent.value = ''
}
const saveEdit = async () => {
if (!editingId.value || !canSaveEdit.value) return
saving.value = true
try {
await api.patch(`/knowledge/comments/${editingId.value}`, {
content: editContent.value.trim(),
})
editingId.value = null
editContent.value = ''
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to update comment'
} finally {
saving.value = false
}
}
const deleteComment = async (comment: CommentRecord) => {
if (!confirm('Delete this comment?')) return
saving.value = true
try {
await api.delete(`/knowledge/comments/${comment.id}`)
await fetchComments()
} catch (e: any) {
error.value = e.message || 'Failed to delete comment'
} finally {
saving.value = false
}
}
watch(
() => [props.objectApiName, props.recordId],
() => {
fetchComments()
},
{ immediate: true },
)
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Comments</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-3">
<Textarea
v-model="newComment"
placeholder="Add a comment..."
:disabled="saving"
class="min-h-[96px]"
/>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground" v-if="error">{{ error }}</p>
<Button size="sm" :disabled="!canSubmit" @click="addComment">
Add Comment
</Button>
</div>
</div>
<Separator />
<div v-if="loading" class="text-sm text-muted-foreground">Loading comments...</div>
<div v-else-if="comments.length === 0" class="text-sm text-muted-foreground">
No comments yet.
</div>
<div v-else class="space-y-4">
<div v-for="comment in comments" :key="comment.id" class="rounded-lg border p-4 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs text-muted-foreground">
<span>Author: {{ comment.author_user_id }}</span>
<span class="mx-2"></span>
<span>{{ formatDate(comment.created_at) }}</span>
</div>
<div class="flex items-center gap-2" v-if="isOwner(comment)">
<Button variant="ghost" size="sm" @click="startEdit(comment)">Edit</Button>
<Button variant="ghost" size="sm" @click="deleteComment(comment)">Delete</Button>
</div>
</div>
<div v-if="editingId === comment.id" class="space-y-2">
<Textarea v-model="editContent" :disabled="saving" class="min-h-[80px]" />
<div class="flex items-center gap-2">
<Button size="sm" :disabled="!canSaveEdit" @click="saveEdit">Save</Button>
<Button variant="ghost" size="sm" @click="cancelEdit">Cancel</Button>
</div>
</div>
<p v-else class="text-sm whitespace-pre-line">{{ comment.content }}</p>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { useApi } from '@/composables/useApi'
type SemanticLink = {
id: string
source_entity_type: string
source_entity_id: string
target_entity_type: string
target_entity_id: string
source_entity_label?: string
target_entity_label?: string
source_entity_name?: string
target_entity_name?: string
link_type: string
status: string
origin: string
confidence?: number
reason?: string
evidence?: any
updated_at?: string
}
interface Props {
objectApiName: string
recordId: string
}
const props = defineProps<Props>()
const { api } = useApi()
const links = ref<SemanticLink[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const activeTab = ref<'all' | 'suggested' | 'approved' | 'rejected' | 'dismissed'>('suggested')
const formatDate = (value?: string) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const formatConfidence = (value?: number) => {
if (value === undefined || value === null) return '—'
return `${Math.round(value * 100)}%`
}
const getOtherSide = (link: SemanticLink) => {
const isSource =
link.source_entity_type === props.objectApiName &&
link.source_entity_id === props.recordId
return {
entityType: isSource ? link.target_entity_type : link.source_entity_type,
entityId: isSource ? link.target_entity_id : link.source_entity_id,
entityLabel: isSource ? link.target_entity_label : link.source_entity_label,
entityName: isSource ? link.target_entity_name : link.source_entity_name,
}
}
const formatLinkType = (value?: string) => {
if (!value) return 'Related'
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const parseEvidence = (raw: any) => {
if (!raw) return null
if (typeof raw === 'object') return raw
try {
return JSON.parse(raw)
} catch {
return null
}
}
const fetchLinks = async () => {
if (!props.objectApiName || !props.recordId) return
loading.value = true
error.value = null
try {
const params =
activeTab.value === 'all'
? undefined
: { status: activeTab.value }
const data = await api.get(`/knowledge/semantic/links/${props.objectApiName}/${props.recordId}`, {
params,
})
links.value = Array.isArray(data) ? data : []
} catch (e: any) {
error.value = e.message || 'Failed to load semantic links'
} finally {
loading.value = false
}
}
const reviewLink = async (id: string, status: 'approved' | 'rejected' | 'dismissed') => {
try {
await api.patch(`/knowledge/semantic/links/${id}/review`, { status })
await fetchLinks()
} catch (e: any) {
error.value = e.message || 'Failed to update link'
}
}
const canApprove = (status: string) => status !== 'approved'
const canReject = (status: string) => status !== 'rejected'
const canDismiss = (status: string) => status !== 'dismissed'
watch(
() => [props.objectApiName, props.recordId, activeTab.value],
() => {
fetchLinks()
},
{ immediate: true },
)
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<CardTitle>Semantic Links</CardTitle>
<Button variant="ghost" size="sm" @click="fetchLinks">Refresh</Button>
</CardHeader>
<CardContent class="space-y-4">
<Tabs v-model="activeTab" class="space-y-4">
<TabsList>
<TabsTrigger value="suggested">Suggested</TabsTrigger>
<TabsTrigger value="approved">Approved</TabsTrigger>
<TabsTrigger value="rejected">Rejected</TabsTrigger>
<TabsTrigger value="dismissed">Dismissed</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
</TabsList>
<TabsContent :value="activeTab" class="space-y-4">
<div v-if="loading" class="text-sm text-muted-foreground">
Loading links...
</div>
<div v-else-if="error" class="text-sm text-destructive">
{{ error }}
</div>
<div v-else-if="links.length === 0" class="text-sm text-muted-foreground">
No links found.
</div>
<div v-else class="space-y-4">
<div
v-for="link in links"
:key="link.id"
class="rounded-lg border p-4 space-y-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium">
{{ getOtherSide(link).entityLabel || getOtherSide(link).entityType }} ·
{{ getOtherSide(link).entityName || getOtherSide(link).entityId }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatLinkType(link.link_type) }} {{ link.origin }} {{ formatConfidence(link.confidence) }}
</div>
</div>
<div class="text-xs text-muted-foreground">
Status: <span class="font-medium text-foreground">{{ link.status }}</span>
<span v-if="link.updated_at" class="ml-2">Updated: {{ formatDate(link.updated_at) }}</span>
</div>
<p v-if="link.reason" class="text-sm">{{ link.reason }}</p>
<div v-if="parseEvidence(link.evidence)" class="text-xs text-muted-foreground space-y-2">
<Separator />
<div>
<div class="font-medium text-foreground">Evidence</div>
<p v-if="parseEvidence(link.evidence)?.explanation" class="mt-1 text-foreground">
{{ parseEvidence(link.evidence).explanation }}
</p>
<div v-if="parseEvidence(link.evidence)?.matchedSignals?.length" class="mt-2">
<div>Matched context:</div>
<ul class="list-disc pl-4">
<li
v-for="(signal, idx) in parseEvidence(link.evidence).matchedSignals"
:key="idx"
>
{{ signal }}
</li>
</ul>
</div>
<div v-if="parseEvidence(link.evidence)?.matchedChunks?.length" class="mt-2">
<div>Matched excerpts:</div>
<ul class="list-disc pl-4">
<li
v-for="(match, idx) in parseEvidence(link.evidence).matchedChunks"
:key="idx"
>
{{ match.sourceKind }}: {{ match.text }}
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'approved')"
:disabled="!canApprove(link.status)"
>
Approve
</Button>
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'rejected')"
:disabled="!canReject(link.status)"
>
Reject
</Button>
<Button
size="sm"
variant="outline"
@click="reviewLink(link.id, 'dismissed')"
:disabled="!canDismiss(link.status)"
>
Dismiss
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { DropdownMenuSeparator, type DropdownMenuSeparatorProps, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(props)
</script>
<template>
<DropdownMenuSeparator
v-bind="forwarded"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -2,3 +2,4 @@ export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordCommentsPanel from '@/components/knowledge/RecordCommentsPanel.vue'
import SemanticLinksPanel from '@/components/knowledge/SemanticLinksPanel.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -167,6 +169,18 @@ const getFieldsBySection = (section: FieldSection) => {
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
<!-- Knowledge Panels -->
<div v-if="data?.id && config?.objectApiName" class="space-y-6">
<RecordCommentsPanel
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
<SemanticLinksPanel
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
</div>
</div>
</template>

View File

@@ -7,6 +7,8 @@ import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordSharing from '@/components/RecordSharing.vue'
import RecordCommentsPanel from '@/components/knowledge/RecordCommentsPanel.vue'
import SemanticLinksPanel from '@/components/knowledge/SemanticLinksPanel.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -170,6 +172,9 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
<TabsTrigger v-if="showSharing && data.id" value="sharing">
Sharing
</TabsTrigger>
<TabsTrigger v-if="data.id && config.objectApiName" value="knowledge">
Knowledge
</TabsTrigger>
</TabsList>
<!-- Details Tab -->
@@ -277,6 +282,20 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
</CardContent>
</Card>
</TabsContent>
<!-- Knowledge Tab -->
<TabsContent value="knowledge" class="space-y-6">
<RecordCommentsPanel
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
<SemanticLinksPanel
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
/>
</TabsContent>
</Tabs>
</div>
</template>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { AgGridVue } from 'ag-grid-vue3'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import {
Table,
TableBody,
@@ -13,9 +17,17 @@ 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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'
import { ListViewConfig, ViewMode, FieldType, FieldConfig } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit, Bookmark, BookmarkPlus, Settings2 } from 'lucide-vue-next'
import type { SavedView } from '@/composables/useSavedViews'
interface Props {
config: ListViewConfig
@@ -25,6 +37,14 @@ interface Props {
baseUrl?: string
totalCount?: number
searchSummary?: string
draftEdits?: Record<string, Record<string, any>>
cellErrors?: Record<string, Record<string, string | boolean>>
savingDrafts?: boolean
// Saved views
savedViews?: SavedView[]
activeViewId?: string | null
currentSearchPlan?: { strategy: string; filters: any[]; sort: any; explanation: string } | null
savingView?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -33,6 +53,13 @@ const props = withDefaults(defineProps<Props>(), {
selectable: false,
baseUrl: '/runtime/objects',
searchSummary: '',
draftEdits: () => ({}),
cellErrors: () => ({}),
savingDrafts: false,
savedViews: () => [],
activeViewId: null,
currentSearchPlan: null,
savingView: false,
})
const emit = defineEmits<{
@@ -47,6 +74,14 @@ const emit = defineEmits<{
'refresh': []
'page-change': [page: number, pageSize: number]
'load-more': [page: number, pageSize: number]
'view-change': [mode: 'list' | 'spreadsheet']
'cell-edit': [payload: { row: any; field: FieldConfig; newValue: any; oldValue: any }]
'save-drafts': []
'discard-drafts': []
// Saved views
'apply-view': [view: SavedView]
'save-view': []
'open-view-manager': []
}>()
// State
@@ -57,6 +92,8 @@ const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
const viewMode = ref<'list' | 'spreadsheet'>('list')
const gridApi = ref<GridApi | null>(null)
// Computed
const visibleFields = computed(() =>
@@ -87,7 +124,7 @@ const paginatedData = computed(() => {
})
const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1))
const pageEnd = computed(() => Math.min(startIndex.value + paginatedData.value.length, totalRecords.value))
const showPagination = computed(() => totalRecords.value > pageSize.value)
const showPagination = computed(() => viewMode.value === 'list' && totalRecords.value > pageSize.value)
const canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < availablePages.value)
const showLoadMore = computed(() => (
@@ -95,6 +132,8 @@ const showLoadMore = computed(() => (
Boolean(props.totalCount) &&
props.data.length < totalRecords.value
))
const draftRowCount = computed(() => Object.keys(props.draftEdits).length)
const draftCellCount = computed(() => Object.values(props.draftEdits).reduce((sum, row) => sum + Object.keys(row).length, 0))
const allSelected = computed({
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
@@ -159,6 +198,154 @@ const handleBulkAction = () => {
emit('action', bulkAction.value, getSelectedRows())
}
const isEditableField = (field: FieldConfig) =>
field.showOnEdit !== false &&
!field.isReadOnly &&
![FieldType.BELONGS_TO, FieldType.HAS_MANY, FieldType.MANY_TO_MANY].includes(field.type)
const getRelationPropertyName = (apiName: string) => apiName.replace(/Id$/, '').toLowerCase()
const formatFieldValue = (field: FieldConfig, value: any, record?: any) => {
if (value === null || value === undefined) return ''
switch (field.type) {
case FieldType.BELONGS_TO: {
const relationPropertyName = getRelationPropertyName(field.apiName)
const relatedObject = record?.[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || value
}
return value
}
case FieldType.DATE: {
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' })
}
case FieldType.DATETIME: {
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
case FieldType.BOOLEAN:
return value ? 'Yes' : 'No'
case FieldType.CURRENCY:
return `${field.prefix || '$'}${Number(value).toFixed(2)}${field.suffix || ''}`
case FieldType.SELECT: {
const option = field.options?.find(opt => opt.value === value)
return option?.label || value
}
case FieldType.MULTI_SELECT:
return Array.isArray(value)
? value
.map(v => field.options?.find(opt => opt.value === v)?.label || v)
.join(', ')
: ''
default:
return String(value)
}
}
const hasOwnProperty = (target: Record<string, any> | undefined, key: string) =>
!!target && Object.prototype.hasOwnProperty.call(target, key)
const isDraftCell = (params: any) => {
const rowId = normalizeId(params?.data?.id)
const field = String(params?.colDef?.field || '')
return hasOwnProperty(props.draftEdits[rowId], field)
}
const isErrorCell = (params: any) => {
const rowId = normalizeId(params?.data?.id)
const field = String(params?.colDef?.field || '')
return hasOwnProperty(props.cellErrors[rowId], field)
}
const getCellClasses = (params: any) => {
const classes: string[] = []
if (isDraftCell(params)) classes.push('cell-draft')
if (isErrorCell(params)) classes.push('cell-error')
return classes
}
const getCellStyle = (params: any) => {
const isDraft = isDraftCell(params)
const isError = isErrorCell(params)
if (!isDraft && !isError) return null
const style: Record<string, string> = {}
if (isDraft) {
style.backgroundColor = 'hsl(var(--accent) / 0.45)'
style.outline = '2px solid hsl(var(--accent))'
style.outlineOffset = '-2px'
}
if (isError) {
style.backgroundColor = 'hsl(var(--destructive) / 0.28)'
style.outline = '2px solid hsl(var(--destructive))'
style.outlineOffset = '-2px'
}
return style
}
const columnDefs = computed<ColDef[]>(() =>
visibleFields.value.map(field => ({
field: field.apiName,
headerName: field.label,
sortable: field.sortable !== false,
editable: isEditableField(field),
valueFormatter: params => formatFieldValue(field, params.value, params.data),
cellClass: params => getCellClasses(params),
cellStyle: params => getCellStyle(params),
cellEditor:
field.type === FieldType.SELECT
? 'agSelectCellEditor'
: field.type === FieldType.MULTI_SELECT
? 'agTextCellEditor'
: field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
? 'agTextCellEditor'
: undefined,
cellEditorParams:
field.type === FieldType.SELECT
? { values: (field.options || []).map(opt => opt.value) }
: undefined,
valueParser:
field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
? params => (params.newValue === '' ? null : Number(params.newValue))
: undefined,
flex: 1,
minWidth: 160,
}))
)
const defaultColDef: ColDef = {
resizable: true,
sortable: true,
}
const handleCellValueChanged = (event: CellValueChangedEvent) => {
const field = visibleFields.value.find(item => item.apiName === event.colDef.field)
if (!field || event.newValue === event.oldValue) return
emit('cell-edit', {
row: event.data,
field,
newValue: event.newValue,
oldValue: event.oldValue,
})
}
const handleGridReady = (event: GridReadyEvent) => {
gridApi.value = event.api
}
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) {
@@ -193,6 +380,23 @@ watch(
},
{ deep: true }
)
watch(
() => viewMode.value,
(mode) => {
emit('view-change', mode)
}
)
watch(
() => [props.draftEdits, props.cellErrors],
() => {
if (gridApi.value) {
gridApi.value.refreshCells({ force: true })
}
},
{ deep: true }
)
</script>
<template>
@@ -216,6 +420,95 @@ watch(
</div>
<div class="flex items-center gap-2">
<!-- Saved Views dropdown + cog -->
<div class="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-2">
<Bookmark class="h-4 w-4" />
<span class="max-w-[120px] truncate">
{{ savedViews.find(v => v.id === activeViewId)?.name || 'Views' }}
</span>
<ChevronDown class="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<DropdownMenuItem
v-if="savedViews.length === 0"
disabled
class="text-muted-foreground"
>
No saved views yet
</DropdownMenuItem>
<DropdownMenuItem
v-for="view in savedViews"
:key="view.id"
:class="{ 'font-medium': view.id === activeViewId }"
@click="emit('apply-view', view)"
>
<span class="flex-1 truncate">{{ view.name }}</span>
<Badge v-if="view.isShared" variant="secondary" class="ml-2 text-[10px] px-1.5 py-0">Shared</Badge>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="savedViews.length > 0" />
<DropdownMenuItem @click="emit('open-view-manager')">
Manage views
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="Manage saved views"
@click="emit('open-view-manager')"
>
<Settings2 class="h-4 w-4" />
</Button>
</div>
<!-- Save current search as a view (only for query strategy) -->
<Button
v-if="currentSearchPlan?.strategy === 'query'"
variant="outline"
size="sm"
:disabled="savingView"
class="gap-2"
@click="emit('save-view')"
>
<BookmarkPlus class="h-4 w-4" />
Save view
</Button>
<Select v-model="viewMode">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">List view</SelectItem>
<SelectItem value="spreadsheet">Spreadsheet view</SelectItem>
</SelectContent>
</Select>
<template v-if="viewMode === 'spreadsheet'">
<Badge v-if="draftRowCount > 0" variant="secondary" class="px-3 py-1">
{{ draftCellCount }} change{{ draftCellCount === 1 ? '' : 's' }}
</Badge>
<Button
variant="outline"
size="sm"
:disabled="draftRowCount === 0 || savingDrafts"
@click="emit('discard-drafts')"
>
Discard changes
</Button>
<Button
size="sm"
:disabled="draftRowCount === 0 || savingDrafts"
@click="emit('save-drafts')"
>
Save changes ({{ draftRowCount }})
</Button>
</template>
<!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1">
@@ -264,7 +557,7 @@ watch(
<!-- Table -->
<div class="border rounded-lg">
<Table>
<Table v-if="viewMode === 'list'">
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
@@ -338,6 +631,19 @@ watch(
</TableRow>
</TableBody>
</Table>
<div v-else class="ag-theme-alpine">
<AgGridVue
:row-data="data"
:column-defs="columnDefs"
:default-col-def="defaultColDef"
dom-layout="autoHeight"
row-selection="multiple"
suppress-row-click-selection
single-click-edit
@cell-value-changed="handleCellValueChanged"
@grid-ready="handleGridReady"
/>
</div>
</div>
<div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
@@ -375,4 +681,5 @@ watch(
.list-view :deep(input) {
background-color: hsl(var(--background));
}
</style>

View File

@@ -1,40 +1,25 @@
export const useApi = () => {
const config = useRuntimeConfig()
const router = useRouter()
const { toast } = useToast()
const { isLoggedIn, logout } = useAuth()
// Use current domain for API calls (same subdomain routing)
/**
* API calls now go through the Nitro BFF proxy at /api/*
* The proxy handles:
* - Auth token injection from HTTP-only cookies
* - Tenant subdomain extraction and forwarding
* - Forwarding requests to the NestJS backend
*/
const getApiBaseUrl = () => {
if (import.meta.client) {
// In browser, use current hostname but with port 3000 for API
const currentHost = window.location.hostname
const protocol = window.location.protocol
//return `${protocol}//${currentHost}:3000`
return `${protocol}//${currentHost}`
}
// Fallback for SSR
return config.public.apiBaseUrl
// All API calls go through Nitro proxy - works for both SSR and client
return ''
}
const getHeaders = () => {
// Headers are now minimal - auth and tenant are handled by the Nitro proxy
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add tenant ID from localStorage or state
if (import.meta.client) {
const tenantId = localStorage.getItem('tenantId')
if (tenantId) {
headers['x-tenant-id'] = tenantId
}
const token = localStorage.getItem('token')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
return headers
}

View File

@@ -1,43 +1,70 @@
/**
* Authentication composable using BFF (Backend for Frontend) pattern
* Auth tokens are stored in HTTP-only cookies managed by Nitro server
* Tenant context is stored in a readable cookie for client-side access
*/
export const useAuth = () => {
const tokenCookie = useCookie('token')
const authMessageCookie = useCookie('authMessage')
const tenantCookie = useCookie('routebox_tenant')
const router = useRouter()
const config = useRuntimeConfig()
// Reactive user state - populated from /api/auth/me
const user = useState<any>('auth_user', () => null)
const isAuthenticated = useState<boolean>('auth_is_authenticated', () => false)
const isLoading = useState<boolean>('auth_is_loading', () => false)
/**
* Check if user is logged in
* Uses server-side session validation via /api/auth/me
*/
const isLoggedIn = () => {
if (!import.meta.client) return false
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId')
return !!(token && tenantId)
return isAuthenticated.value
}
const logout = async () => {
if (import.meta.client) {
// Call backend logout endpoint
/**
* Login with email and password
* Calls the Nitro BFF login endpoint which sets HTTP-only cookies
*/
const login = async (email: string, password: string) => {
isLoading.value = true
try {
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId')
if (token) {
await fetch(`${config.public.apiBaseUrl}/api/auth/logout`, {
const response = await $fetch('/api/auth/login', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
...(tenantId && { 'x-tenant-id': tenantId }),
},
body: { email, password },
})
if (response.success) {
user.value = response.user
isAuthenticated.value = true
return { success: true, user: response.user }
}
return { success: false, error: 'Login failed' }
} catch (error: any) {
const message = error.data?.message || error.message || 'Login failed'
return { success: false, error: message }
} finally {
isLoading.value = false
}
}
/**
* Logout user
* Calls the Nitro BFF logout endpoint which clears HTTP-only cookies
*/
const logout = async () => {
try {
await $fetch('/api/auth/logout', {
method: 'POST',
})
} catch (error) {
console.error('Logout error:', error)
}
// Clear local storage
localStorage.removeItem('token')
localStorage.removeItem('tenantId')
localStorage.removeItem('user')
// Clear cookie for server-side check
tokenCookie.value = null
// Clear local state
user.value = null
isAuthenticated.value = false
// Set flash message for login page
authMessageCookie.value = 'Logged out successfully'
@@ -45,17 +72,60 @@ export const useAuth = () => {
// Redirect to login page
router.push('/login')
}
/**
* Check current authentication status
* Validates session with backend via Nitro BFF
*/
const checkAuth = async () => {
isLoading.value = true
try {
const response = await $fetch('/api/auth/me', {
method: 'GET',
})
if (response.authenticated && response.user) {
user.value = response.user
isAuthenticated.value = true
return true
}
} catch (error) {
// Session invalid or expired
user.value = null
isAuthenticated.value = false
} finally {
isLoading.value = false
}
return false
}
/**
* Get current user
*/
const getUser = () => {
if (!import.meta.client) return null
const userStr = localStorage.getItem('user')
return userStr ? JSON.parse(userStr) : null
return user.value
}
/**
* Get current tenant ID from cookie
*/
const getTenantId = () => {
return tenantCookie.value
}
return {
// State
user,
isAuthenticated,
isLoading,
// Methods
isLoggedIn,
login,
logout,
checkAuth,
getUser,
getTenantId,
}
}

Some files were not shown because too many files have changed in this diff Show More