@thinksoftai/cli 1.6.13 → 1.6.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/export.js +1550 -99
- package/dist/commands/export.js.map +1 -1
- package/dist/index.js +2 -2
- package/package.json +1 -1
package/dist/commands/export.js
CHANGED
|
@@ -126,6 +126,7 @@ async function exportBackend(options = {}) {
|
|
|
126
126
|
}
|
|
127
127
|
// Chat option (only for node-express)
|
|
128
128
|
let chatOption = 'none';
|
|
129
|
+
let authOption = 'none';
|
|
129
130
|
if (answers.format === 'node-express') {
|
|
130
131
|
const chatAnswer = await inquirer_1.default.prompt([
|
|
131
132
|
{
|
|
@@ -141,6 +142,21 @@ async function exportBackend(options = {}) {
|
|
|
141
142
|
}
|
|
142
143
|
]);
|
|
143
144
|
chatOption = chatAnswer.chatOption;
|
|
145
|
+
// Auth option
|
|
146
|
+
const authAnswer = await inquirer_1.default.prompt([
|
|
147
|
+
{
|
|
148
|
+
type: 'list',
|
|
149
|
+
name: 'authOption',
|
|
150
|
+
message: 'User authentication:',
|
|
151
|
+
choices: [
|
|
152
|
+
{ name: 'ThinkSoft Email OTP (no setup needed)', value: 'thinksoft' },
|
|
153
|
+
{ name: 'Self-hosted Email OTP (requires Resend/SendGrid)', value: 'self-hosted' },
|
|
154
|
+
{ name: 'None (no authentication)', value: 'none' }
|
|
155
|
+
],
|
|
156
|
+
default: 'thinksoft'
|
|
157
|
+
}
|
|
158
|
+
]);
|
|
159
|
+
authOption = authAnswer.authOption;
|
|
144
160
|
}
|
|
145
161
|
// Output directory
|
|
146
162
|
const appSlug = exportData.app?.name?.toLowerCase().replace(/\s+/g, '-') || appId.toLowerCase();
|
|
@@ -158,6 +174,7 @@ async function exportBackend(options = {}) {
|
|
|
158
174
|
const fileCount = await generateExport(exportData, {
|
|
159
175
|
...answers,
|
|
160
176
|
chatOption,
|
|
177
|
+
authOption,
|
|
161
178
|
baseUrl: answers.baseUrl
|
|
162
179
|
}, endpointConfig, outputDir);
|
|
163
180
|
generatorSpinner.succeed(`Generated ${fileCount} files`);
|
|
@@ -193,7 +210,7 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
193
210
|
// PostgreSQL only - just schema and triggers
|
|
194
211
|
fs.mkdirSync(path.join(outputDir, 'database'), { recursive: true });
|
|
195
212
|
// Generate schema
|
|
196
|
-
const schema = generateSchema(data.tables);
|
|
213
|
+
const schema = generateSchema(data.tables, config.authOption);
|
|
197
214
|
fs.writeFileSync(path.join(outputDir, 'database/schema.sql'), schema);
|
|
198
215
|
fileCount++;
|
|
199
216
|
// Generate triggers if rules included
|
|
@@ -226,7 +243,7 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
226
243
|
fs.mkdirSync(path.join(outputDir, dir), { recursive: true });
|
|
227
244
|
}
|
|
228
245
|
// Database files
|
|
229
|
-
const schema = generateSchema(data.tables);
|
|
246
|
+
const schema = generateSchema(data.tables, config.authOption);
|
|
230
247
|
fs.writeFileSync(path.join(outputDir, 'database/schema.sql'), schema);
|
|
231
248
|
fileCount++;
|
|
232
249
|
if (config.includeRules && data.rules?.length > 0) {
|
|
@@ -264,7 +281,7 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
264
281
|
fs.writeFileSync(path.join(outputDir, 'src/routes/agents.js'), agentRoutes);
|
|
265
282
|
fileCount++;
|
|
266
283
|
// Agents
|
|
267
|
-
const agentConfig = generateAgentConfig(data.agents);
|
|
284
|
+
const agentConfig = generateAgentConfig(data.agents, data.rules);
|
|
268
285
|
fs.writeFileSync(path.join(outputDir, 'agents/config.json'), agentConfig);
|
|
269
286
|
fileCount++;
|
|
270
287
|
const agentActions = generateAgentActions();
|
|
@@ -289,7 +306,7 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
289
306
|
fs.writeFileSync(path.join(outputDir, 'src/events/index.js'), events);
|
|
290
307
|
fileCount++;
|
|
291
308
|
// Main files
|
|
292
|
-
const indexJs = generateIndex(config.baseUrl);
|
|
309
|
+
const indexJs = generateIndex(config.baseUrl, config.authOption);
|
|
293
310
|
fs.writeFileSync(path.join(outputDir, 'src/index.js'), indexJs);
|
|
294
311
|
fileCount++;
|
|
295
312
|
const dbJs = generateDb();
|
|
@@ -298,6 +315,15 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
298
315
|
const authMiddleware = generateAuthMiddleware();
|
|
299
316
|
fs.writeFileSync(path.join(outputDir, 'src/middleware/auth.js'), authMiddleware);
|
|
300
317
|
fileCount++;
|
|
318
|
+
// Auth controller and routes (if auth enabled)
|
|
319
|
+
if (config.authOption && config.authOption !== 'none') {
|
|
320
|
+
const authController = generateAuthController(data.app, config.authOption);
|
|
321
|
+
fs.writeFileSync(path.join(outputDir, 'src/controllers/auth.js'), authController);
|
|
322
|
+
fileCount++;
|
|
323
|
+
const authRoutes = generateAuthRoutes();
|
|
324
|
+
fs.writeFileSync(path.join(outputDir, 'src/routes/auth.js'), authRoutes);
|
|
325
|
+
fileCount++;
|
|
326
|
+
}
|
|
301
327
|
// Config files
|
|
302
328
|
const apiConfig = generateApiConfig(data.tables, endpoints, config);
|
|
303
329
|
fs.writeFileSync(path.join(outputDir, 'api.config.json'), apiConfig);
|
|
@@ -319,16 +345,37 @@ async function generateExport(data, config, endpoints, outputDir) {
|
|
|
319
345
|
const readme = generateReadme(data.app, config);
|
|
320
346
|
fs.writeFileSync(path.join(outputDir, 'README.md'), readme);
|
|
321
347
|
fileCount++;
|
|
348
|
+
// Context.md - comprehensive app documentation for developers and AI
|
|
349
|
+
const context = generateContext(data, config, endpoints);
|
|
350
|
+
fs.writeFileSync(path.join(outputDir, 'context.md'), context);
|
|
351
|
+
fileCount++;
|
|
322
352
|
}
|
|
323
353
|
return fileCount;
|
|
324
354
|
}
|
|
325
355
|
// ============================================
|
|
326
356
|
// Generator Functions
|
|
327
357
|
// ============================================
|
|
328
|
-
function generateSchema(tables) {
|
|
358
|
+
function generateSchema(tables, authOption = 'none') {
|
|
329
359
|
let sql = '-- Auto-generated PostgreSQL schema\n';
|
|
330
360
|
sql += '-- Generated by ThinkSoft CLI\n\n';
|
|
331
361
|
sql += 'CREATE EXTENSION IF NOT EXISTS "pgcrypto";\n\n';
|
|
362
|
+
// Add app_users table for authentication
|
|
363
|
+
if (authOption !== 'none') {
|
|
364
|
+
sql += '-- Users table for authentication\n';
|
|
365
|
+
sql += 'CREATE TABLE app_users (\n';
|
|
366
|
+
sql += ' id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n';
|
|
367
|
+
sql += ' email VARCHAR(255) UNIQUE NOT NULL,\n';
|
|
368
|
+
sql += ' name VARCHAR(255),\n';
|
|
369
|
+
sql += ' otp_code VARCHAR(6),\n';
|
|
370
|
+
sql += ' otp_expires_at TIMESTAMP,\n';
|
|
371
|
+
sql += ' refresh_token VARCHAR(500),\n';
|
|
372
|
+
sql += ' last_login TIMESTAMP,\n';
|
|
373
|
+
sql += ' is_active BOOLEAN DEFAULT TRUE,\n';
|
|
374
|
+
sql += ' created_at TIMESTAMP DEFAULT NOW(),\n';
|
|
375
|
+
sql += ' updated_at TIMESTAMP DEFAULT NOW()\n';
|
|
376
|
+
sql += ');\n\n';
|
|
377
|
+
sql += 'CREATE INDEX idx_app_users_email ON app_users(email);\n\n';
|
|
378
|
+
}
|
|
332
379
|
const typeMap = {
|
|
333
380
|
'text': 'VARCHAR(255)',
|
|
334
381
|
'textarea': 'TEXT',
|
|
@@ -596,37 +643,271 @@ module.exports = { schema, validate };
|
|
|
596
643
|
`;
|
|
597
644
|
}
|
|
598
645
|
function generateHooks(table, rules) {
|
|
646
|
+
// Group rules by trigger
|
|
647
|
+
const rulesByTrigger = {
|
|
648
|
+
before_create: [],
|
|
649
|
+
after_create: [],
|
|
650
|
+
before_update: [],
|
|
651
|
+
after_update: [],
|
|
652
|
+
before_delete: [],
|
|
653
|
+
after_delete: []
|
|
654
|
+
};
|
|
655
|
+
for (const rule of rules || []) {
|
|
656
|
+
if (rule.trigger && rulesByTrigger[rule.trigger]) {
|
|
657
|
+
rulesByTrigger[rule.trigger].push(rule);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Sort each group by priority
|
|
661
|
+
for (const trigger of Object.keys(rulesByTrigger)) {
|
|
662
|
+
rulesByTrigger[trigger].sort((a, b) => (a.priority || 100) - (b.priority || 100));
|
|
663
|
+
}
|
|
664
|
+
// Generate condition evaluation code
|
|
665
|
+
const generateCondition = (condition) => {
|
|
666
|
+
if (!condition || condition === 'true')
|
|
667
|
+
return 'true';
|
|
668
|
+
// Convert Liquid-style conditions to JavaScript
|
|
669
|
+
// Handle common patterns
|
|
670
|
+
let jsCondition = condition
|
|
671
|
+
// Liquid equality: {% if x == 'value' %} -> x === 'value'
|
|
672
|
+
.replace(/\{\%\s*if\s+([^%]+)\s*\%\}/g, '$1')
|
|
673
|
+
.replace(/\{\%\s*endif\s*\%\}/g, '')
|
|
674
|
+
// Liquid variables: {{ field }} -> data.field
|
|
675
|
+
.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
676
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
677
|
+
.replace(/\{\{\s*_new\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
678
|
+
.replace(/\{\{\s*_old\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'oldData.$1')
|
|
679
|
+
// Field access without braces
|
|
680
|
+
.replace(/\brecord\.([a-zA-Z_][a-zA-Z0-9_]*)/g, 'data.$1')
|
|
681
|
+
.replace(/\b_new\.([a-zA-Z_][a-zA-Z0-9_]*)/g, 'data.$1')
|
|
682
|
+
.replace(/\b_old\.([a-zA-Z_][a-zA-Z0-9_]*)/g, 'oldData.$1')
|
|
683
|
+
// Operators
|
|
684
|
+
.replace(/\s+==\s+/g, ' === ')
|
|
685
|
+
.replace(/\s+!=\s+/g, ' !== ')
|
|
686
|
+
.replace(/\s+and\s+/g, ' && ')
|
|
687
|
+
.replace(/\s+or\s+/g, ' || ')
|
|
688
|
+
.replace(/\s+contains\s+/g, '.includes(')
|
|
689
|
+
// Liquid filters: | size -> .length
|
|
690
|
+
.replace(/\|\s*size/g, '.length')
|
|
691
|
+
// Blank check
|
|
692
|
+
.replace(/\s+blank/g, " === '' || data.$1 === null || data.$1 === undefined")
|
|
693
|
+
.replace(/\s+present/g, " !== '' && data.$1 !== null && data.$1 !== undefined")
|
|
694
|
+
.trim();
|
|
695
|
+
// If still has Liquid syntax, wrap in try-catch eval
|
|
696
|
+
if (jsCondition.includes('{%') || jsCondition.includes('{{')) {
|
|
697
|
+
return `/* Complex condition: ${condition} */ true`;
|
|
698
|
+
}
|
|
699
|
+
return jsCondition || 'true';
|
|
700
|
+
};
|
|
701
|
+
// Generate action code
|
|
702
|
+
const generateActionCode = (action, indent = ' ') => {
|
|
703
|
+
switch (action.type) {
|
|
704
|
+
case 'set_field': {
|
|
705
|
+
let value = action.value;
|
|
706
|
+
// Convert Liquid template to JS
|
|
707
|
+
if (typeof value === 'string') {
|
|
708
|
+
if (value.includes('{{') || value.includes('{%')) {
|
|
709
|
+
// Replace Liquid variables with JS
|
|
710
|
+
value = value
|
|
711
|
+
.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, "' + data.$1 + '")
|
|
712
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, "' + data.$1 + '")
|
|
713
|
+
.replace(/\{\{\s*_timestamp\s*\}\}/g, "' + new Date().toISOString() + '")
|
|
714
|
+
.replace(/\{\{\s*_date\s*\}\}/g, "' + new Date().toISOString().split('T')[0] + '");
|
|
715
|
+
value = `'${value}'`;
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
value = `'${value}'`;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return `${indent}data.${action.field} = ${value};`;
|
|
722
|
+
}
|
|
723
|
+
case 'calculate': {
|
|
724
|
+
// Convert Liquid math to JS
|
|
725
|
+
let formula = action.value || '';
|
|
726
|
+
formula = formula
|
|
727
|
+
.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
728
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
729
|
+
.replace(/\|\s*plus:\s*/g, ' + ')
|
|
730
|
+
.replace(/\|\s*minus:\s*/g, ' - ')
|
|
731
|
+
.replace(/\|\s*times:\s*/g, ' * ')
|
|
732
|
+
.replace(/\|\s*divided_by:\s*/g, ' / ');
|
|
733
|
+
return `${indent}data.${action.field} = ${formula};`;
|
|
734
|
+
}
|
|
735
|
+
case 'reject': {
|
|
736
|
+
let message = action.message || 'Operation rejected by business rule';
|
|
737
|
+
// Convert Liquid variables in message
|
|
738
|
+
message = message
|
|
739
|
+
.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, '${data.$1}')
|
|
740
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, '${data.$1}');
|
|
741
|
+
return `${indent}throw new Error(\`${message}\`);`;
|
|
742
|
+
}
|
|
743
|
+
case 'validate': {
|
|
744
|
+
const condition = generateCondition(action.condition || 'true');
|
|
745
|
+
let message = action.message || 'Validation failed';
|
|
746
|
+
message = message
|
|
747
|
+
.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, '${data.$1}')
|
|
748
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, '${data.$1}');
|
|
749
|
+
return `${indent}if (!(${condition})) {\n${indent} throw new Error(\`${message}\`);\n${indent}}`;
|
|
750
|
+
}
|
|
751
|
+
case 'lookup_and_set': {
|
|
752
|
+
// Generate lookup query
|
|
753
|
+
const lookupFilter = action.lookupFilter || {};
|
|
754
|
+
const filterEntries = Object.entries(lookupFilter);
|
|
755
|
+
let filterCode = '';
|
|
756
|
+
if (filterEntries.length > 0) {
|
|
757
|
+
const conditions = filterEntries.map(([key, val]) => {
|
|
758
|
+
if (typeof val === 'string' && val.includes('{{')) {
|
|
759
|
+
const jsVal = val.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
760
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1');
|
|
761
|
+
return `${key} = $\${paramIndex++}`;
|
|
762
|
+
}
|
|
763
|
+
return `${key} = $\${paramIndex++}`;
|
|
764
|
+
});
|
|
765
|
+
filterCode = conditions.join(' AND ');
|
|
766
|
+
}
|
|
767
|
+
return `${indent}// Lookup from ${action.lookupTable}
|
|
768
|
+
${indent}const lookupResult = await db.query(
|
|
769
|
+
${indent} 'SELECT ${action.returnField || 'id'} FROM ${action.lookupTable} WHERE ${filterCode || 'TRUE'} LIMIT 1',
|
|
770
|
+
${indent} [${filterEntries.map(([k, v]) => {
|
|
771
|
+
if (typeof v === 'string' && v.includes('{{')) {
|
|
772
|
+
return v.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
773
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1');
|
|
774
|
+
}
|
|
775
|
+
return `'${v}'`;
|
|
776
|
+
}).join(', ')}]
|
|
777
|
+
${indent});
|
|
778
|
+
${indent}if (lookupResult.rows.length > 0) {
|
|
779
|
+
${indent} data.${action.field} = lookupResult.rows[0].${action.returnField || 'id'};
|
|
780
|
+
${indent}}`;
|
|
781
|
+
}
|
|
782
|
+
case 'create_record': {
|
|
783
|
+
const recordData = action.data || {};
|
|
784
|
+
const fields = Object.keys(recordData);
|
|
785
|
+
const values = Object.values(recordData).map((v) => {
|
|
786
|
+
if (typeof v === 'string' && v.includes('{{')) {
|
|
787
|
+
return v.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
788
|
+
.replace(/\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1')
|
|
789
|
+
.replace(/\{\{\s*_new\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, 'data.$1');
|
|
790
|
+
}
|
|
791
|
+
return typeof v === 'string' ? `'${v}'` : v;
|
|
792
|
+
});
|
|
793
|
+
return `${indent}// Create record in ${action.table}
|
|
794
|
+
${indent}await db.query(
|
|
795
|
+
${indent} 'INSERT INTO ${action.table} (${fields.join(', ')}) VALUES (${fields.map((_, i) => '$' + (i + 1)).join(', ')})',
|
|
796
|
+
${indent} [${values.join(', ')}]
|
|
797
|
+
${indent});`;
|
|
798
|
+
}
|
|
799
|
+
default:
|
|
800
|
+
return `${indent}// Unknown action type: ${action.type}`;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
// Generate hook function code
|
|
804
|
+
const generateHookCode = (trigger, rulesForTrigger) => {
|
|
805
|
+
if (rulesForTrigger.length === 0) {
|
|
806
|
+
if (trigger.startsWith('before')) {
|
|
807
|
+
return ` // No rules for this trigger
|
|
808
|
+
return data;`;
|
|
809
|
+
}
|
|
810
|
+
return ` // No rules for this trigger`;
|
|
811
|
+
}
|
|
812
|
+
let code = '';
|
|
813
|
+
for (const rule of rulesForTrigger) {
|
|
814
|
+
const condition = generateCondition(rule.condition || 'true');
|
|
815
|
+
code += ` // Rule: ${rule.name} (priority: ${rule.priority || 100})\n`;
|
|
816
|
+
code += ` if (${condition}) {\n`;
|
|
817
|
+
for (const action of rule.actions || []) {
|
|
818
|
+
code += generateActionCode(action, ' ') + '\n';
|
|
819
|
+
}
|
|
820
|
+
code += ` }\n\n`;
|
|
821
|
+
}
|
|
822
|
+
if (trigger.startsWith('before')) {
|
|
823
|
+
code += ` return data;`;
|
|
824
|
+
}
|
|
825
|
+
return code;
|
|
826
|
+
};
|
|
827
|
+
// Build the full hooks file
|
|
599
828
|
return `/**
|
|
600
829
|
* ${table.name} Hooks
|
|
601
|
-
* Auto-generated by ThinkSoft CLI
|
|
830
|
+
* Auto-generated from business rules by ThinkSoft CLI
|
|
831
|
+
*
|
|
832
|
+
* Rules converted: ${rules?.length || 0}
|
|
602
833
|
*/
|
|
603
834
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Before Create Hook
|
|
837
|
+
* Called before a new record is inserted
|
|
838
|
+
* @param {object} data - The data being inserted
|
|
839
|
+
* @param {object} db - Database connection
|
|
840
|
+
* @returns {object} Modified data or throws error to reject
|
|
841
|
+
*/
|
|
842
|
+
async function beforeCreate(data, db) {
|
|
843
|
+
${generateHookCode('before_create', rulesByTrigger.before_create)}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* After Create Hook
|
|
848
|
+
* Called after a record is successfully inserted
|
|
849
|
+
* @param {object} record - The created record (with id)
|
|
850
|
+
* @param {object} db - Database connection
|
|
851
|
+
*/
|
|
852
|
+
async function afterCreate(record, db) {
|
|
853
|
+
const data = record; // Alias for rule compatibility
|
|
854
|
+
${generateHookCode('after_create', rulesByTrigger.after_create)}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Before Update Hook
|
|
859
|
+
* Called before a record is updated
|
|
860
|
+
* @param {object} oldData - The current record data
|
|
861
|
+
* @param {object} data - The new data being applied
|
|
862
|
+
* @param {object} db - Database connection
|
|
863
|
+
* @returns {object} Modified data or throws error to reject
|
|
864
|
+
*/
|
|
865
|
+
async function beforeUpdate(oldData, data, db) {
|
|
866
|
+
${generateHookCode('before_update', rulesByTrigger.before_update)}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* After Update Hook
|
|
871
|
+
* Called after a record is successfully updated
|
|
872
|
+
* @param {object} oldData - The previous record data
|
|
873
|
+
* @param {object} newData - The updated record
|
|
874
|
+
* @param {object} db - Database connection
|
|
875
|
+
*/
|
|
876
|
+
async function afterUpdate(oldData, newData, db) {
|
|
877
|
+
const data = newData; // Alias for rule compatibility
|
|
878
|
+
${generateHookCode('after_update', rulesByTrigger.after_update)}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Before Delete Hook
|
|
883
|
+
* Called before a record is deleted
|
|
884
|
+
* @param {object} record - The record being deleted
|
|
885
|
+
* @param {object} db - Database connection
|
|
886
|
+
* @throws {Error} To prevent deletion
|
|
887
|
+
*/
|
|
888
|
+
async function beforeDelete(record, db) {
|
|
889
|
+
const data = record; // Alias for rule compatibility
|
|
890
|
+
${generateHookCode('before_delete', rulesByTrigger.before_delete)}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* After Delete Hook
|
|
895
|
+
* Called after a record is successfully deleted
|
|
896
|
+
* @param {object} record - The deleted record
|
|
897
|
+
* @param {object} db - Database connection
|
|
898
|
+
*/
|
|
899
|
+
async function afterDelete(record, db) {
|
|
900
|
+
const data = record; // Alias for rule compatibility
|
|
901
|
+
${generateHookCode('after_delete', rulesByTrigger.after_delete)}
|
|
902
|
+
}
|
|
626
903
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
904
|
+
module.exports = {
|
|
905
|
+
beforeCreate,
|
|
906
|
+
afterCreate,
|
|
907
|
+
beforeUpdate,
|
|
908
|
+
afterUpdate,
|
|
909
|
+
beforeDelete,
|
|
910
|
+
afterDelete
|
|
630
911
|
};
|
|
631
912
|
`;
|
|
632
913
|
}
|
|
@@ -663,30 +944,33 @@ function generateAgentRoutes() {
|
|
|
663
944
|
|
|
664
945
|
const express = require('express');
|
|
665
946
|
const router = express.Router();
|
|
666
|
-
const {
|
|
667
|
-
const
|
|
668
|
-
const {
|
|
947
|
+
const { optionalAuth } = require('../middleware/auth');
|
|
948
|
+
const { checkAgentAccess, checkTablePermissions } = require('../middleware/agentAuth');
|
|
949
|
+
const { executeAction } = require('../../agents/actions');
|
|
669
950
|
const agentConfig = require('../../agents/config.json');
|
|
670
951
|
|
|
952
|
+
// Middleware chain: optionalAuth -> checkAgentAccess -> checkTablePermissions
|
|
953
|
+
// optionalAuth: tries to authenticate but doesn't fail if no token (for public agents)
|
|
954
|
+
// checkAgentAccess: enforces access rules (public/authenticated/table-based)
|
|
955
|
+
// checkTablePermissions: enforces CRUD permissions on tables
|
|
956
|
+
|
|
671
957
|
// Get agent info
|
|
672
|
-
router.get('/:agent',
|
|
673
|
-
const agent =
|
|
674
|
-
if (!agent) {
|
|
675
|
-
return res.status(404).json({ error: 'Agent not found' });
|
|
676
|
-
}
|
|
958
|
+
router.get('/:agent', optionalAuth, checkAgentAccess, (req, res) => {
|
|
959
|
+
const agent = req.agent;
|
|
677
960
|
res.json({
|
|
678
961
|
name: agent.name,
|
|
679
962
|
description: agent.description,
|
|
680
963
|
welcome_message: agent.welcome_message,
|
|
681
|
-
action_chips: agent.action_chips
|
|
964
|
+
action_chips: agent.action_chips,
|
|
965
|
+
access_type: agent.access_rules?.type || 'authenticated'
|
|
682
966
|
});
|
|
683
967
|
});
|
|
684
968
|
|
|
685
969
|
// Execute action chip
|
|
686
|
-
router.post('/:agent/action',
|
|
970
|
+
router.post('/:agent/action', optionalAuth, checkAgentAccess, async (req, res) => {
|
|
687
971
|
try {
|
|
688
972
|
const { action } = req.body;
|
|
689
|
-
const result = await executeAction(req.params.agent, action, req.user?.id);
|
|
973
|
+
const result = await executeAction(req.params.agent, action, req.user?.userId, req.matchedRecord?.id);
|
|
690
974
|
res.json(result);
|
|
691
975
|
} catch (error) {
|
|
692
976
|
res.status(400).json({ error: error.message });
|
|
@@ -694,49 +978,101 @@ router.post('/:agent/action', authenticate, async (req, res) => {
|
|
|
694
978
|
});
|
|
695
979
|
|
|
696
980
|
// Agent-scoped CRUD
|
|
697
|
-
router.get('/:agent/:table',
|
|
981
|
+
router.get('/:agent/:table', optionalAuth, checkAgentAccess, checkTablePermissions, async (req, res) => {
|
|
698
982
|
const controller = require(\`../controllers/\${req.params.table}\`);
|
|
699
983
|
return controller.list(req, res);
|
|
700
984
|
});
|
|
701
985
|
|
|
702
|
-
router.get('/:agent/:table/:id',
|
|
986
|
+
router.get('/:agent/:table/:id', optionalAuth, checkAgentAccess, checkTablePermissions, async (req, res) => {
|
|
703
987
|
const controller = require(\`../controllers/\${req.params.table}\`);
|
|
704
988
|
return controller.get(req, res);
|
|
705
989
|
});
|
|
706
990
|
|
|
707
|
-
router.post('/:agent/:table',
|
|
991
|
+
router.post('/:agent/:table', optionalAuth, checkAgentAccess, checkTablePermissions, async (req, res) => {
|
|
708
992
|
const controller = require(\`../controllers/\${req.params.table}\`);
|
|
709
993
|
return controller.create(req, res);
|
|
710
994
|
});
|
|
711
995
|
|
|
712
|
-
router.put('/:agent/:table/:id',
|
|
996
|
+
router.put('/:agent/:table/:id', optionalAuth, checkAgentAccess, checkTablePermissions, async (req, res) => {
|
|
713
997
|
const controller = require(\`../controllers/\${req.params.table}\`);
|
|
714
998
|
return controller.update(req, res);
|
|
715
999
|
});
|
|
716
1000
|
|
|
1001
|
+
router.delete('/:agent/:table/:id', optionalAuth, checkAgentAccess, checkTablePermissions, async (req, res) => {
|
|
1002
|
+
const controller = require(\`../controllers/\${req.params.table}\`);
|
|
1003
|
+
return controller.delete(req, res);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
717
1006
|
module.exports = router;
|
|
718
1007
|
`;
|
|
719
1008
|
}
|
|
720
|
-
function generateAgentConfig(agents) {
|
|
1009
|
+
function generateAgentConfig(agents, rules = []) {
|
|
721
1010
|
const config = {};
|
|
722
1011
|
for (const agent of agents || []) {
|
|
1012
|
+
// Build access_rules from auth_type
|
|
1013
|
+
let accessRules = { type: 'authenticated' };
|
|
1014
|
+
const authType = agent.auth_type || 'authenticated';
|
|
1015
|
+
if (authType === 'public') {
|
|
1016
|
+
accessRules = { type: 'public' };
|
|
1017
|
+
}
|
|
1018
|
+
else if (authType === 'table' || authType === 'table-based') {
|
|
1019
|
+
// Table-based auth - user must exist in specific table
|
|
1020
|
+
accessRules = {
|
|
1021
|
+
type: 'table-based',
|
|
1022
|
+
table: agent.auth_table || agent.audience?.toLowerCase().replace(/\s+/g, '_'),
|
|
1023
|
+
match_field: 'email',
|
|
1024
|
+
match_value: '{{user.email}}'
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
accessRules = { type: 'authenticated' };
|
|
1029
|
+
}
|
|
723
1030
|
config[agent.slug] = {
|
|
724
1031
|
name: agent.name,
|
|
725
1032
|
slug: agent.slug,
|
|
726
1033
|
description: agent.description,
|
|
1034
|
+
audience: agent.audience || agent.name,
|
|
727
1035
|
welcome_message: agent.welcome_message,
|
|
728
|
-
|
|
1036
|
+
access_rules: accessRules,
|
|
729
1037
|
permissions: agent.permissions || {},
|
|
730
1038
|
user_filter: agent.user_filter || {},
|
|
731
|
-
action_chips: (agent.action_chips || []).map((chip) =>
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1039
|
+
action_chips: (agent.action_chips || []).map((chip) => {
|
|
1040
|
+
// Start with chip's existing autoFill and hiddenFields
|
|
1041
|
+
const autoFill = [...(chip.autoFill || [])];
|
|
1042
|
+
const hiddenFields = [...(chip.hiddenFields || [])];
|
|
1043
|
+
// For create actions, add autoFill from before_create rules
|
|
1044
|
+
if (chip.action === 'create' && chip.table) {
|
|
1045
|
+
const createRules = (rules || []).filter((r) => (r.table === chip.table || r.table_slug === chip.table) &&
|
|
1046
|
+
r.trigger === 'before_create' &&
|
|
1047
|
+
r.enabled !== false);
|
|
1048
|
+
for (const rule of createRules) {
|
|
1049
|
+
for (const action of (rule.actions || [])) {
|
|
1050
|
+
if (action.type === 'set_field' && action.field) {
|
|
1051
|
+
// Add to autoFill if not already set by chip
|
|
1052
|
+
if (!autoFill.find((af) => af.field === action.field)) {
|
|
1053
|
+
autoFill.push({ field: action.field, value: action.value });
|
|
1054
|
+
}
|
|
1055
|
+
// Add to hiddenFields if not already there
|
|
1056
|
+
if (!hiddenFields.includes(action.field)) {
|
|
1057
|
+
hiddenFields.push(action.field);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
label: chip.label,
|
|
1065
|
+
icon: chip.icon,
|
|
1066
|
+
action: chip.action,
|
|
1067
|
+
table: chip.table,
|
|
1068
|
+
display: chip.display || 'table',
|
|
1069
|
+
filter: chip.filter,
|
|
1070
|
+
filters: chip.filters,
|
|
1071
|
+
fields: chip.fields,
|
|
1072
|
+
autoFill: autoFill.length > 0 ? autoFill : undefined,
|
|
1073
|
+
hiddenFields: hiddenFields.length > 0 ? hiddenFields : undefined
|
|
1074
|
+
};
|
|
1075
|
+
})
|
|
740
1076
|
};
|
|
741
1077
|
}
|
|
742
1078
|
return JSON.stringify(config, null, 2);
|
|
@@ -813,18 +1149,94 @@ function generateAgentMiddleware() {
|
|
|
813
1149
|
return `/**
|
|
814
1150
|
* Agent Permission Middleware
|
|
815
1151
|
* Auto-generated by ThinkSoft CLI
|
|
1152
|
+
*
|
|
1153
|
+
* Handles:
|
|
1154
|
+
* 1. Access Rules (public/authenticated/table-based)
|
|
1155
|
+
* 2. Table Permissions (CRUD)
|
|
816
1156
|
*/
|
|
817
1157
|
|
|
1158
|
+
const db = require('../db');
|
|
818
1159
|
const agentConfig = require('../../agents/config.json');
|
|
819
1160
|
|
|
820
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* Check agent access rules
|
|
1163
|
+
* - public: anyone can access (no login required)
|
|
1164
|
+
* - authenticated: must be logged in (in app_users)
|
|
1165
|
+
* - table-based: must be logged in AND exist in specified table
|
|
1166
|
+
*/
|
|
1167
|
+
const checkAgentAccess = async (req, res, next) => {
|
|
821
1168
|
const agent = agentConfig[req.params.agent];
|
|
822
1169
|
if (!agent) {
|
|
823
1170
|
return res.status(404).json({ error: 'Agent not found' });
|
|
824
1171
|
}
|
|
825
1172
|
|
|
1173
|
+
const accessRules = agent.access_rules || { type: 'authenticated' };
|
|
1174
|
+
|
|
1175
|
+
switch (accessRules.type) {
|
|
1176
|
+
case 'public':
|
|
1177
|
+
// Anyone can access, no login required
|
|
1178
|
+
req.agent = agent;
|
|
1179
|
+
return next();
|
|
1180
|
+
|
|
1181
|
+
case 'authenticated':
|
|
1182
|
+
// Must be logged in
|
|
1183
|
+
if (!req.user) {
|
|
1184
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
1185
|
+
}
|
|
1186
|
+
req.agent = agent;
|
|
1187
|
+
return next();
|
|
1188
|
+
|
|
1189
|
+
case 'table-based':
|
|
1190
|
+
// Must be logged in AND exist in specified table
|
|
1191
|
+
if (!req.user) {
|
|
1192
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (!accessRules.table) {
|
|
1196
|
+
// No table specified, fall back to authenticated
|
|
1197
|
+
req.agent = agent;
|
|
1198
|
+
return next();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
try {
|
|
1202
|
+
const matchField = accessRules.match_field || 'email';
|
|
1203
|
+
const matchValue = accessRules.match_value === '{{user.id}}'
|
|
1204
|
+
? req.user.userId
|
|
1205
|
+
: req.user.email;
|
|
1206
|
+
|
|
1207
|
+
const { rows } = await db.query(
|
|
1208
|
+
\`SELECT id FROM \${accessRules.table} WHERE \${matchField} = $1 LIMIT 1\`,
|
|
1209
|
+
[matchValue]
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
if (rows.length === 0) {
|
|
1213
|
+
return res.status(403).json({
|
|
1214
|
+
error: \`Access denied. You are not registered as a \${agent.audience || 'user'}.\`
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
req.matchedRecord = rows[0];
|
|
1219
|
+
req.agent = agent;
|
|
1220
|
+
return next();
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
console.error('Access check error:', error);
|
|
1223
|
+
// If table doesn't exist, fall back to authenticated
|
|
1224
|
+
req.agent = agent;
|
|
1225
|
+
return next();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
default:
|
|
1229
|
+
return res.status(403).json({ error: 'Unknown access rule type' });
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Check table-level permissions (CRUD)
|
|
1235
|
+
*/
|
|
1236
|
+
const checkTablePermissions = (req, res, next) => {
|
|
1237
|
+
const agent = req.agent;
|
|
826
1238
|
const table = req.params.table;
|
|
827
|
-
const permissions = agent.permissions[table];
|
|
1239
|
+
const permissions = agent.permissions?.[table];
|
|
828
1240
|
|
|
829
1241
|
if (!permissions) {
|
|
830
1242
|
return res.status(403).json({ error: 'No access to this table' });
|
|
@@ -838,18 +1250,20 @@ const agentAuth = (req, res, next) => {
|
|
|
838
1250
|
(method === 'DELETE' && permissions.delete);
|
|
839
1251
|
|
|
840
1252
|
if (!allowed) {
|
|
841
|
-
return res.status(403).json({ error: 'Permission denied' });
|
|
1253
|
+
return res.status(403).json({ error: 'Permission denied for this action' });
|
|
842
1254
|
}
|
|
843
1255
|
|
|
1256
|
+
// Apply user filter if configured
|
|
844
1257
|
if (agent.user_filter?.[table] && req.user) {
|
|
845
|
-
req.userFilter = agent.user_filter[table]
|
|
1258
|
+
req.userFilter = agent.user_filter[table]
|
|
1259
|
+
.replace(/{{user\\.id}}/g, req.user.userId)
|
|
1260
|
+
.replace(/{{user\\.email}}/g, req.user.email);
|
|
846
1261
|
}
|
|
847
1262
|
|
|
848
|
-
req.agent = agent;
|
|
849
1263
|
next();
|
|
850
1264
|
};
|
|
851
1265
|
|
|
852
|
-
module.exports =
|
|
1266
|
+
module.exports = { checkAgentAccess, checkTablePermissions };
|
|
853
1267
|
`;
|
|
854
1268
|
}
|
|
855
1269
|
function generateChat(option) {
|
|
@@ -918,61 +1332,513 @@ module.exports = { chat };
|
|
|
918
1332
|
`;
|
|
919
1333
|
}
|
|
920
1334
|
function generateClient(tables, endpoints, baseUrl) {
|
|
921
|
-
|
|
922
|
-
|
|
1335
|
+
// Generate table list for schema endpoint
|
|
1336
|
+
const tableSchemas = (tables || []).map(t => ({
|
|
1337
|
+
slug: t.slug,
|
|
1338
|
+
name: t.name,
|
|
1339
|
+
fields: t.fields || []
|
|
1340
|
+
}));
|
|
1341
|
+
return `/**
|
|
1342
|
+
* ThinkSoft Compatible Client SDK
|
|
923
1343
|
* Auto-generated by ThinkSoft CLI
|
|
1344
|
+
*
|
|
1345
|
+
* This client mirrors the @thinksoftai/sdk interface.
|
|
1346
|
+
* Code written for ThinkSoft hosted will work with this client.
|
|
1347
|
+
*
|
|
1348
|
+
* Usage:
|
|
1349
|
+
* import { ThinkSoft } from './client/api';
|
|
1350
|
+
* const client = new ThinkSoft({ appId: 'my-app' });
|
|
1351
|
+
*
|
|
1352
|
+
* // Auth
|
|
1353
|
+
* await client.auth.sendOtp('user@example.com');
|
|
1354
|
+
* await client.auth.verifyOtp('user@example.com', '123456');
|
|
1355
|
+
*
|
|
1356
|
+
* // CRUD
|
|
1357
|
+
* const records = await client.from('orders').list();
|
|
1358
|
+
* await client.from('orders').create({ product: 'Widget' });
|
|
924
1359
|
*/
|
|
925
1360
|
|
|
926
|
-
const
|
|
1361
|
+
const DEFAULT_BASE_URL = 'http://localhost:3000${baseUrl}';
|
|
927
1362
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
});
|
|
1363
|
+
// Storage keys
|
|
1364
|
+
const TOKEN_KEY = 'thinksoft_token';
|
|
1365
|
+
const REFRESH_TOKEN_KEY = 'thinksoft_refresh_token';
|
|
1366
|
+
const EXPIRES_AT_KEY = 'thinksoft_expires_at';
|
|
1367
|
+
const USER_KEY = 'thinksoft_user';
|
|
934
1368
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (!response.ok) throw new Error(data.error || 'Request failed');
|
|
938
|
-
return data;
|
|
939
|
-
};
|
|
1369
|
+
// In-memory storage fallback
|
|
1370
|
+
const memoryStorage = {};
|
|
940
1371
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1372
|
+
function getStorage(type) {
|
|
1373
|
+
if (type === 'none') return null;
|
|
1374
|
+
|
|
1375
|
+
if (type === 'memory') {
|
|
1376
|
+
return {
|
|
1377
|
+
getItem: (key) => memoryStorage[key] || null,
|
|
1378
|
+
setItem: (key, value) => { memoryStorage[key] = value; },
|
|
1379
|
+
removeItem: (key) => { delete memoryStorage[key]; },
|
|
1380
|
+
clear: () => { Object.keys(memoryStorage).forEach(k => delete memoryStorage[k]); }
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Check if localStorage is available (browser)
|
|
1385
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
1386
|
+
return window.localStorage;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Fallback to memory
|
|
1390
|
+
return getStorage('memory');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Token Storage - handles storing and retrieving auth tokens
|
|
1395
|
+
*/
|
|
1396
|
+
class TokenStorage {
|
|
1397
|
+
constructor(type = 'localStorage') {
|
|
1398
|
+
this.storage = getStorage(type);
|
|
1399
|
+
this._token = null;
|
|
1400
|
+
this._refreshToken = null;
|
|
1401
|
+
this._expiresAt = null;
|
|
1402
|
+
this._user = null;
|
|
1403
|
+
this.load();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
load() {
|
|
1407
|
+
if (!this.storage) return;
|
|
1408
|
+
this._token = this.storage.getItem(TOKEN_KEY);
|
|
1409
|
+
this._refreshToken = this.storage.getItem(REFRESH_TOKEN_KEY);
|
|
1410
|
+
const expiresAtStr = this.storage.getItem(EXPIRES_AT_KEY);
|
|
1411
|
+
if (expiresAtStr) this._expiresAt = parseInt(expiresAtStr, 10);
|
|
1412
|
+
const userJson = this.storage.getItem(USER_KEY);
|
|
1413
|
+
if (userJson) {
|
|
1414
|
+
try { this._user = JSON.parse(userJson); } catch { this._user = null; }
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
get token() { return this._token; }
|
|
1419
|
+
get refreshToken() { return this._refreshToken; }
|
|
1420
|
+
get expiresAt() { return this._expiresAt; }
|
|
1421
|
+
get user() { return this._user; }
|
|
1422
|
+
|
|
1423
|
+
isTokenExpired(bufferSeconds = 300) {
|
|
1424
|
+
if (!this._token || !this._expiresAt) return true;
|
|
1425
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1426
|
+
return now >= (this._expiresAt - bufferSeconds);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
setToken(token, refreshToken, expiresIn) {
|
|
1430
|
+
this._token = token;
|
|
1431
|
+
this._refreshToken = refreshToken || null;
|
|
1432
|
+
if (expiresIn) {
|
|
1433
|
+
this._expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
|
|
1434
|
+
}
|
|
1435
|
+
if (this.storage) {
|
|
1436
|
+
this.storage.setItem(TOKEN_KEY, token);
|
|
1437
|
+
if (refreshToken) this.storage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
1438
|
+
if (this._expiresAt) this.storage.setItem(EXPIRES_AT_KEY, this._expiresAt.toString());
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
setUser(user) {
|
|
1443
|
+
this._user = user;
|
|
1444
|
+
if (this.storage) {
|
|
1445
|
+
this.storage.setItem(USER_KEY, JSON.stringify(user));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
clear() {
|
|
1450
|
+
this._token = null;
|
|
1451
|
+
this._refreshToken = null;
|
|
1452
|
+
this._expiresAt = null;
|
|
1453
|
+
this._user = null;
|
|
1454
|
+
if (this.storage) {
|
|
1455
|
+
this.storage.removeItem(TOKEN_KEY);
|
|
1456
|
+
this.storage.removeItem(REFRESH_TOKEN_KEY);
|
|
1457
|
+
this.storage.removeItem(EXPIRES_AT_KEY);
|
|
1458
|
+
this.storage.removeItem(USER_KEY);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* HTTP Client - handles API requests
|
|
1465
|
+
*/
|
|
1466
|
+
class HttpClient {
|
|
1467
|
+
constructor(baseUrl, storage) {
|
|
1468
|
+
this.baseUrl = baseUrl;
|
|
1469
|
+
this.storage = storage;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async request(method, path, body = null, requiresAuth = true) {
|
|
1473
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1474
|
+
|
|
1475
|
+
if (requiresAuth && this.storage.token) {
|
|
1476
|
+
headers['Authorization'] = \`Bearer \${this.storage.token}\`;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const options = { method, headers };
|
|
1480
|
+
if (body && method !== 'GET') {
|
|
1481
|
+
options.body = JSON.stringify(body);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const response = await fetch(\`\${this.baseUrl}\${path}\`, options);
|
|
1485
|
+
const data = await response.json();
|
|
1486
|
+
|
|
1487
|
+
if (!response.ok) {
|
|
1488
|
+
return { error: data.error || 'Request failed' };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return data;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
get(path, requiresAuth = true) { return this.request('GET', path, null, requiresAuth); }
|
|
1495
|
+
post(path, body, requiresAuth = true) { return this.request('POST', path, body, requiresAuth); }
|
|
1496
|
+
put(path, body, requiresAuth = true) { return this.request('PUT', path, body, requiresAuth); }
|
|
1497
|
+
delete(path, requiresAuth = true) { return this.request('DELETE', path, null, requiresAuth); }
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Auth Module - handles authentication
|
|
1502
|
+
*/
|
|
1503
|
+
class Auth {
|
|
1504
|
+
constructor(http, storage) {
|
|
1505
|
+
this.http = http;
|
|
1506
|
+
this.storage = storage;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async sendOtp(email) {
|
|
1510
|
+
const response = await this.http.post('/auth/send-otp', { email }, false);
|
|
1511
|
+
if (response.error) return { success: false, error: response.error };
|
|
1512
|
+
return { success: true, message: response.message || 'OTP sent' };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async verifyOtp(email, otp) {
|
|
1516
|
+
const response = await this.http.post('/auth/verify-otp', { email, otp }, false);
|
|
1517
|
+
if (response.error) return { success: false, error: response.error };
|
|
1518
|
+
|
|
1519
|
+
if (response.accessToken || response.token) {
|
|
1520
|
+
const token = response.accessToken || response.token;
|
|
1521
|
+
this.storage.setToken(token, response.refreshToken || response.refresh_token, response.expires_in || 3600);
|
|
1522
|
+
}
|
|
1523
|
+
if (response.user) {
|
|
1524
|
+
this.storage.setUser(response.user);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return {
|
|
1528
|
+
success: true,
|
|
1529
|
+
token: response.accessToken || response.token,
|
|
1530
|
+
refresh_token: response.refreshToken || response.refresh_token,
|
|
1531
|
+
expires_in: response.expires_in,
|
|
1532
|
+
user: response.user
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
setToken(token) { this.storage.setToken(token); }
|
|
1537
|
+
getToken() { return this.storage.token; }
|
|
1538
|
+
getUser() { return this.storage.user; }
|
|
1539
|
+
isAuthenticated() { return !!this.storage.token; }
|
|
1540
|
+
isTokenExpired(bufferSeconds = 300) { return this.storage.isTokenExpired(bufferSeconds); }
|
|
1541
|
+
getExpiresAt() { return this.storage.expiresAt; }
|
|
1542
|
+
|
|
1543
|
+
async refreshSession() {
|
|
1544
|
+
const refreshToken = this.storage.refreshToken;
|
|
1545
|
+
if (!refreshToken) return { success: false, error: 'No refresh token available' };
|
|
1546
|
+
|
|
1547
|
+
const response = await this.http.post('/auth/refresh-token', { refreshToken }, false);
|
|
1548
|
+
if (response.error) {
|
|
1549
|
+
this.storage.clear();
|
|
1550
|
+
return { success: false, error: response.error };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (response.accessToken || response.token) {
|
|
1554
|
+
const token = response.accessToken || response.token;
|
|
1555
|
+
this.storage.setToken(token, response.refreshToken || refreshToken, response.expires_in || 3600);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
return { success: true, token: response.accessToken || response.token };
|
|
1559
|
+
}
|
|
955
1560
|
|
|
956
|
-
|
|
1561
|
+
async ensureValidToken() {
|
|
1562
|
+
if (!this.storage.token) return false;
|
|
1563
|
+
if (!this.isTokenExpired()) return true;
|
|
1564
|
+
const result = await this.refreshSession();
|
|
1565
|
+
return result.success;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
logout() { this.storage.clear(); }
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Query Builder - handles CRUD operations for a table
|
|
1573
|
+
*/
|
|
1574
|
+
class QueryBuilder {
|
|
1575
|
+
constructor(http, table, tableSchemas) {
|
|
1576
|
+
this.http = http;
|
|
1577
|
+
this.table = table;
|
|
1578
|
+
this.tableSchemas = tableSchemas;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
async schema() {
|
|
1582
|
+
// Return from local schema (self-hosted)
|
|
1583
|
+
const tableSchema = this.tableSchemas.find(t => t.slug === this.table);
|
|
1584
|
+
if (!tableSchema) throw new Error('Table not found');
|
|
1585
|
+
return tableSchema;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async fields() {
|
|
1589
|
+
const tableSchema = await this.schema();
|
|
1590
|
+
return tableSchema.fields || [];
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async list(options = {}) {
|
|
1594
|
+
const { filter, sort, limit = 50, offset = 0 } = options;
|
|
1595
|
+
const params = new URLSearchParams();
|
|
1596
|
+
if (filter) params.set('filter', JSON.stringify(filter));
|
|
1597
|
+
if (sort) params.set('sort', sort);
|
|
1598
|
+
params.set('limit', limit.toString());
|
|
1599
|
+
params.set('offset', offset.toString());
|
|
1600
|
+
|
|
1601
|
+
const queryString = params.toString();
|
|
1602
|
+
const path = \`/\${this.table}\${queryString ? '?' + queryString : ''}\`;
|
|
1603
|
+
const response = await this.http.get(path);
|
|
1604
|
+
|
|
1605
|
+
if (response.error) throw new Error(response.error);
|
|
1606
|
+
|
|
1607
|
+
const data = Array.isArray(response.data) ? response.data : [];
|
|
1608
|
+
return { data, count: response.count ?? data.length };
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
async get(id) {
|
|
1612
|
+
const response = await this.http.get(\`/\${this.table}/\${id}\`);
|
|
1613
|
+
if (response.error) throw new Error(response.error);
|
|
1614
|
+
return response.data;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async create(data) {
|
|
1618
|
+
const response = await this.http.post(\`/\${this.table}\`, data);
|
|
1619
|
+
if (response.error) throw new Error(response.error);
|
|
1620
|
+
return response.data;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
async update(id, data) {
|
|
1624
|
+
const response = await this.http.put(\`/\${this.table}/\${id}\`, data);
|
|
1625
|
+
if (response.error) throw new Error(response.error);
|
|
1626
|
+
return response.data;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async delete(id) {
|
|
1630
|
+
const response = await this.http.delete(\`/\${this.table}/\${id}\`);
|
|
1631
|
+
if (response.error) throw new Error(response.error);
|
|
1632
|
+
return response.success || true;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async findFirst(filter) {
|
|
1636
|
+
const result = await this.list({ filter, limit: 1 });
|
|
1637
|
+
return result.data[0] || null;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async count(filter) {
|
|
1641
|
+
const result = await this.list({ filter, limit: 0 });
|
|
1642
|
+
return result.count;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* ThinkSoft Client - Main SDK class
|
|
1648
|
+
* Compatible with @thinksoftai/sdk interface
|
|
1649
|
+
*/
|
|
1650
|
+
class ThinkSoft {
|
|
1651
|
+
constructor(config) {
|
|
1652
|
+
if (!config.appId) {
|
|
1653
|
+
throw new Error('appId is required');
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
this.config = {
|
|
1657
|
+
appId: config.appId,
|
|
1658
|
+
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
|
|
1659
|
+
token: config.token || '',
|
|
1660
|
+
storage: config.storage || 'localStorage'
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
// Table schemas (generated from export)
|
|
1664
|
+
this.tableSchemas = ${JSON.stringify(tableSchemas, null, 2).split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')};
|
|
1665
|
+
|
|
1666
|
+
// Initialize storage
|
|
1667
|
+
this.storage = new TokenStorage(this.config.storage);
|
|
1668
|
+
|
|
1669
|
+
// Set initial token if provided
|
|
1670
|
+
if (this.config.token) {
|
|
1671
|
+
this.storage.setToken(this.config.token);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Initialize HTTP client
|
|
1675
|
+
this.http = new HttpClient(this.config.baseUrl, this.storage);
|
|
1676
|
+
|
|
1677
|
+
// Initialize modules
|
|
1678
|
+
this.auth = new Auth(this.http, this.storage);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async tables() {
|
|
1682
|
+
return this.tableSchemas.map(t => ({
|
|
1683
|
+
id: t.slug,
|
|
1684
|
+
slug: t.slug,
|
|
1685
|
+
name: t.name,
|
|
1686
|
+
fieldCount: (t.fields || []).length
|
|
1687
|
+
}));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
from(table) {
|
|
1691
|
+
return new QueryBuilder(this.http, table, this.tableSchemas);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
getAppId() { return this.config.appId; }
|
|
1695
|
+
getBaseUrl() { return this.config.baseUrl; }
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// ES Module export
|
|
1699
|
+
export { ThinkSoft, Auth, QueryBuilder, TokenStorage };
|
|
1700
|
+
export default ThinkSoft;
|
|
1701
|
+
|
|
1702
|
+
// CommonJS compatibility
|
|
1703
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1704
|
+
module.exports = { ThinkSoft, Auth, QueryBuilder, TokenStorage, default: ThinkSoft };
|
|
1705
|
+
}
|
|
957
1706
|
`;
|
|
958
|
-
return code;
|
|
959
1707
|
}
|
|
960
1708
|
function generateEvents(tables) {
|
|
1709
|
+
const tableListeners = (tables || []).map(t => `
|
|
1710
|
+
// ${t.name} events
|
|
1711
|
+
eventBus.on('${t.slug}.created', async ({ new: record }) => {
|
|
1712
|
+
console.log('📝 ${t.name} created:', record.id);
|
|
1713
|
+
await processNotifications('${t.slug}', 'created', record);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
eventBus.on('${t.slug}.updated', async ({ old: oldRecord, new: newRecord }) => {
|
|
1717
|
+
console.log('✏️ ${t.name} updated:', newRecord.id);
|
|
1718
|
+
await processNotifications('${t.slug}', 'updated', newRecord, oldRecord);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
eventBus.on('${t.slug}.deleted', async ({ old: record }) => {
|
|
1722
|
+
console.log('🗑️ ${t.name} deleted:', record.id);
|
|
1723
|
+
await processNotifications('${t.slug}', 'deleted', record);
|
|
1724
|
+
});
|
|
1725
|
+
`).join('\n');
|
|
961
1726
|
return `/**
|
|
962
1727
|
* Event Bus
|
|
963
1728
|
* Auto-generated by ThinkSoft CLI
|
|
1729
|
+
*
|
|
1730
|
+
* Events emitted:
|
|
1731
|
+
* - {table}.created - { new: record }
|
|
1732
|
+
* - {table}.updated - { old: oldRecord, new: newRecord }
|
|
1733
|
+
* - {table}.deleted - { old: record }
|
|
964
1734
|
*/
|
|
965
1735
|
|
|
966
1736
|
const EventEmitter = require('events');
|
|
967
1737
|
const eventBus = new EventEmitter();
|
|
968
1738
|
|
|
969
|
-
//
|
|
970
|
-
|
|
1739
|
+
// Notification queue (for external actions like emails, webhooks)
|
|
1740
|
+
const notificationQueue = [];
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Process notifications for an event
|
|
1744
|
+
* This handles email, webhook, and other external actions
|
|
1745
|
+
*/
|
|
1746
|
+
async function processNotifications(table, action, record, oldRecord = null) {
|
|
1747
|
+
// Load notification handlers from config
|
|
1748
|
+
const handlers = getNotificationHandlers(table, action);
|
|
1749
|
+
|
|
1750
|
+
for (const handler of handlers) {
|
|
1751
|
+
try {
|
|
1752
|
+
switch (handler.type) {
|
|
1753
|
+
case 'email':
|
|
1754
|
+
await sendEmailNotification(handler, record, oldRecord);
|
|
1755
|
+
break;
|
|
1756
|
+
case 'webhook':
|
|
1757
|
+
await sendWebhook(handler, record, action, oldRecord);
|
|
1758
|
+
break;
|
|
1759
|
+
case 'log':
|
|
1760
|
+
console.log(\`[Notification] \${handler.message || table + '.' + action}\`, record.id);
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
console.error(\`Notification handler error (\${handler.type}):\`, error.message);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Get notification handlers for a table/action
|
|
1771
|
+
* Override this to customize notification behavior
|
|
1772
|
+
*/
|
|
1773
|
+
function getNotificationHandlers(table, action) {
|
|
1774
|
+
// Default: no notifications
|
|
1775
|
+
// Add your notification rules here
|
|
1776
|
+
return [];
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Send email notification
|
|
1781
|
+
*/
|
|
1782
|
+
async function sendEmailNotification(handler, record, oldRecord) {
|
|
1783
|
+
// ThinkSoft email proxy or custom implementation
|
|
1784
|
+
const emailEndpoint = process.env.EMAIL_ENDPOINT || 'https://developerapi-ir6zo3lfxq-uc.a.run.app/api/v1/email/send';
|
|
1785
|
+
|
|
1786
|
+
if (!handler.to) return;
|
|
1787
|
+
|
|
1788
|
+
const to = typeof handler.to === 'function' ? handler.to(record) : handler.to;
|
|
1789
|
+
const subject = typeof handler.subject === 'function' ? handler.subject(record) : handler.subject;
|
|
1790
|
+
const body = typeof handler.body === 'function' ? handler.body(record) : handler.body;
|
|
1791
|
+
|
|
1792
|
+
// Queue the email (implement your email sending logic)
|
|
1793
|
+
console.log(\`📧 Email queued: to=\${to}, subject=\${subject}\`);
|
|
1794
|
+
|
|
1795
|
+
// If using ThinkSoft email proxy:
|
|
1796
|
+
// await fetch(emailEndpoint, { method: 'POST', body: JSON.stringify({ to, subject, body }) });
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Send webhook notification
|
|
1801
|
+
*/
|
|
1802
|
+
async function sendWebhook(handler, record, action, oldRecord) {
|
|
1803
|
+
if (!handler.url) return;
|
|
1804
|
+
|
|
1805
|
+
try {
|
|
1806
|
+
const payload = {
|
|
1807
|
+
event: \`\${action}\`,
|
|
1808
|
+
timestamp: new Date().toISOString(),
|
|
1809
|
+
data: record,
|
|
1810
|
+
...(oldRecord && { previousData: oldRecord })
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
const response = await fetch(handler.url, {
|
|
1814
|
+
method: 'POST',
|
|
1815
|
+
headers: {
|
|
1816
|
+
'Content-Type': 'application/json',
|
|
1817
|
+
...(handler.headers || {}),
|
|
1818
|
+
...(handler.secret && { 'X-Webhook-Secret': handler.secret })
|
|
1819
|
+
},
|
|
1820
|
+
body: JSON.stringify(payload)
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
if (!response.ok) {
|
|
1824
|
+
console.error(\`Webhook failed: \${response.status}\`);
|
|
1825
|
+
}
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
console.error('Webhook error:', error.message);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Table event listeners
|
|
1832
|
+
${tableListeners}
|
|
971
1833
|
|
|
972
1834
|
module.exports = eventBus;
|
|
1835
|
+
module.exports.processNotifications = processNotifications;
|
|
1836
|
+
module.exports.getNotificationHandlers = getNotificationHandlers;
|
|
973
1837
|
`;
|
|
974
1838
|
}
|
|
975
|
-
function generateIndex(baseUrl) {
|
|
1839
|
+
function generateIndex(baseUrl, authOption = 'none') {
|
|
1840
|
+
const authImport = authOption !== 'none' ? "const authRoutes = require('./routes/auth');\n" : '';
|
|
1841
|
+
const authRoute = authOption !== 'none' ? `app.use('${baseUrl}/auth', authRoutes);\n` : '';
|
|
976
1842
|
return `/**
|
|
977
1843
|
* Server Entry Point
|
|
978
1844
|
* Auto-generated by ThinkSoft CLI
|
|
@@ -983,7 +1849,7 @@ const express = require('express');
|
|
|
983
1849
|
const cors = require('cors');
|
|
984
1850
|
const routes = require('./routes');
|
|
985
1851
|
const agentRoutes = require('./routes/agents');
|
|
986
|
-
|
|
1852
|
+
${authImport}
|
|
987
1853
|
const app = express();
|
|
988
1854
|
const PORT = process.env.PORT || 3000;
|
|
989
1855
|
|
|
@@ -993,7 +1859,7 @@ app.use(express.json());
|
|
|
993
1859
|
// Routes
|
|
994
1860
|
app.use('${baseUrl}', routes);
|
|
995
1861
|
app.use('${baseUrl}/agent', agentRoutes);
|
|
996
|
-
|
|
1862
|
+
${authRoute}
|
|
997
1863
|
// Health check
|
|
998
1864
|
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
999
1865
|
|
|
@@ -1029,6 +1895,10 @@ function generateAuthMiddleware() {
|
|
|
1029
1895
|
|
|
1030
1896
|
const jwt = require('jsonwebtoken');
|
|
1031
1897
|
|
|
1898
|
+
/**
|
|
1899
|
+
* Strict authentication - fails if no valid token
|
|
1900
|
+
* Use for protected endpoints that always require login
|
|
1901
|
+
*/
|
|
1032
1902
|
const authenticate = (req, res, next) => {
|
|
1033
1903
|
const authHeader = req.headers.authorization;
|
|
1034
1904
|
|
|
@@ -1047,7 +1917,276 @@ const authenticate = (req, res, next) => {
|
|
|
1047
1917
|
}
|
|
1048
1918
|
};
|
|
1049
1919
|
|
|
1050
|
-
|
|
1920
|
+
/**
|
|
1921
|
+
* Optional authentication - sets req.user if token present, but doesn't fail
|
|
1922
|
+
* Use for endpoints that may be public or authenticated based on agent config
|
|
1923
|
+
*/
|
|
1924
|
+
const optionalAuth = (req, res, next) => {
|
|
1925
|
+
const authHeader = req.headers.authorization;
|
|
1926
|
+
|
|
1927
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
1928
|
+
// No token, but that's ok - continue without user
|
|
1929
|
+
req.user = null;
|
|
1930
|
+
return next();
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
const token = authHeader.split(' ')[1];
|
|
1934
|
+
|
|
1935
|
+
try {
|
|
1936
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
1937
|
+
req.user = decoded;
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
// Invalid token, continue without user
|
|
1940
|
+
req.user = null;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
next();
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
module.exports = { authenticate, optionalAuth };
|
|
1947
|
+
`;
|
|
1948
|
+
}
|
|
1949
|
+
function generateAuthController(app, authOption) {
|
|
1950
|
+
const appId = app?.id || 'APP_ID';
|
|
1951
|
+
const appName = app?.name || 'App';
|
|
1952
|
+
const emailSendCode = authOption === 'thinksoft'
|
|
1953
|
+
? `// Send OTP via ThinkSoft email proxy (no setup needed)
|
|
1954
|
+
const response = await fetch('https://developerapi-ir6zo3lfxq-uc.a.run.app/api/v1/email/send-otp', {
|
|
1955
|
+
method: 'POST',
|
|
1956
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1957
|
+
body: JSON.stringify({
|
|
1958
|
+
appId: process.env.THINKSOFT_APP_ID || '${appId}',
|
|
1959
|
+
email,
|
|
1960
|
+
otp,
|
|
1961
|
+
appName: process.env.APP_NAME || '${appName}',
|
|
1962
|
+
expiresInMinutes: 10
|
|
1963
|
+
})
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
if (!response.ok) {
|
|
1967
|
+
const error = await response.json();
|
|
1968
|
+
throw new Error(error.error || 'Failed to send OTP');
|
|
1969
|
+
}`
|
|
1970
|
+
: `// Send OTP via self-hosted email (requires RESEND_API_KEY)
|
|
1971
|
+
const { Resend } = require('resend');
|
|
1972
|
+
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
1973
|
+
|
|
1974
|
+
await resend.emails.send({
|
|
1975
|
+
from: process.env.EMAIL_FROM || 'noreply@example.com',
|
|
1976
|
+
to: email,
|
|
1977
|
+
subject: \`\${otp} is your verification code\`,
|
|
1978
|
+
html: \`<h1>Your verification code</h1><p style="font-size:36px;font-weight:bold">\${otp}</p><p>This code expires in 10 minutes.</p>\`
|
|
1979
|
+
});`;
|
|
1980
|
+
return `/**
|
|
1981
|
+
* Authentication Controller
|
|
1982
|
+
* Auto-generated by ThinkSoft CLI
|
|
1983
|
+
* Auth type: ${authOption}
|
|
1984
|
+
*/
|
|
1985
|
+
|
|
1986
|
+
const db = require('../db');
|
|
1987
|
+
const jwt = require('jsonwebtoken');
|
|
1988
|
+
|
|
1989
|
+
// Generate 6-digit OTP
|
|
1990
|
+
function generateOtp() {
|
|
1991
|
+
return Math.floor(100000 + Math.random() * 900000).toString();
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// Send OTP to email
|
|
1995
|
+
async function sendOtp(req, res) {
|
|
1996
|
+
try {
|
|
1997
|
+
const { email } = req.body;
|
|
1998
|
+
|
|
1999
|
+
if (!email) {
|
|
2000
|
+
return res.status(400).json({ error: 'Email is required' });
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Validate email format
|
|
2004
|
+
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
|
|
2005
|
+
if (!emailRegex.test(email)) {
|
|
2006
|
+
return res.status(400).json({ error: 'Invalid email format' });
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const otp = generateOtp();
|
|
2010
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
|
2011
|
+
|
|
2012
|
+
// Create or update user with OTP
|
|
2013
|
+
await db.query(\`
|
|
2014
|
+
INSERT INTO app_users (email, otp_code, otp_expires_at)
|
|
2015
|
+
VALUES ($1, $2, $3)
|
|
2016
|
+
ON CONFLICT (email) DO UPDATE SET
|
|
2017
|
+
otp_code = $2,
|
|
2018
|
+
otp_expires_at = $3,
|
|
2019
|
+
updated_at = NOW()
|
|
2020
|
+
\`, [email, otp, expiresAt]);
|
|
2021
|
+
|
|
2022
|
+
${emailSendCode}
|
|
2023
|
+
|
|
2024
|
+
res.json({ success: true, message: 'OTP sent to email' });
|
|
2025
|
+
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
console.error('Send OTP error:', error);
|
|
2028
|
+
res.status(500).json({ error: 'Failed to send OTP' });
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Verify OTP and return tokens
|
|
2033
|
+
async function verifyOtp(req, res) {
|
|
2034
|
+
try {
|
|
2035
|
+
const { email, otp } = req.body;
|
|
2036
|
+
|
|
2037
|
+
if (!email || !otp) {
|
|
2038
|
+
return res.status(400).json({ error: 'Email and OTP are required' });
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// Find user with valid OTP
|
|
2042
|
+
const { rows } = await db.query(\`
|
|
2043
|
+
SELECT * FROM app_users
|
|
2044
|
+
WHERE email = $1 AND otp_code = $2 AND otp_expires_at > NOW()
|
|
2045
|
+
\`, [email, otp]);
|
|
2046
|
+
|
|
2047
|
+
if (rows.length === 0) {
|
|
2048
|
+
return res.status(401).json({ error: 'Invalid or expired OTP' });
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const user = rows[0];
|
|
2052
|
+
|
|
2053
|
+
// Generate tokens
|
|
2054
|
+
const accessToken = jwt.sign(
|
|
2055
|
+
{ userId: user.id, email: user.email },
|
|
2056
|
+
process.env.JWT_SECRET,
|
|
2057
|
+
{ expiresIn: '1h' }
|
|
2058
|
+
);
|
|
2059
|
+
|
|
2060
|
+
const refreshToken = jwt.sign(
|
|
2061
|
+
{ userId: user.id, type: 'refresh' },
|
|
2062
|
+
process.env.JWT_SECRET,
|
|
2063
|
+
{ expiresIn: '7d' }
|
|
2064
|
+
);
|
|
2065
|
+
|
|
2066
|
+
// Clear OTP and save refresh token
|
|
2067
|
+
await db.query(\`
|
|
2068
|
+
UPDATE app_users SET
|
|
2069
|
+
otp_code = NULL,
|
|
2070
|
+
otp_expires_at = NULL,
|
|
2071
|
+
refresh_token = $2,
|
|
2072
|
+
last_login = NOW(),
|
|
2073
|
+
updated_at = NOW()
|
|
2074
|
+
WHERE id = $1
|
|
2075
|
+
\`, [user.id, refreshToken]);
|
|
2076
|
+
|
|
2077
|
+
res.json({
|
|
2078
|
+
success: true,
|
|
2079
|
+
user: {
|
|
2080
|
+
id: user.id,
|
|
2081
|
+
email: user.email,
|
|
2082
|
+
name: user.name
|
|
2083
|
+
},
|
|
2084
|
+
accessToken,
|
|
2085
|
+
refreshToken
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
console.error('Verify OTP error:', error);
|
|
2090
|
+
res.status(500).json({ error: 'Authentication failed' });
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Refresh access token
|
|
2095
|
+
async function refreshToken(req, res) {
|
|
2096
|
+
try {
|
|
2097
|
+
const { refreshToken } = req.body;
|
|
2098
|
+
|
|
2099
|
+
if (!refreshToken) {
|
|
2100
|
+
return res.status(400).json({ error: 'Refresh token is required' });
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// Verify refresh token
|
|
2104
|
+
let decoded;
|
|
2105
|
+
try {
|
|
2106
|
+
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
|
|
2107
|
+
} catch (err) {
|
|
2108
|
+
return res.status(401).json({ error: 'Invalid refresh token' });
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
if (decoded.type !== 'refresh') {
|
|
2112
|
+
return res.status(401).json({ error: 'Invalid token type' });
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Find user with this refresh token
|
|
2116
|
+
const { rows } = await db.query(
|
|
2117
|
+
'SELECT * FROM app_users WHERE id = $1 AND refresh_token = $2',
|
|
2118
|
+
[decoded.userId, refreshToken]
|
|
2119
|
+
);
|
|
2120
|
+
|
|
2121
|
+
if (rows.length === 0) {
|
|
2122
|
+
return res.status(401).json({ error: 'Token revoked or user not found' });
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const user = rows[0];
|
|
2126
|
+
|
|
2127
|
+
// Generate new access token
|
|
2128
|
+
const accessToken = jwt.sign(
|
|
2129
|
+
{ userId: user.id, email: user.email },
|
|
2130
|
+
process.env.JWT_SECRET,
|
|
2131
|
+
{ expiresIn: '1h' }
|
|
2132
|
+
);
|
|
2133
|
+
|
|
2134
|
+
res.json({ success: true, accessToken });
|
|
2135
|
+
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
console.error('Refresh token error:', error);
|
|
2138
|
+
res.status(500).json({ error: 'Token refresh failed' });
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Get current user
|
|
2143
|
+
async function getMe(req, res) {
|
|
2144
|
+
try {
|
|
2145
|
+
const { rows } = await db.query(
|
|
2146
|
+
'SELECT id, email, name, created_at FROM app_users WHERE id = $1',
|
|
2147
|
+
[req.user.userId]
|
|
2148
|
+
);
|
|
2149
|
+
|
|
2150
|
+
if (rows.length === 0) {
|
|
2151
|
+
return res.status(404).json({ error: 'User not found' });
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
res.json({ user: rows[0] });
|
|
2155
|
+
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
console.error('Get me error:', error);
|
|
2158
|
+
res.status(500).json({ error: 'Failed to get user' });
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
module.exports = {
|
|
2163
|
+
sendOtp,
|
|
2164
|
+
verifyOtp,
|
|
2165
|
+
refreshToken,
|
|
2166
|
+
getMe
|
|
2167
|
+
};
|
|
2168
|
+
`;
|
|
2169
|
+
}
|
|
2170
|
+
function generateAuthRoutes() {
|
|
2171
|
+
return `/**
|
|
2172
|
+
* Authentication Routes
|
|
2173
|
+
* Auto-generated by ThinkSoft CLI
|
|
2174
|
+
*/
|
|
2175
|
+
|
|
2176
|
+
const express = require('express');
|
|
2177
|
+
const router = express.Router();
|
|
2178
|
+
const auth = require('../controllers/auth');
|
|
2179
|
+
const { authenticate } = require('../middleware/auth');
|
|
2180
|
+
|
|
2181
|
+
// Public routes (no auth required)
|
|
2182
|
+
router.post('/send-otp', auth.sendOtp);
|
|
2183
|
+
router.post('/verify-otp', auth.verifyOtp);
|
|
2184
|
+
router.post('/refresh-token', auth.refreshToken);
|
|
2185
|
+
|
|
2186
|
+
// Protected routes
|
|
2187
|
+
router.get('/me', authenticate, auth.getMe);
|
|
2188
|
+
|
|
2189
|
+
module.exports = router;
|
|
1051
2190
|
`;
|
|
1052
2191
|
}
|
|
1053
2192
|
function generateApiConfig(tables, endpoints, config) {
|
|
@@ -1091,6 +2230,9 @@ function generatePackageJson(app, config) {
|
|
|
1091
2230
|
if (config.chatOption === 'openai') {
|
|
1092
2231
|
pkg.dependencies.openai = '^4.20.0';
|
|
1093
2232
|
}
|
|
2233
|
+
if (config.authOption === 'self-hosted') {
|
|
2234
|
+
pkg.dependencies.resend = '^3.0.0';
|
|
2235
|
+
}
|
|
1094
2236
|
return JSON.stringify(pkg, null, 2);
|
|
1095
2237
|
}
|
|
1096
2238
|
function generateDockerCompose(app) {
|
|
@@ -1152,6 +2294,20 @@ NODE_ENV=development
|
|
|
1152
2294
|
JWT_SECRET=your-secret-key-change-in-production
|
|
1153
2295
|
JWT_EXPIRES_IN=7d
|
|
1154
2296
|
`;
|
|
2297
|
+
if (config.authOption === 'thinksoft') {
|
|
2298
|
+
env += `
|
|
2299
|
+
# ThinkSoft Email Proxy (no additional setup needed)
|
|
2300
|
+
THINKSOFT_APP_ID=${config.appId || 'YOUR_APP_ID'}
|
|
2301
|
+
APP_NAME=${config.appName || 'Your App'}
|
|
2302
|
+
`;
|
|
2303
|
+
}
|
|
2304
|
+
else if (config.authOption === 'self-hosted') {
|
|
2305
|
+
env += `
|
|
2306
|
+
# Self-hosted Email (requires Resend account)
|
|
2307
|
+
RESEND_API_KEY=re_...
|
|
2308
|
+
EMAIL_FROM=noreply@yourdomain.com
|
|
2309
|
+
`;
|
|
2310
|
+
}
|
|
1155
2311
|
if (config.chatOption === 'openai') {
|
|
1156
2312
|
env += `
|
|
1157
2313
|
# OpenAI (for AI chat)
|
|
@@ -1225,4 +2381,299 @@ psql -d your_database -f database/triggers.sql
|
|
|
1225
2381
|
\`\`\`
|
|
1226
2382
|
`;
|
|
1227
2383
|
}
|
|
2384
|
+
function generateContext(data, config, endpoints) {
|
|
2385
|
+
const app = data.app || {};
|
|
2386
|
+
const tables = data.tables || [];
|
|
2387
|
+
const agents = data.agents || [];
|
|
2388
|
+
const rules = data.rules || [];
|
|
2389
|
+
const exportDate = new Date().toISOString().split('T')[0];
|
|
2390
|
+
// Generate tables section
|
|
2391
|
+
const tablesSection = tables.map((t) => {
|
|
2392
|
+
const fields = (t.fields || []).map((f) => f.slug).join(', ');
|
|
2393
|
+
return `| ${t.name} | ${t.slug} | ${fields || '-'} |`;
|
|
2394
|
+
}).join('\n');
|
|
2395
|
+
// Generate relationships section
|
|
2396
|
+
const relationships = [];
|
|
2397
|
+
for (const table of tables) {
|
|
2398
|
+
for (const field of table.fields || []) {
|
|
2399
|
+
if (field.type === 'reference' && field.reference_table) {
|
|
2400
|
+
relationships.push(`- \`${table.slug}.${field.slug}\` → \`${field.reference_table}.id\``);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
// Generate field details per table
|
|
2405
|
+
const fieldDetails = tables.map((t) => {
|
|
2406
|
+
const fieldsTable = (t.fields || []).map((f) => {
|
|
2407
|
+
const required = f.required ? '✓' : '';
|
|
2408
|
+
const options = f.options?.length ? f.options.join(', ') : '-';
|
|
2409
|
+
return `| ${f.slug} | ${f.label || f.slug} | ${f.type} | ${required} | ${options} |`;
|
|
2410
|
+
}).join('\n');
|
|
2411
|
+
return `### ${t.name} (\`${t.slug}\`)
|
|
2412
|
+
|
|
2413
|
+
| Field | Label | Type | Required | Options |
|
|
2414
|
+
|-------|-------|------|----------|---------|
|
|
2415
|
+
${fieldsTable || '| - | - | - | - | - |'}`;
|
|
2416
|
+
}).join('\n\n');
|
|
2417
|
+
// Generate business rules section
|
|
2418
|
+
const rulesByTable = {};
|
|
2419
|
+
for (const rule of rules) {
|
|
2420
|
+
const table = rule.table || rule.table_slug || '*';
|
|
2421
|
+
if (!rulesByTable[table])
|
|
2422
|
+
rulesByTable[table] = [];
|
|
2423
|
+
rulesByTable[table].push(rule);
|
|
2424
|
+
}
|
|
2425
|
+
const rulesSection = Object.entries(rulesByTable).map(([table, tableRules]) => {
|
|
2426
|
+
const rulesList = tableRules.map((r) => {
|
|
2427
|
+
const trigger = r.trigger || 'unknown';
|
|
2428
|
+
const actions = (r.actions || []).map((a) => a.type).join(', ');
|
|
2429
|
+
return `| ${r.name} | ${trigger} | ${r.condition || 'true'} | ${actions} |`;
|
|
2430
|
+
}).join('\n');
|
|
2431
|
+
return `#### ${table === '*' ? 'All Tables' : table}
|
|
2432
|
+
|
|
2433
|
+
| Rule | Trigger | Condition | Actions |
|
|
2434
|
+
|------|---------|-----------|---------|
|
|
2435
|
+
${rulesList}`;
|
|
2436
|
+
}).join('\n\n');
|
|
2437
|
+
// Generate agents section
|
|
2438
|
+
const agentsSection = agents.map((a) => {
|
|
2439
|
+
const accessType = a.auth_type || 'authenticated';
|
|
2440
|
+
const permissions = Object.entries(a.permissions || {}).map(([table, perms]) => {
|
|
2441
|
+
const p = [];
|
|
2442
|
+
if (perms.read)
|
|
2443
|
+
p.push('R');
|
|
2444
|
+
if (perms.create)
|
|
2445
|
+
p.push('C');
|
|
2446
|
+
if (perms.update)
|
|
2447
|
+
p.push('U');
|
|
2448
|
+
if (perms.delete)
|
|
2449
|
+
p.push('D');
|
|
2450
|
+
return `${table}:${p.join('')}`;
|
|
2451
|
+
}).join(', ');
|
|
2452
|
+
return `| ${a.name} | ${a.slug} | ${a.audience || '-'} | ${accessType} | ${permissions || 'all'} |`;
|
|
2453
|
+
}).join('\n');
|
|
2454
|
+
// Generate action chips section
|
|
2455
|
+
const actionChipsSection = agents.map((a) => {
|
|
2456
|
+
if (!a.action_chips?.length)
|
|
2457
|
+
return '';
|
|
2458
|
+
const chips = a.action_chips.map((c) => {
|
|
2459
|
+
return ` - **${c.label}**: ${c.action} on \`${c.table || '-'}\` ${c.filter ? `(filter: ${c.filter})` : ''}`;
|
|
2460
|
+
}).join('\n');
|
|
2461
|
+
return `#### ${a.name}\n${chips}`;
|
|
2462
|
+
}).filter(Boolean).join('\n\n');
|
|
2463
|
+
// Generate API endpoints section
|
|
2464
|
+
const apiEndpoints = tables.map((t) => {
|
|
2465
|
+
const path = endpoints[t.slug]?.path || `/${t.slug}`;
|
|
2466
|
+
return `| ${t.name} | \`GET ${config.baseUrl}${path}\` | \`POST ${config.baseUrl}${path}\` | \`PUT ${config.baseUrl}${path}/:id\` | \`DELETE ${config.baseUrl}${path}/:id\` |`;
|
|
2467
|
+
}).join('\n');
|
|
2468
|
+
// Generate env vars section
|
|
2469
|
+
const envVars = [
|
|
2470
|
+
{ name: 'DATABASE_URL', required: true, desc: 'PostgreSQL connection string' },
|
|
2471
|
+
{ name: 'JWT_SECRET', required: true, desc: 'Secret key for JWT tokens' },
|
|
2472
|
+
{ name: 'PORT', required: false, desc: 'Server port (default: 3000)' }
|
|
2473
|
+
];
|
|
2474
|
+
if (config.authOption === 'thinksoft') {
|
|
2475
|
+
envVars.push({ name: 'THINKSOFT_APP_ID', required: false, desc: 'App ID for ThinkSoft email proxy' });
|
|
2476
|
+
envVars.push({ name: 'APP_NAME', required: false, desc: 'App name shown in emails' });
|
|
2477
|
+
}
|
|
2478
|
+
else if (config.authOption === 'self-hosted') {
|
|
2479
|
+
envVars.push({ name: 'RESEND_API_KEY', required: true, desc: 'Resend API key for emails' });
|
|
2480
|
+
envVars.push({ name: 'EMAIL_FROM', required: true, desc: 'Sender email address' });
|
|
2481
|
+
}
|
|
2482
|
+
if (config.chatOption === 'openai') {
|
|
2483
|
+
envVars.push({ name: 'OPENAI_API_KEY', required: true, desc: 'OpenAI API key for chat' });
|
|
2484
|
+
}
|
|
2485
|
+
const envVarsSection = envVars.map(v => `| \`${v.name}\` | ${v.required ? 'Yes' : 'No'} | ${v.desc} |`).join('\n');
|
|
2486
|
+
return `# App Context: ${app.name || 'ThinkSoft App'}
|
|
2487
|
+
|
|
2488
|
+
> Auto-generated context documentation by ThinkSoft CLI
|
|
2489
|
+
> This file helps developers and AI assistants understand the application
|
|
2490
|
+
|
|
2491
|
+
## Overview
|
|
2492
|
+
|
|
2493
|
+
| Property | Value |
|
|
2494
|
+
|----------|-------|
|
|
2495
|
+
| **App Name** | ${app.name || 'Unknown'} |
|
|
2496
|
+
| **App ID** | ${app.id || 'Unknown'} |
|
|
2497
|
+
| **Description** | ${app.description || '-'} |
|
|
2498
|
+
| **Original Platform** | ThinkSoft |
|
|
2499
|
+
| **Export Date** | ${exportDate} |
|
|
2500
|
+
| **Tables** | ${tables.length} |
|
|
2501
|
+
| **Agents** | ${agents.length} |
|
|
2502
|
+
| **Business Rules** | ${rules.length} |
|
|
2503
|
+
|
|
2504
|
+
---
|
|
2505
|
+
|
|
2506
|
+
## Data Model
|
|
2507
|
+
|
|
2508
|
+
### Tables Overview
|
|
2509
|
+
|
|
2510
|
+
| Name | Slug | Key Fields |
|
|
2511
|
+
|------|------|------------|
|
|
2512
|
+
${tablesSection || '| - | - | - |'}
|
|
2513
|
+
|
|
2514
|
+
### Relationships
|
|
2515
|
+
|
|
2516
|
+
${relationships.length > 0 ? relationships.join('\n') : '_No relationships defined_'}
|
|
2517
|
+
|
|
2518
|
+
### Field Definitions
|
|
2519
|
+
|
|
2520
|
+
${fieldDetails || '_No tables defined_'}
|
|
2521
|
+
|
|
2522
|
+
---
|
|
2523
|
+
|
|
2524
|
+
## Business Rules
|
|
2525
|
+
|
|
2526
|
+
Business rules are converted to application hooks in \`src/hooks/\`.
|
|
2527
|
+
|
|
2528
|
+
${rulesSection || '_No business rules defined_'}
|
|
2529
|
+
|
|
2530
|
+
### Rule Triggers
|
|
2531
|
+
|
|
2532
|
+
| Trigger | When it fires |
|
|
2533
|
+
|---------|---------------|
|
|
2534
|
+
| \`before_create\` | Before inserting a new record |
|
|
2535
|
+
| \`after_create\` | After a record is created |
|
|
2536
|
+
| \`before_update\` | Before updating a record |
|
|
2537
|
+
| \`after_update\` | After a record is updated |
|
|
2538
|
+
| \`before_delete\` | Before deleting a record |
|
|
2539
|
+
| \`after_delete\` | After a record is deleted |
|
|
2540
|
+
|
|
2541
|
+
### Action Types
|
|
2542
|
+
|
|
2543
|
+
| Action | Description |
|
|
2544
|
+
|--------|-------------|
|
|
2545
|
+
| \`set_field\` | Set a field to a value |
|
|
2546
|
+
| \`calculate\` | Calculate a field from formula |
|
|
2547
|
+
| \`validate\` | Validate condition, reject if false |
|
|
2548
|
+
| \`reject\` | Reject the operation with message |
|
|
2549
|
+
| \`lookup_and_set\` | Query another table and set field |
|
|
2550
|
+
| \`create_record\` | Create a record in another table |
|
|
2551
|
+
|
|
2552
|
+
---
|
|
2553
|
+
|
|
2554
|
+
## Service Agents
|
|
2555
|
+
|
|
2556
|
+
Agents define access control and user interfaces.
|
|
2557
|
+
|
|
2558
|
+
| Name | Slug | Audience | Access Type | Permissions |
|
|
2559
|
+
|------|------|----------|-------------|-------------|
|
|
2560
|
+
${agentsSection || '| - | - | - | - | - |'}
|
|
2561
|
+
|
|
2562
|
+
### Access Types
|
|
2563
|
+
|
|
2564
|
+
| Type | Description |
|
|
2565
|
+
|------|-------------|
|
|
2566
|
+
| \`public\` | Anyone can access (no login required) |
|
|
2567
|
+
| \`authenticated\` | Must be logged in (exist in \`app_users\`) |
|
|
2568
|
+
| \`table-based\` | Must be logged in AND exist in specific role table |
|
|
2569
|
+
|
|
2570
|
+
### Action Chips
|
|
2571
|
+
|
|
2572
|
+
${actionChipsSection || '_No action chips defined_'}
|
|
2573
|
+
|
|
2574
|
+
---
|
|
2575
|
+
|
|
2576
|
+
## API Reference
|
|
2577
|
+
|
|
2578
|
+
Base URL: \`http://localhost:3000${config.baseUrl}\`
|
|
2579
|
+
|
|
2580
|
+
### Authentication
|
|
2581
|
+
|
|
2582
|
+
| Endpoint | Method | Description |
|
|
2583
|
+
|----------|--------|-------------|
|
|
2584
|
+
| \`/auth/send-otp\` | POST | Send OTP to email |
|
|
2585
|
+
| \`/auth/verify-otp\` | POST | Verify OTP, get tokens |
|
|
2586
|
+
| \`/auth/refresh-token\` | POST | Refresh access token |
|
|
2587
|
+
| \`/auth/me\` | GET | Get current user |
|
|
2588
|
+
|
|
2589
|
+
### CRUD Endpoints
|
|
2590
|
+
|
|
2591
|
+
| Table | List | Create | Update | Delete |
|
|
2592
|
+
|-------|------|--------|--------|--------|
|
|
2593
|
+
${apiEndpoints || '| - | - | - | - | - |'}
|
|
2594
|
+
|
|
2595
|
+
### Agent Endpoints
|
|
2596
|
+
|
|
2597
|
+
| Endpoint | Description |
|
|
2598
|
+
|----------|-------------|
|
|
2599
|
+
| \`/agent/:slug\` | Get agent info and action chips |
|
|
2600
|
+
| \`/agent/:slug/action\` | Execute action chip |
|
|
2601
|
+
| \`/agent/:slug/:table\` | Agent-scoped CRUD |
|
|
2602
|
+
|
|
2603
|
+
---
|
|
2604
|
+
|
|
2605
|
+
## Environment Variables
|
|
2606
|
+
|
|
2607
|
+
| Variable | Required | Description |
|
|
2608
|
+
|----------|----------|-------------|
|
|
2609
|
+
${envVarsSection}
|
|
2610
|
+
|
|
2611
|
+
---
|
|
2612
|
+
|
|
2613
|
+
## File Structure
|
|
2614
|
+
|
|
2615
|
+
\`\`\`
|
|
2616
|
+
├── src/
|
|
2617
|
+
│ ├── index.js # Server entry point
|
|
2618
|
+
│ ├── db.js # Database connection
|
|
2619
|
+
│ ├── controllers/ # CRUD logic per table
|
|
2620
|
+
│ ├── routes/ # API endpoints
|
|
2621
|
+
│ │ ├── index.js # Table routes
|
|
2622
|
+
│ │ ├── agents.js # Agent routes
|
|
2623
|
+
│ │ └── auth.js # Auth routes
|
|
2624
|
+
│ ├── middleware/
|
|
2625
|
+
│ │ ├── auth.js # JWT authentication
|
|
2626
|
+
│ │ └── agentAuth.js # Agent access control
|
|
2627
|
+
│ ├── validators/ # Request validation (Joi)
|
|
2628
|
+
│ ├── hooks/ # Business rules as code
|
|
2629
|
+
│ └── events/ # Event bus & notifications
|
|
2630
|
+
├── agents/
|
|
2631
|
+
│ ├── config.json # Agent configurations
|
|
2632
|
+
│ └── actions.js # Action chip handlers
|
|
2633
|
+
├── client/
|
|
2634
|
+
│ └── api.js # Frontend SDK (ThinkSoft compatible)
|
|
2635
|
+
├── database/
|
|
2636
|
+
│ ├── schema.sql # PostgreSQL schema
|
|
2637
|
+
│ └── triggers.sql # Database triggers
|
|
2638
|
+
├── docker-compose.yml # Docker setup
|
|
2639
|
+
├── Dockerfile
|
|
2640
|
+
├── package.json
|
|
2641
|
+
├── .env.example
|
|
2642
|
+
└── README.md
|
|
2643
|
+
\`\`\`
|
|
2644
|
+
|
|
2645
|
+
---
|
|
2646
|
+
|
|
2647
|
+
## Migration Notes
|
|
2648
|
+
|
|
2649
|
+
### From ThinkSoft Hosted to Self-Hosted
|
|
2650
|
+
|
|
2651
|
+
1. **Export Data**: Use \`thinksoft export-data ${app.id || 'APP_ID'}\` to export records
|
|
2652
|
+
2. **Update Frontend**: Change SDK import or set \`baseUrl\` to self-hosted server
|
|
2653
|
+
3. **DNS**: Point your domain to the self-hosted server
|
|
2654
|
+
4. **Email**: Configure email provider or use ThinkSoft email proxy
|
|
2655
|
+
|
|
2656
|
+
### SDK Compatibility
|
|
2657
|
+
|
|
2658
|
+
The generated \`client/api.js\` is compatible with \`@thinksoftai/sdk\`:
|
|
2659
|
+
|
|
2660
|
+
\`\`\`javascript
|
|
2661
|
+
// ThinkSoft hosted
|
|
2662
|
+
import { ThinkSoft } from '@thinksoftai/sdk';
|
|
2663
|
+
const client = new ThinkSoft({ appId: '${app.id || 'APP_ID'}' });
|
|
2664
|
+
|
|
2665
|
+
// Self-hosted (same code, different import)
|
|
2666
|
+
import { ThinkSoft } from './client/api';
|
|
2667
|
+
const client = new ThinkSoft({ appId: '${app.id || 'APP_ID'}' });
|
|
2668
|
+
|
|
2669
|
+
// Same API works
|
|
2670
|
+
await client.auth.sendOtp('user@example.com');
|
|
2671
|
+
const orders = await client.from('orders').list();
|
|
2672
|
+
\`\`\`
|
|
2673
|
+
|
|
2674
|
+
---
|
|
2675
|
+
|
|
2676
|
+
_Generated by ThinkSoft CLI v1.6.x_
|
|
2677
|
+
`;
|
|
2678
|
+
}
|
|
1228
2679
|
//# sourceMappingURL=export.js.map
|