Compare commits
5 Commits
20fc90a3fb
...
nitro-bff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1619c56c | ||
|
|
9226442525 | ||
|
|
49a571215d | ||
|
|
0e2f3dddbc | ||
|
|
f68321c802 |
4
.env.web
4
.env.web
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
136
backend/package-lock.json
generated
136
backend/package-lock.json
generated
@@ -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",
|
||||
@@ -29,9 +29,10 @@
|
||||
"bullmq": "^5.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"deepagents": "^1.5.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"knex": "^3.1.0",
|
||||
"langchain": "^1.2.7",
|
||||
"langchain": "^1.2.10",
|
||||
"mysql2": "^3.15.3",
|
||||
"objection": "^3.1.5",
|
||||
"openai": "^6.15.0",
|
||||
@@ -228,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",
|
||||
@@ -689,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",
|
||||
@@ -1689,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",
|
||||
@@ -2487,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",
|
||||
@@ -2501,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"
|
||||
@@ -2511,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",
|
||||
@@ -4029,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"
|
||||
@@ -4754,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",
|
||||
@@ -5514,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",
|
||||
@@ -5740,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"
|
||||
@@ -6145,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"
|
||||
@@ -6594,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"
|
||||
@@ -6623,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"
|
||||
@@ -6658,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"
|
||||
@@ -7609,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",
|
||||
@@ -7811,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",
|
||||
@@ -7826,7 +7891,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "1.1.12"
|
||||
"@langchain/core": "1.1.15"
|
||||
}
|
||||
},
|
||||
"node_modules/langchain/node_modules/uuid": {
|
||||
@@ -8201,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"
|
||||
@@ -8211,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",
|
||||
@@ -8225,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"
|
||||
@@ -9388,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",
|
||||
@@ -9727,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",
|
||||
@@ -10657,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"
|
||||
@@ -10709,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",
|
||||
@@ -11455,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",
|
||||
@@ -11508,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"
|
||||
|
||||
@@ -28,7 +28,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",
|
||||
@@ -46,9 +46,10 @@
|
||||
"bullmq": "^5.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"deepagents": "^1.5.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"knex": "^3.1.0",
|
||||
"langchain": "^1.2.7",
|
||||
"langchain": "^1.2.10",
|
||||
"mysql2": "^3.15.3",
|
||||
"objection": "^3.1.5",
|
||||
"openai": "^6.15.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,6 +145,7 @@ export class FieldMapperService {
|
||||
'boolean': 'boolean',
|
||||
'date': 'date',
|
||||
'datetime': 'datetime',
|
||||
'date_time': 'datetime',
|
||||
'time': 'time',
|
||||
'email': 'email',
|
||||
'url': 'url',
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
264
frontend/components/ListViewLayoutEditor.vue
Normal file
264
frontend/components/ListViewLayoutEditor.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export const useFields = () => {
|
||||
// Hide system fields and auto-generated fields on edit
|
||||
const shouldHideOnEdit = isSystemField || isAutoGeneratedField
|
||||
|
||||
// Hide 'id' field from list view by default (check both apiName and id field)
|
||||
const shouldHideOnList = fieldDef.apiName === 'id' || fieldDef.label === 'Id' || fieldDef.label === 'ID'
|
||||
|
||||
return {
|
||||
id: fieldDef.id,
|
||||
apiName: fieldDef.apiName,
|
||||
@@ -37,7 +40,7 @@ export const useFields = () => {
|
||||
validationRules: fieldDef.validationRules || [],
|
||||
|
||||
// View options - only hide system and auto-generated fields by default
|
||||
showOnList: fieldDef.showOnList ?? true,
|
||||
showOnList: fieldDef.showOnList ?? !shouldHideOnList,
|
||||
showOnDetail: fieldDef.showOnDetail ?? true,
|
||||
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
|
||||
sortable: fieldDef.sortable ?? true,
|
||||
@@ -67,12 +70,36 @@ export const useFields = () => {
|
||||
|
||||
/**
|
||||
* Build a ListView configuration from object definition
|
||||
* @param objectDef - The object definition containing fields
|
||||
* @param customConfig - Optional custom configuration
|
||||
* @param listLayoutConfig - Optional list view layout configuration from page_layouts
|
||||
*/
|
||||
const buildListViewConfig = (
|
||||
objectDef: any,
|
||||
customConfig?: Partial<ListViewConfig>
|
||||
customConfig?: Partial<ListViewConfig>,
|
||||
listLayoutConfig?: { fields: Array<{ fieldId: string; order?: number }> } | null
|
||||
): ListViewConfig => {
|
||||
const fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
|
||||
let fields = objectDef.fields?.map(mapFieldDefinitionToConfig) || []
|
||||
|
||||
// If a list layout is provided, filter and order fields according to it
|
||||
if (listLayoutConfig && listLayoutConfig.fields && listLayoutConfig.fields.length > 0) {
|
||||
// Sort layout fields by order
|
||||
const sortedLayoutFields = [...listLayoutConfig.fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
|
||||
// Map layout fields to actual field configs, preserving order
|
||||
const orderedFields: FieldConfig[] = []
|
||||
for (const layoutField of sortedLayoutFields) {
|
||||
const fieldConfig = fields.find((f: FieldConfig) => f.id === layoutField.fieldId)
|
||||
if (fieldConfig) {
|
||||
orderedFields.push(fieldConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Use ordered fields if we found any, otherwise fall back to all fields
|
||||
if (orderedFields.length > 0) {
|
||||
fields = orderedFields
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
objectApiName: objectDef.apiName,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest } from '~/types/page-layout'
|
||||
import type { PageLayout, CreatePageLayoutRequest, UpdatePageLayoutRequest, PageLayoutType } from '~/types/page-layout'
|
||||
|
||||
export const usePageLayouts = () => {
|
||||
const { api } = useApi()
|
||||
|
||||
const getPageLayouts = async (objectId?: string) => {
|
||||
const getPageLayouts = async (objectId?: string, layoutType?: PageLayoutType) => {
|
||||
try {
|
||||
const params = objectId ? { objectId } : {}
|
||||
const params: Record<string, string> = {}
|
||||
if (objectId) params.objectId = objectId
|
||||
if (layoutType) params.layoutType = layoutType
|
||||
const response = await api.get('/page-layouts', { params })
|
||||
return response
|
||||
} catch (error) {
|
||||
@@ -24,9 +26,11 @@ export const usePageLayouts = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultPageLayout = async (objectId: string) => {
|
||||
const getDefaultPageLayout = async (objectId: string, layoutType: PageLayoutType = 'detail') => {
|
||||
try {
|
||||
const response = await api.get(`/page-layouts/default/${objectId}`)
|
||||
const response = await api.get(`/page-layouts/default/${objectId}`, {
|
||||
params: { layoutType }
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Error fetching default page layout:', error)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
|
||||
import { useAuth } from './useAuth';
|
||||
@@ -44,17 +44,6 @@ const volume = ref(100);
|
||||
export function useSoftphone() {
|
||||
const auth = useAuth();
|
||||
|
||||
// Get token and tenantId from localStorage
|
||||
const getToken = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('token');
|
||||
};
|
||||
|
||||
const getTenantId = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('tenantId');
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const isInCall = computed(() => currentCall.value !== null);
|
||||
const hasIncomingCall = computed(() => incomingCall.value !== null);
|
||||
@@ -93,6 +82,12 @@ export function useSoftphone() {
|
||||
* Initialize Twilio Device
|
||||
*/
|
||||
const initializeTwilioDevice = async () => {
|
||||
// Prevent re-initialization if device already exists and is registered
|
||||
if (twilioDevice.value) {
|
||||
console.log('Twilio Device already exists, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// First, explicitly request microphone permission
|
||||
const hasPermission = await requestMicrophonePermission();
|
||||
@@ -107,12 +102,14 @@ export function useSoftphone() {
|
||||
// Log the token payload to see what identity is being used
|
||||
try {
|
||||
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
|
||||
console.log('📱 Twilio Device identity:', tokenPayload.grants?.identity || tokenPayload.sub);
|
||||
} catch (e) {
|
||||
console.log('Could not parse token payload');
|
||||
}
|
||||
|
||||
console.log('📱 Creating new Twilio Device...');
|
||||
twilioDevice.value = new Device(token, {
|
||||
logLevel: 3,
|
||||
logLevel: 1, // Reduce log level (1 = errors only, 3 = debug)
|
||||
codecPreferences: ['opus', 'pcmu'],
|
||||
enableImprovedSignalingErrorPrecision: true,
|
||||
edge: 'ashburn',
|
||||
@@ -120,10 +117,12 @@ export function useSoftphone() {
|
||||
|
||||
// Device events
|
||||
twilioDevice.value.on('registered', () => {
|
||||
console.log('✅ Twilio Device registered successfully');
|
||||
toast.success('Softphone ready');
|
||||
});
|
||||
|
||||
twilioDevice.value.on('unregistered', () => {
|
||||
console.log('📱 Twilio Device unregistered');
|
||||
});
|
||||
|
||||
twilioDevice.value.on('error', (error) => {
|
||||
@@ -132,6 +131,11 @@ export function useSoftphone() {
|
||||
});
|
||||
|
||||
twilioDevice.value.on('incoming', (call: TwilioCall) => {
|
||||
console.log('📞 Twilio Device incoming call event!', {
|
||||
callSid: call.parameters.CallSid,
|
||||
from: call.parameters.From,
|
||||
to: call.parameters.To,
|
||||
});
|
||||
twilioCall.value = call;
|
||||
|
||||
// Update state
|
||||
@@ -210,10 +214,29 @@ export function useSoftphone() {
|
||||
/**
|
||||
* Initialize WebSocket connection
|
||||
*/
|
||||
const connect = () => {
|
||||
const token = getToken();
|
||||
const connect = async () => {
|
||||
// Guard against multiple connection attempts
|
||||
if (socket.value) {
|
||||
console.log('Softphone: Socket already exists, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value?.connected || !token) {
|
||||
// Check if user is authenticated
|
||||
if (!auth.isAuthenticated.value) {
|
||||
// Try to verify authentication first
|
||||
const isValid = await auth.checkAuth();
|
||||
if (!isValid) {
|
||||
console.log('Softphone: User not authenticated, skipping connection');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Get WebSocket token from BFF (this retrieves the token from HTTP-only cookie server-side)
|
||||
const wsAuth = await $fetch('/api/auth/ws-token');
|
||||
|
||||
if (!wsAuth.token) {
|
||||
console.log('Softphone: No WebSocket token available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -230,7 +253,7 @@ export function useSoftphone() {
|
||||
// Connect to /voice namespace with proper auth header
|
||||
socket.value = io(`${getBackendUrl()}/voice`, {
|
||||
auth: {
|
||||
token: token,
|
||||
token: wsAuth.token,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
@@ -279,6 +302,10 @@ export function useSoftphone() {
|
||||
socket.value.on('ai:action', handleAiAction);
|
||||
|
||||
isInitialized.value = true;
|
||||
} catch (error: any) {
|
||||
console.error('Softphone: Failed to connect:', error);
|
||||
toast.error('Failed to initialize voice service');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -569,14 +596,25 @@ export function useSoftphone() {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-connect on mount if token is available
|
||||
onMounted(() => {
|
||||
if (getToken() && !isInitialized.value) {
|
||||
// Only set up auto-connect and watchers once (not for every component that uses this composable)
|
||||
// Use a module-level flag to track if watchers are already set up
|
||||
if (process.client && !isInitialized.value && !socket.value) {
|
||||
// Auto-connect if authenticated
|
||||
if (auth.isAuthenticated.value) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
// Watch for authentication changes to connect/disconnect
|
||||
watch(() => auth.isAuthenticated.value, async (isAuth) => {
|
||||
if (isAuth && !isInitialized.value && !socket.value) {
|
||||
await connect();
|
||||
} else if (!isAuth && isInitialized.value) {
|
||||
disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount - only stop ringtone, don't disconnect shared socket
|
||||
onUnmounted(() => {
|
||||
stopRingtone();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// Allow pages to opt-out of auth with definePageMeta({ auth: false })
|
||||
if (to.meta.auth === false) {
|
||||
return
|
||||
@@ -11,28 +11,45 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
return
|
||||
}
|
||||
|
||||
const token = useCookie('token')
|
||||
const authMessage = useCookie('authMessage')
|
||||
// Check for tenant cookie (set alongside session cookie on login)
|
||||
const tenantCookie = useCookie('routebox_tenant')
|
||||
// Also check for session cookie (HTTP-only, but readable in SSR context)
|
||||
const sessionCookie = useCookie('routebox_session')
|
||||
|
||||
// Routes that don't need a toast message (user knows they need to login)
|
||||
const silentRoutes = ['/']
|
||||
|
||||
// Check token cookie (works on both server and client)
|
||||
if (!token.value) {
|
||||
// On client side, check the reactive auth state
|
||||
if (import.meta.client) {
|
||||
const { isAuthenticated, checkAuth } = useAuth()
|
||||
|
||||
// If we already know we're authenticated, allow
|
||||
if (isAuthenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a tenant cookie, try to validate the session
|
||||
if (tenantCookie.value) {
|
||||
const isValid = await checkAuth()
|
||||
if (isValid) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!silentRoutes.includes(to.path)) {
|
||||
authMessage.value = 'Please login to access this page'
|
||||
}
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
// On client side, also verify localStorage is in sync
|
||||
if (import.meta.client) {
|
||||
const { isLoggedIn } = useAuth()
|
||||
if (!isLoggedIn()) {
|
||||
// Server-side: check for both session and tenant cookies
|
||||
// The session cookie is HTTP-only but can be read in SSR context
|
||||
if (!sessionCookie.value || !tenantCookie.value) {
|
||||
if (!silentRoutes.includes(to.path)) {
|
||||
authMessage.value = 'Please login to access this page'
|
||||
}
|
||||
return navigateTo('/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,9 +24,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
|
||||
},
|
||||
// Server-only config (not exposed to client)
|
||||
// Used by Nitro BFF to proxy requests to the NestJS backend
|
||||
backendUrl: process.env.BACKEND_URL || 'http://localhost:3000',
|
||||
},
|
||||
|
||||
app: {
|
||||
|
||||
831
frontend/package-lock.json
generated
831
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||
import { usePageLayouts } from '@/composables/usePageLayouts'
|
||||
import ListView from '@/components/views/ListView.vue'
|
||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||
import EditView from '@/components/views/EditViewEnhanced.vue'
|
||||
@@ -19,6 +20,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { api } = useApi()
|
||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||
const { getDefaultPageLayout } = usePageLayouts()
|
||||
|
||||
// Use breadcrumbs composable
|
||||
const { setBreadcrumbs } = useBreadcrumbs()
|
||||
@@ -40,6 +42,7 @@ const view = computed(() => {
|
||||
|
||||
// State
|
||||
const objectDefinition = ref<any>(null)
|
||||
const listViewLayout = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -134,11 +137,13 @@ watch([objectDefinition, currentRecord, recordId], () => {
|
||||
// View configs
|
||||
const listConfig = computed(() => {
|
||||
if (!objectDefinition.value) return null
|
||||
// Pass the list view layout config to buildListViewConfig if available
|
||||
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
|
||||
return buildListViewConfig(objectDefinition.value, {
|
||||
searchable: true,
|
||||
exportable: true,
|
||||
filterable: true,
|
||||
})
|
||||
}, layoutConfig)
|
||||
})
|
||||
|
||||
const detailConfig = computed(() => {
|
||||
@@ -172,6 +177,16 @@ const fetchObjectDefinition = async () => {
|
||||
error.value = null
|
||||
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
||||
objectDefinition.value = response
|
||||
|
||||
// Fetch the default list view layout for this object
|
||||
if (response?.id) {
|
||||
try {
|
||||
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
|
||||
} catch (e) {
|
||||
// No list view layout configured, will use default behavior
|
||||
listViewLayout.value = null
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load object definition'
|
||||
console.error('Error fetching object definition:', e)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||
import { usePageLayouts } from '@/composables/usePageLayouts'
|
||||
import ListView from '@/components/views/ListView.vue'
|
||||
import DetailView from '@/components/views/DetailView.vue'
|
||||
import EditView from '@/components/views/EditView.vue'
|
||||
@@ -11,6 +12,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { api } = useApi()
|
||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||
const { getDefaultPageLayout } = usePageLayouts()
|
||||
|
||||
// Get object API name from route
|
||||
const objectApiName = computed(() => route.params.objectName as string)
|
||||
@@ -25,6 +27,7 @@ const view = computed(() => {
|
||||
|
||||
// State
|
||||
const objectDefinition = ref<any>(null)
|
||||
const listViewLayout = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -66,11 +69,13 @@ onBeforeUnmount(() => {
|
||||
// View configs
|
||||
const listConfig = computed(() => {
|
||||
if (!objectDefinition.value) return null
|
||||
// Pass the list view layout config to buildListViewConfig if available
|
||||
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
|
||||
return buildListViewConfig(objectDefinition.value, {
|
||||
searchable: true,
|
||||
exportable: true,
|
||||
filterable: true,
|
||||
})
|
||||
}, layoutConfig)
|
||||
})
|
||||
|
||||
const detailConfig = computed(() => {
|
||||
@@ -93,6 +98,16 @@ const fetchObjectDefinition = async () => {
|
||||
error.value = null
|
||||
const response = await api.get(`/setup/objects/${objectApiName.value}`)
|
||||
objectDefinition.value = response
|
||||
|
||||
// Fetch the default list view layout for this object
|
||||
if (response?.id) {
|
||||
try {
|
||||
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
|
||||
} catch (e) {
|
||||
// No list view layout configured, will use default behavior
|
||||
listViewLayout.value = null
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load object definition'
|
||||
console.error('Error fetching object definition:', e)
|
||||
|
||||
@@ -74,24 +74,8 @@ definePageMeta({
|
||||
auth: false
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
|
||||
// Extract subdomain from hostname
|
||||
const getSubdomain = () => {
|
||||
if (!import.meta.client) return null
|
||||
const hostname = window.location.hostname
|
||||
const parts = hostname.split('.')
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return null
|
||||
}
|
||||
if (parts.length > 1 && parts[0] !== 'www') {
|
||||
return parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const subdomain = ref(getSubdomain())
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const firstName = ref('')
|
||||
@@ -106,30 +90,17 @@ const handleRegister = async () => {
|
||||
error.value = ''
|
||||
success.value = false
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (subdomain.value) {
|
||||
headers['x-tenant-id'] = subdomain.value
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, {
|
||||
// Use BFF proxy - subdomain/tenant is handled automatically by Nitro
|
||||
await $fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
firstName: firstName.value || undefined,
|
||||
lastName: lastName.value || undefined,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.message || 'Registration failed')
|
||||
}
|
||||
|
||||
success.value = true
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
@@ -137,9 +108,10 @@ const handleRegister = async () => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Registration failed'
|
||||
error.value = e.data?.message || e.message || 'Registration failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
<!-- Tabs -->
|
||||
<div class="mb-8">
|
||||
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-3 max-w-2xl">
|
||||
<TabsList class="grid w-full grid-cols-4 max-w-2xl">
|
||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||
<TabsTrigger value="access">Access</TabsTrigger>
|
||||
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
||||
<TabsTrigger value="listLayouts">List View Layouts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Fields Tab -->
|
||||
@@ -148,7 +149,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="layout.isDefault"
|
||||
v-if="layout.isDefault || layout.is_default"
|
||||
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
|
||||
>
|
||||
Default
|
||||
@@ -185,6 +186,84 @@
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- List View Layouts Tab -->
|
||||
<TabsContent value="listLayouts" class="mt-6">
|
||||
<div v-if="!selectedListLayout" class="space-y-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">List View Layouts</h2>
|
||||
<Button @click="handleCreateListLayout">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
New List Layout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Configure which fields appear in list views and their order.
|
||||
</p>
|
||||
|
||||
<div v-if="loadingListLayouts" class="text-center py-8">
|
||||
Loading list layouts...
|
||||
</div>
|
||||
|
||||
<div v-else-if="listLayouts.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
No list view layouts yet. Create one to customize your list views.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="layout in listLayouts"
|
||||
:key="layout.id"
|
||||
class="p-4 border rounded-lg bg-card hover:border-primary cursor-pointer transition-colors"
|
||||
@click="handleSelectListLayout(layout)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">{{ layout.name }}</h3>
|
||||
<p v-if="layout.description" class="text-sm text-muted-foreground">
|
||||
{{ layout.description }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ getListLayoutFieldCount(layout) }} fields configured
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="layout.isDefault || layout.is_default"
|
||||
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="handleDeleteListLayout(layout.id)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Layout Editor -->
|
||||
<div v-else>
|
||||
<div class="mb-4">
|
||||
<Button variant="outline" @click="selectedListLayout = null">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Back to List Layouts
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListViewLayoutEditor
|
||||
:fields="object.fields"
|
||||
:initial-layout="(selectedListLayout.layoutConfig || selectedListLayout.layout_config)?.fields || []"
|
||||
:layout-name="selectedListLayout.name"
|
||||
@save="handleSaveListLayout"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,6 +378,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
||||
import ListViewLayoutEditor from '@/components/ListViewLayoutEditor.vue'
|
||||
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
||||
import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue'
|
||||
import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue'
|
||||
@@ -315,11 +395,16 @@ const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const activeTab = ref('fields')
|
||||
|
||||
// Page layouts state
|
||||
// Page layouts state (detail/edit layouts)
|
||||
const layouts = ref<PageLayout[]>([])
|
||||
const loadingLayouts = ref(false)
|
||||
const selectedLayout = ref<PageLayout | null>(null)
|
||||
|
||||
// List view layouts state
|
||||
const listLayouts = ref<PageLayout[]>([])
|
||||
const loadingListLayouts = ref(false)
|
||||
const selectedListLayout = ref<PageLayout | null>(null)
|
||||
|
||||
// Field management state
|
||||
const showFieldDialog = ref(false)
|
||||
const fieldDialogMode = ref<'create' | 'edit'>('create')
|
||||
@@ -420,7 +505,8 @@ const fetchLayouts = async () => {
|
||||
|
||||
try {
|
||||
loadingLayouts.value = true
|
||||
layouts.value = await getPageLayouts(object.value.id)
|
||||
// Fetch only detail layouts (default type)
|
||||
layouts.value = await getPageLayouts(object.value.id, 'detail')
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching layouts:', e)
|
||||
toast.error('Failed to load page layouts')
|
||||
@@ -429,6 +515,20 @@ const fetchLayouts = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchListLayouts = async () => {
|
||||
if (!object.value) return
|
||||
|
||||
try {
|
||||
loadingListLayouts.value = true
|
||||
listLayouts.value = await getPageLayouts(object.value.id, 'list')
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching list layouts:', e)
|
||||
toast.error('Failed to load list view layouts')
|
||||
} finally {
|
||||
loadingListLayouts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => {
|
||||
fieldDialogMode.value = mode
|
||||
fieldDialogError.value = null
|
||||
@@ -684,6 +784,7 @@ const handleCreateLayout = async () => {
|
||||
const newLayout = await createPageLayout({
|
||||
name,
|
||||
objectId: object.value.id,
|
||||
layoutType: 'detail',
|
||||
isDefault: layouts.value.length === 0,
|
||||
layoutConfig: { fields: [], relatedLists: [] },
|
||||
})
|
||||
@@ -736,6 +837,73 @@ const handleDeleteLayout = async (layoutId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// List View Layout methods
|
||||
const handleCreateListLayout = async () => {
|
||||
const name = prompt('Enter a name for the new list view layout:')
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
const newLayout = await createPageLayout({
|
||||
name,
|
||||
objectId: object.value.id,
|
||||
layoutType: 'list',
|
||||
isDefault: listLayouts.value.length === 0,
|
||||
layoutConfig: { fields: [] },
|
||||
})
|
||||
|
||||
listLayouts.value.push(newLayout)
|
||||
selectedListLayout.value = newLayout
|
||||
toast.success('List view layout created successfully')
|
||||
} catch (e: any) {
|
||||
console.error('Error creating list layout:', e)
|
||||
toast.error('Failed to create list view layout')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectListLayout = (layout: PageLayout) => {
|
||||
selectedListLayout.value = layout
|
||||
}
|
||||
|
||||
const handleSaveListLayout = async (layoutConfig: { fields: FieldLayoutItem[] }) => {
|
||||
if (!selectedListLayout.value) return
|
||||
|
||||
try {
|
||||
const updated = await updatePageLayout(selectedListLayout.value.id, {
|
||||
layoutConfig,
|
||||
})
|
||||
|
||||
// Update the layout in the list
|
||||
const index = listLayouts.value.findIndex(l => l.id === selectedListLayout.value!.id)
|
||||
if (index !== -1) {
|
||||
listLayouts.value[index] = updated
|
||||
}
|
||||
|
||||
selectedListLayout.value = updated
|
||||
toast.success('List view layout saved successfully')
|
||||
} catch (e: any) {
|
||||
console.error('Error saving list layout:', e)
|
||||
toast.error('Failed to save list view layout')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteListLayout = async (layoutId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this list view layout?')) return
|
||||
|
||||
try {
|
||||
await deletePageLayout(layoutId)
|
||||
listLayouts.value = listLayouts.value.filter(l => l.id !== layoutId)
|
||||
toast.success('List view layout deleted successfully')
|
||||
} catch (e: any) {
|
||||
console.error('Error deleting list layout:', e)
|
||||
toast.error('Failed to delete list view layout')
|
||||
}
|
||||
}
|
||||
|
||||
const getListLayoutFieldCount = (layout: PageLayout): number => {
|
||||
const config = layout.layoutConfig || layout.layout_config
|
||||
return config?.fields?.length || 0
|
||||
}
|
||||
|
||||
const handleAccessUpdate = (orgWideDefault: string) => {
|
||||
if (object.value) {
|
||||
object.value.orgWideDefault = orgWideDefault
|
||||
@@ -747,6 +915,9 @@ watch(activeTab, (newTab) => {
|
||||
if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) {
|
||||
fetchLayouts()
|
||||
}
|
||||
if (newTab === 'listLayouts' && listLayouts.value.length === 0 && !loadingListLayouts.value) {
|
||||
fetchListLayouts()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -755,5 +926,8 @@ onMounted(async () => {
|
||||
if (activeTab.value === 'layouts') {
|
||||
await fetchLayouts()
|
||||
}
|
||||
if (activeTab.value === 'listLayouts') {
|
||||
await fetchListLayouts()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
119
frontend/server/api/[...path].ts
Normal file
119
frontend/server/api/[...path].ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { defineEventHandler, getMethod, readBody, getQuery, createError, getHeader } from 'h3'
|
||||
import { getSubdomainFromRequest } from '~/server/utils/tenant'
|
||||
import { getSessionToken } from '~/server/utils/session'
|
||||
|
||||
/**
|
||||
* Catch-all API proxy that forwards requests to the NestJS backend
|
||||
* Injects x-tenant-subdomain header and Authorization from HTTP-only cookie
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const method = getMethod(event)
|
||||
const path = event.context.params?.path || ''
|
||||
|
||||
// Get subdomain and session token
|
||||
const subdomain = getSubdomainFromRequest(event)
|
||||
const token = getSessionToken(event)
|
||||
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3000'
|
||||
|
||||
// Build the full URL with query parameters
|
||||
const query = getQuery(event)
|
||||
const queryString = new URLSearchParams(query as Record<string, string>).toString()
|
||||
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
console.log(`[BFF Proxy] ${method} ${fullUrl} (subdomain: ${subdomain}, hasToken: ${!!token})`)
|
||||
|
||||
// Build headers to forward
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': getHeader(event, 'content-type') || 'application/json',
|
||||
}
|
||||
|
||||
// Add subdomain header for backend tenant resolution
|
||||
if (subdomain) {
|
||||
headers['x-tenant-subdomain'] = subdomain
|
||||
}
|
||||
|
||||
// Add auth token from HTTP-only cookie
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Forward additional headers that might be needed
|
||||
const acceptHeader = getHeader(event, 'accept')
|
||||
if (acceptHeader) {
|
||||
headers['Accept'] = acceptHeader
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare fetch options
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
// Add body for methods that support it
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = await readBody(event)
|
||||
if (body) {
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Make request to backend
|
||||
const response = await fetch(fullUrl, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (like file downloads)
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get error details
|
||||
let errorMessage = `Backend error: ${response.status}`
|
||||
let errorData = null
|
||||
|
||||
try {
|
||||
errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// Response wasn't JSON
|
||||
try {
|
||||
const text = await response.text()
|
||||
console.error(`[BFF Proxy] Backend error (non-JSON): ${text}`)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.error(`[BFF Proxy] Backend returned ${response.status}: ${errorMessage}`, errorData)
|
||||
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: errorMessage,
|
||||
data: errorData,
|
||||
})
|
||||
}
|
||||
|
||||
// Return empty response for 204 No Content
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle JSON responses
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Handle other content types (text, etc.)
|
||||
return await response.text()
|
||||
|
||||
} catch (error: any) {
|
||||
// Re-throw H3 errors
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.error(`Proxy error for ${method} /api/${path}:`, error)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Failed to connect to backend service',
|
||||
})
|
||||
}
|
||||
})
|
||||
81
frontend/server/api/auth/login.post.ts
Normal file
81
frontend/server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { getSubdomainFromRequest, isCentralSubdomain } from '~/server/utils/tenant'
|
||||
import { setSessionCookie, setTenantIdCookie } from '~/server/utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const body = await readBody(event)
|
||||
|
||||
// Extract subdomain from the request
|
||||
const subdomain = getSubdomainFromRequest(event)
|
||||
|
||||
if (!subdomain) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Unable to determine tenant from subdomain',
|
||||
})
|
||||
}
|
||||
|
||||
// Determine the backend URL based on whether this is central admin or tenant
|
||||
const isCentral = isCentralSubdomain(subdomain)
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3000'
|
||||
const loginEndpoint = isCentral ? '/api/auth/central/login' : '/api/auth/login'
|
||||
|
||||
try {
|
||||
// Forward login request to NestJS backend with subdomain header
|
||||
const response = await fetch(`${backendUrl}${loginEndpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-tenant-subdomain': subdomain,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: data.message || 'Login failed',
|
||||
data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract token and tenant info from response
|
||||
const { access_token, user, tenantId } = data
|
||||
|
||||
if (!access_token) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'No access token received from backend',
|
||||
})
|
||||
}
|
||||
|
||||
// Set HTTP-only cookie with the JWT token
|
||||
setSessionCookie(event, access_token)
|
||||
|
||||
// Set tenant ID cookie (readable by client for context)
|
||||
// Use tenantId from response, or fall back to subdomain
|
||||
const tenantToStore = tenantId || subdomain
|
||||
setTenantIdCookie(event, tenantToStore)
|
||||
|
||||
// Return user info (but NOT the token - it's in HTTP-only cookie)
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
tenantId: tenantToStore,
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Re-throw H3 errors
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.error('Login proxy error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to connect to authentication service',
|
||||
})
|
||||
}
|
||||
})
|
||||
37
frontend/server/api/auth/logout.post.ts
Normal file
37
frontend/server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { getSubdomainFromRequest } from '~/server/utils/tenant'
|
||||
import { getSessionToken, clearSessionCookie, clearTenantIdCookie } from '~/server/utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const subdomain = getSubdomainFromRequest(event)
|
||||
const token = getSessionToken(event)
|
||||
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3000'
|
||||
|
||||
try {
|
||||
// Call backend logout endpoint if we have a token
|
||||
if (token) {
|
||||
await fetch(`${backendUrl}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(subdomain && { 'x-tenant-subdomain': subdomain }),
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail - we still want to clear cookies
|
||||
console.error('Backend logout error:', error)
|
||||
}
|
||||
|
||||
// Always clear cookies regardless of backend response
|
||||
clearSessionCookie(event)
|
||||
clearTenantIdCookie(event)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully',
|
||||
}
|
||||
})
|
||||
60
frontend/server/api/auth/me.get.ts
Normal file
60
frontend/server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { getSubdomainFromRequest } from '~/server/utils/tenant'
|
||||
import { getSessionToken } from '~/server/utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const subdomain = getSubdomainFromRequest(event)
|
||||
const token = getSessionToken(event)
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated',
|
||||
})
|
||||
}
|
||||
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3000'
|
||||
|
||||
try {
|
||||
// Fetch current user from backend
|
||||
const response = await fetch(`${backendUrl}/api/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(subdomain && { 'x-tenant-subdomain': subdomain }),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Session expired',
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Failed to fetch user',
|
||||
})
|
||||
}
|
||||
|
||||
const user = await response.json()
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.error('Auth check error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to verify authentication',
|
||||
})
|
||||
}
|
||||
})
|
||||
26
frontend/server/api/auth/ws-token.get.ts
Normal file
26
frontend/server/api/auth/ws-token.get.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { getSubdomainFromRequest } from '~/server/utils/tenant'
|
||||
import { getSessionToken } from '~/server/utils/session'
|
||||
|
||||
/**
|
||||
* Get a short-lived token for WebSocket authentication
|
||||
* This is needed because socket.io cannot use HTTP-only cookies directly
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const subdomain = getSubdomainFromRequest(event)
|
||||
const token = getSessionToken(event)
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated',
|
||||
})
|
||||
}
|
||||
|
||||
// Return the token for WebSocket use
|
||||
// The token is already validated by being in the HTTP-only cookie
|
||||
return {
|
||||
token,
|
||||
subdomain,
|
||||
}
|
||||
})
|
||||
94
frontend/server/utils/session.ts
Normal file
94
frontend/server/utils/session.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { getCookie, setCookie, deleteCookie, getHeader } from 'h3'
|
||||
|
||||
const SESSION_COOKIE_NAME = 'routebox_session'
|
||||
const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
|
||||
|
||||
export interface SessionData {
|
||||
token: string
|
||||
tenantId: string
|
||||
userId: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the request is over a secure connection
|
||||
* Checks both direct HTTPS and proxy headers
|
||||
*/
|
||||
function isSecureRequest(event: H3Event): boolean {
|
||||
// Check x-forwarded-proto header (set by reverse proxies)
|
||||
const forwardedProto = getHeader(event, 'x-forwarded-proto')
|
||||
if (forwardedProto === 'https') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if NODE_ENV is production (assume HTTPS in production)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session token from HTTP-only cookie
|
||||
*/
|
||||
export function getSessionToken(event: H3Event): string | null {
|
||||
return getCookie(event, SESSION_COOKIE_NAME) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session token in an HTTP-only cookie
|
||||
*/
|
||||
export function setSessionCookie(event: H3Event, token: string): void {
|
||||
const secure = isSecureRequest(event)
|
||||
|
||||
setCookie(event, SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session cookie
|
||||
*/
|
||||
export function clearSessionCookie(event: H3Event): void {
|
||||
deleteCookie(event, SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant ID from a separate cookie (for SSR access)
|
||||
* This is NOT the auth token - just tenant context
|
||||
*/
|
||||
export function getTenantIdFromCookie(event: H3Event): string | null {
|
||||
return getCookie(event, 'routebox_tenant') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tenant ID cookie (readable by client for context)
|
||||
*/
|
||||
export function setTenantIdCookie(event: H3Event, tenantId: string): void {
|
||||
const secure = isSecureRequest(event)
|
||||
|
||||
setCookie(event, 'routebox_tenant', tenantId, {
|
||||
httpOnly: false, // Allow client to read tenant context
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tenant ID cookie
|
||||
*/
|
||||
export function clearTenantIdCookie(event: H3Event): void {
|
||||
deleteCookie(event, 'routebox_tenant', {
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
39
frontend/server/utils/tenant.ts
Normal file
39
frontend/server/utils/tenant.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { getHeader } from 'h3'
|
||||
|
||||
/**
|
||||
* Extract subdomain from the request Host header
|
||||
* Handles production domains (tenant1.routebox.co) and development (tenant1.localhost)
|
||||
*/
|
||||
export function getSubdomainFromRequest(event: H3Event): string | null {
|
||||
const host = getHeader(event, 'host') || ''
|
||||
const hostname = host.split(':')[0] // Remove port if present
|
||||
|
||||
const parts = hostname.split('.')
|
||||
|
||||
// For production domains with 3+ parts (e.g., tenant1.routebox.co)
|
||||
if (parts.length >= 3) {
|
||||
const subdomain = parts[0]
|
||||
// Ignore www subdomain
|
||||
if (subdomain === 'www') {
|
||||
return null
|
||||
}
|
||||
return subdomain
|
||||
}
|
||||
|
||||
// For development (e.g., tenant1.localhost)
|
||||
if (parts.length === 2 && parts[1] === 'localhost') {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subdomain is a central/admin subdomain
|
||||
*/
|
||||
export function isCentralSubdomain(subdomain: string | null): boolean {
|
||||
if (!subdomain) return false
|
||||
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',')
|
||||
return centralSubdomains.includes(subdomain)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
export interface FieldLayoutItem {
|
||||
fieldId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
// For list layouts: field order (optional)
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type PageLayoutType = 'detail' | 'list';
|
||||
|
||||
export interface PageLayoutConfig {
|
||||
fields: FieldLayoutItem[];
|
||||
relatedLists?: string[];
|
||||
@@ -15,16 +19,23 @@ export interface PageLayout {
|
||||
id: string;
|
||||
name: string;
|
||||
objectId: string;
|
||||
layoutType: PageLayoutType;
|
||||
isDefault: boolean;
|
||||
layoutConfig: PageLayoutConfig;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// Database column names (snake_case) - used when data comes directly from DB
|
||||
layout_type?: PageLayoutType;
|
||||
layout_config?: PageLayoutConfig;
|
||||
object_id?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePageLayoutRequest {
|
||||
name: string;
|
||||
objectId: string;
|
||||
layoutType?: PageLayoutType;
|
||||
isDefault?: boolean;
|
||||
layoutConfig: PageLayoutConfig;
|
||||
description?: string;
|
||||
|
||||
Reference in New Issue
Block a user