tide-commander 1.87.0 → 1.89.0

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.
Files changed (71) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
  9. package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
  17. package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
  18. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
  21. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
  23. package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
  24. package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
  26. package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
  27. package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
  28. package/dist/assets/index-fZfyvIUZ.js +2 -0
  29. package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
  30. package/dist/assets/index-xEvpFBA8.js +8 -0
  31. package/dist/assets/main-Bw5ZddEN.css +1 -0
  32. package/dist/assets/main-D-YFCprA.js +213 -0
  33. package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
  34. package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
  35. package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  38. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  39. package/dist/src/packages/server/data/event-queries.js +2 -0
  40. package/dist/src/packages/server/data/index.js +56 -2
  41. package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
  42. package/dist/src/packages/server/index.js +2 -1
  43. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  44. package/dist/src/packages/server/integrations/slack/index.js +65 -19
  45. package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
  46. package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
  47. package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
  48. package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
  49. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
  50. package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
  51. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
  52. package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
  53. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  54. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  55. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  56. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
  57. package/dist/src/packages/server/routes/database.js +221 -0
  58. package/dist/src/packages/server/routes/files.js +219 -18
  59. package/dist/src/packages/server/routes/index.js +2 -0
  60. package/dist/src/packages/server/services/building-service.js +41 -0
  61. package/dist/src/packages/server/services/database-service.js +61 -9
  62. package/dist/src/packages/server/services/index.js +1 -0
  63. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  64. package/dist/src/packages/server/websocket/handler.js +2 -1
  65. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  66. package/package.json +3 -1
  67. package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
  68. package/dist/assets/index-BOr_tbLK.js +0 -2
  69. package/dist/assets/index-Co7njQ0Q.js +0 -8
  70. package/dist/assets/main-BrZe9Zbd.js +0 -201
  71. package/dist/assets/main-kpU9m5LW.css +0 -1
@@ -198,6 +198,74 @@ export function createWhatsAppRoutes(ctx) {
198
198
  res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
199
199
  }
200
200
  });
201
+ // ─── POST /sessions/:sessionId/chats/:chatId/sync-messages — Force on-demand history sync for a chat ───
202
+ router.post('/sessions/:sessionId/chats/:chatId/sync-messages', async (req, res) => {
203
+ const built = getClient();
204
+ if ('error' in built) {
205
+ res.status(built.status).json({ error: built.error });
206
+ return;
207
+ }
208
+ const rawCount = typeof req.query.count === 'string' ? Number(req.query.count) : NaN;
209
+ const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.floor(rawCount) : 50;
210
+ try {
211
+ const result = await built.client.syncChatMessages(req.params.sessionId, req.params.chatId, count);
212
+ res.json({ success: true, data: result });
213
+ }
214
+ catch (err) {
215
+ log.error(`WhatsApp syncChatMessages error: ${err}`);
216
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
217
+ }
218
+ });
219
+ // ─── POST /sessions/:sessionId/sync-contacts — Force address-book resync via Baileys app-state ───
220
+ router.post('/sessions/:sessionId/sync-contacts', async (req, res) => {
221
+ const built = getClient();
222
+ if ('error' in built) {
223
+ res.status(built.status).json({ error: built.error });
224
+ return;
225
+ }
226
+ try {
227
+ const result = await built.client.syncContacts(req.params.sessionId);
228
+ res.json({ success: true, data: result });
229
+ }
230
+ catch (err) {
231
+ log.error(`WhatsApp syncContacts error: ${err}`);
232
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
233
+ }
234
+ });
235
+ // ─── GET /sessions/:sessionId/contacts — List contacts for a session ───
236
+ router.get('/sessions/:sessionId/contacts', async (req, res) => {
237
+ const built = getClient();
238
+ if ('error' in built) {
239
+ res.status(built.status).json({ error: built.error });
240
+ return;
241
+ }
242
+ try {
243
+ const result = await built.client.getContacts(req.params.sessionId);
244
+ res.json({ success: true, data: result });
245
+ }
246
+ catch (err) {
247
+ log.error(`WhatsApp getContacts error: ${err}`);
248
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
249
+ }
250
+ });
251
+ // ─── GET /sessions/:sessionId/chats/:chatId/messages — Fetch recent messages for a chat ───
252
+ router.get('/sessions/:sessionId/chats/:chatId/messages', async (req, res) => {
253
+ const built = getClient();
254
+ if ('error' in built) {
255
+ res.status(built.status).json({ error: built.error });
256
+ return;
257
+ }
258
+ const rawLimit = typeof req.query.limit === 'string' ? Number(req.query.limit) : NaN;
259
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : 50;
260
+ try {
261
+ const result = await built.client.getChatMessages(req.params.sessionId, req.params.chatId, limit);
262
+ res.json({ success: true, data: result });
263
+ }
264
+ catch (err) {
265
+ log.error(`WhatsApp getChatMessages error: ${err}`);
266
+ res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
267
+ }
268
+ });
201
269
  // ─── POST /send-message — Send a text message via Baileys ───
202
270
  router.post('/send-message', async (req, res) => {
203
271
  const body = (req.body ?? {});
@@ -13,6 +13,23 @@ import { WhatsAppWsClient } from './whatsapp-ws-client.js';
13
13
  import { MessageDedupeCache } from './message-dedupe.js';
14
14
  import { ContactNameCache } from './contact-name-cache.js';
15
15
  import { WhatsAppClient } from './whatsapp-client.js';
16
+ import { createLogger } from '../../utils/logger.js';
17
+ const log = createLogger('WhatsAppTrigger');
18
+ // Module-level subscriber set fed by the in-process bridge whenever a normalized
19
+ // WhatsApp message is broadcast. The trigger-service-facing handler below
20
+ // registers its callback here, so subscribers stay attached even when the bridge
21
+ // restarts (e.g. on config change).
22
+ const triggerSubscribers = new Set();
23
+ function notifyTriggerSubscribers(payload) {
24
+ for (const cb of triggerSubscribers) {
25
+ try {
26
+ cb(payload);
27
+ }
28
+ catch {
29
+ // Subscriber errors must not break the bridge — swallow.
30
+ }
31
+ }
32
+ }
16
33
  // Dedupe cache config. The upstream whatsapp-api fires BOTH `message` and
17
34
  // `message_create` for the same Baileys event on inbound DMs (see
18
35
  // providers/baileysSessionManager.js around the `messages.upsert` handler — it
@@ -105,6 +122,7 @@ export function createWhatsAppTriggerHandler(ctx) {
105
122
  !!payload.from;
106
123
  if (!needsEnrichment) {
107
124
  ctx.broadcast({ type: 'whatsapp_message', payload });
125
+ notifyTriggerSubscribers(payload);
108
126
  return;
109
127
  }
110
128
  void contactNames.lookup(msg.sessionId, payload.from).then((name) => {
@@ -112,9 +130,11 @@ export function createWhatsAppTriggerHandler(ctx) {
112
130
  if (sanitized)
113
131
  payload.fromName = sanitized;
114
132
  ctx.broadcast({ type: 'whatsapp_message', payload });
133
+ notifyTriggerSubscribers(payload);
115
134
  }, (err) => {
116
135
  ctx.log.warn(`WhatsApp contact lookup failed: ${err}`);
117
136
  ctx.broadcast({ type: 'whatsapp_message', payload });
137
+ notifyTriggerSubscribers(payload);
118
138
  });
119
139
  }
120
140
  // Other event types (message_ack, group_join, group_leave, hello, error) are
@@ -216,6 +236,7 @@ function normalizeBaileysMessage(sessionId, eventName, data, ts, baseUrl) {
216
236
  mediaType,
217
237
  mediaUrl,
218
238
  direction,
239
+ chatId: remoteJid ?? from,
219
240
  };
220
241
  }
221
242
  function extractContent(envelope, message) {
@@ -355,3 +376,109 @@ export function sanitizeFromName(name, fromJid) {
355
376
  }
356
377
  return trimmed;
357
378
  }
379
+ // Chat IDs that represent non-message channels (status updates, broadcast
380
+ // lists). The bridge surfaces them as messages because Baileys delivers
381
+ // status posts via `messages.upsert`, but they're not 1:1 / group chats and
382
+ // most triggers shouldn't fire on them.
383
+ function isStatusOrBroadcastChat(chatId) {
384
+ if (!chatId)
385
+ return false;
386
+ if (chatId === 'status@broadcast')
387
+ return true;
388
+ if (chatId.endsWith('@broadcast'))
389
+ return true;
390
+ return false;
391
+ }
392
+ // Drop presence/typing/group-system events that arrive through the bridge
393
+ // with no body and no media — they fire the agent for nothing. Emoji-only
394
+ // bodies and media-with-empty-caption are real content and pass through.
395
+ export function isEmptyContentMessage(msg) {
396
+ const bodyEmpty = !msg.body || msg.body.trim().length === 0;
397
+ const noMedia = !msg.mediaType && !msg.mediaUrl;
398
+ return bodyEmpty && noMedia;
399
+ }
400
+ let triggerUnsubscribe = null;
401
+ export const whatsappTriggerHandler = {
402
+ triggerType: 'whatsapp',
403
+ async startListening(onEvent) {
404
+ const cb = (msg) => {
405
+ onEvent({
406
+ source: 'whatsapp',
407
+ type: 'message',
408
+ data: msg,
409
+ timestamp: msg.timestamp,
410
+ });
411
+ };
412
+ triggerSubscribers.add(cb);
413
+ triggerUnsubscribe = () => triggerSubscribers.delete(cb);
414
+ },
415
+ async stopListening() {
416
+ if (triggerUnsubscribe) {
417
+ triggerUnsubscribe();
418
+ triggerUnsubscribe = null;
419
+ }
420
+ },
421
+ structuralMatch(trigger, event) {
422
+ const msg = event.data;
423
+ const cfg = trigger.config;
424
+ // Drop status updates and broadcast-list messages by default — they're
425
+ // surfaced through the same bridge as real messages but represent stories /
426
+ // broadcasts, not conversations. Opt-in via includeStatuses.
427
+ if (!cfg.includeStatuses && isStatusOrBroadcastChat(msg.chatId))
428
+ return false;
429
+ if (isEmptyContentMessage(msg)) {
430
+ log.debug(`whatsapp.trigger.skipped reason=empty_content session=${msg.sessionId}`);
431
+ return false;
432
+ }
433
+ if (cfg.direction && cfg.direction !== 'any' && msg.direction !== cfg.direction)
434
+ return false;
435
+ if (cfg.groupOnly && !msg.isGroup)
436
+ return false;
437
+ if (cfg.dmOnly && msg.isGroup)
438
+ return false;
439
+ if (cfg.sessionId && msg.sessionId !== cfg.sessionId)
440
+ return false;
441
+ if (cfg.fromFilter?.length) {
442
+ const fromLower = msg.from.toLowerCase();
443
+ if (!cfg.fromFilter.some(f => fromLower.includes(f.toLowerCase())))
444
+ return false;
445
+ }
446
+ if (cfg.bodyPattern) {
447
+ try {
448
+ if (!new RegExp(cfg.bodyPattern, 'i').test(msg.body))
449
+ return false;
450
+ }
451
+ catch {
452
+ return false;
453
+ }
454
+ }
455
+ return true;
456
+ },
457
+ extractVariables(trigger, event) {
458
+ const msg = event.data;
459
+ void trigger;
460
+ return {
461
+ 'whatsapp.from': msg.from,
462
+ 'whatsapp.fromName': msg.fromName ?? '',
463
+ 'whatsapp.body': msg.body,
464
+ 'whatsapp.sessionId': msg.sessionId,
465
+ 'whatsapp.chatId': msg.chatId,
466
+ 'whatsapp.isGroup': String(msg.isGroup),
467
+ 'whatsapp.groupName': msg.groupName ?? '',
468
+ 'whatsapp.direction': msg.direction,
469
+ 'whatsapp.mediaType': msg.mediaType ?? '',
470
+ 'whatsapp.mediaUrl': msg.mediaUrl ?? '',
471
+ 'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
472
+ };
473
+ },
474
+ formatEventForLLM(event) {
475
+ const msg = event.data;
476
+ const sender = msg.fromName ? `${msg.fromName} (${msg.from})` : msg.from;
477
+ const channel = msg.isGroup ? `group "${msg.groupName ?? msg.from}"` : 'DM';
478
+ const verb = msg.direction === 'outbound' ? 'sent' : 'received';
479
+ const media = msg.mediaType
480
+ ? `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`
481
+ : '';
482
+ return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}${media}\n\n${msg.body}`;
483
+ },
484
+ };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Database Routes
3
+ * REST API endpoints for exploring database buildings.
4
+ *
5
+ * Mirrors the WebSocket database handler operations (test/list/schema/query)
6
+ * over plain HTTP so curl-based agent skills can use them.
7
+ */
8
+ import { Router } from 'express';
9
+ import { databaseService, buildingService } from '../services/index.js';
10
+ import { createLogger } from '../utils/logger.js';
11
+ const log = createLogger('DatabaseRoutes');
12
+ const router = Router();
13
+ const MAX_QUERY_LIMIT = 10000;
14
+ const DEFAULT_QUERY_LIMIT = 1000;
15
+ function getDatabaseBuilding(buildingId) {
16
+ const building = buildingService.getBuilding(buildingId);
17
+ if (!building || building.type !== 'database' || !building.database) {
18
+ return null;
19
+ }
20
+ return building;
21
+ }
22
+ function getConnection(buildingId, connectionId) {
23
+ const building = getDatabaseBuilding(buildingId);
24
+ if (!building)
25
+ return null;
26
+ return building.database.connections.find(c => c.id === connectionId) || null;
27
+ }
28
+ function redactConnection(c) {
29
+ return {
30
+ id: c.id,
31
+ name: c.name,
32
+ engine: c.engine,
33
+ host: c.host,
34
+ port: c.port,
35
+ username: c.username,
36
+ database: c.database,
37
+ filepath: c.filepath,
38
+ ssl: c.ssl,
39
+ hasPassword: Boolean(c.password),
40
+ ssh: c.ssh
41
+ ? { enabled: c.ssh.enabled, host: c.ssh.host, port: c.ssh.port, username: c.ssh.username }
42
+ : undefined,
43
+ };
44
+ }
45
+ // GET /api/database/buildings - list all database buildings (redacted)
46
+ router.get('/buildings', (_req, res) => {
47
+ const buildings = buildingService.getBuildings()
48
+ .filter(b => b.type === 'database' && b.database)
49
+ .map(b => ({
50
+ id: b.id,
51
+ name: b.name,
52
+ activeConnectionId: b.database.activeConnectionId,
53
+ activeDatabase: b.database.activeDatabase,
54
+ connections: b.database.connections.map(redactConnection),
55
+ }));
56
+ res.json({ buildings });
57
+ });
58
+ // GET /api/database/buildings/:buildingId - one database building (redacted)
59
+ router.get('/buildings/:buildingId', (req, res) => {
60
+ const building = getDatabaseBuilding(String(req.params.buildingId));
61
+ if (!building) {
62
+ res.status(404).json({ error: 'Database building not found' });
63
+ return;
64
+ }
65
+ res.json({
66
+ id: building.id,
67
+ name: building.name,
68
+ activeConnectionId: building.database.activeConnectionId,
69
+ activeDatabase: building.database.activeDatabase,
70
+ connections: building.database.connections.map(redactConnection),
71
+ });
72
+ });
73
+ // POST /api/database/buildings/:buildingId/connections/:connectionId/test
74
+ router.post('/buildings/:buildingId/connections/:connectionId/test', async (req, res) => {
75
+ const buildingId = String(req.params.buildingId);
76
+ const connectionId = String(req.params.connectionId);
77
+ const connection = getConnection(buildingId, connectionId);
78
+ if (!connection) {
79
+ res.status(404).json({ error: 'Connection not found' });
80
+ return;
81
+ }
82
+ try {
83
+ const result = await databaseService.testConnection(connection);
84
+ res.json(result);
85
+ }
86
+ catch (err) {
87
+ const message = err instanceof Error ? err.message : 'Unknown error';
88
+ log.error(`testConnection failed for ${buildingId}/${connectionId}:`, message);
89
+ res.status(500).json({ success: false, error: message });
90
+ }
91
+ });
92
+ // GET /api/database/buildings/:buildingId/connections/:connectionId/databases
93
+ router.get('/buildings/:buildingId/connections/:connectionId/databases', async (req, res) => {
94
+ const buildingId = String(req.params.buildingId);
95
+ const connectionId = String(req.params.connectionId);
96
+ const connection = getConnection(buildingId, connectionId);
97
+ if (!connection) {
98
+ res.status(404).json({ error: 'Connection not found' });
99
+ return;
100
+ }
101
+ try {
102
+ const databases = await databaseService.listDatabases(connection);
103
+ res.json({ databases });
104
+ }
105
+ catch (err) {
106
+ const message = err instanceof Error ? err.message : 'Unknown error';
107
+ log.error(`listDatabases failed for ${buildingId}/${connectionId}:`, message);
108
+ res.status(500).json({ error: message });
109
+ }
110
+ });
111
+ // GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables
112
+ router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables', async (req, res) => {
113
+ const buildingId = String(req.params.buildingId);
114
+ const connectionId = String(req.params.connectionId);
115
+ const database = String(req.params.database);
116
+ const connection = getConnection(buildingId, connectionId);
117
+ if (!connection) {
118
+ res.status(404).json({ error: 'Connection not found' });
119
+ return;
120
+ }
121
+ try {
122
+ const tables = await databaseService.listTables(connection, database);
123
+ res.json({ database, tables });
124
+ }
125
+ catch (err) {
126
+ const message = err instanceof Error ? err.message : 'Unknown error';
127
+ log.error(`listTables failed for ${buildingId}/${connectionId}/${database}:`, message);
128
+ res.status(500).json({ error: message });
129
+ }
130
+ });
131
+ // GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/columns
132
+ // Lean variant of /schema — returns only the column list (no indexes / foreign keys).
133
+ router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/columns', async (req, res) => {
134
+ const buildingId = String(req.params.buildingId);
135
+ const connectionId = String(req.params.connectionId);
136
+ const database = String(req.params.database);
137
+ const table = String(req.params.table);
138
+ const connection = getConnection(buildingId, connectionId);
139
+ if (!connection) {
140
+ res.status(404).json({ error: 'Connection not found' });
141
+ return;
142
+ }
143
+ try {
144
+ const { columns } = await databaseService.getTableSchema(connection, database, table);
145
+ res.json({ database, table, columns });
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : 'Unknown error';
149
+ log.error(`describe (columns) failed for ${buildingId}/${connectionId}/${database}/${table}:`, message);
150
+ res.status(500).json({ error: message });
151
+ }
152
+ });
153
+ // GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/schema
154
+ router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/schema', async (req, res) => {
155
+ const buildingId = String(req.params.buildingId);
156
+ const connectionId = String(req.params.connectionId);
157
+ const database = String(req.params.database);
158
+ const table = String(req.params.table);
159
+ const connection = getConnection(buildingId, connectionId);
160
+ if (!connection) {
161
+ res.status(404).json({ error: 'Connection not found' });
162
+ return;
163
+ }
164
+ try {
165
+ const schema = await databaseService.getTableSchema(connection, database, table);
166
+ res.json({ database, table, ...schema });
167
+ }
168
+ catch (err) {
169
+ const message = err instanceof Error ? err.message : 'Unknown error';
170
+ log.error(`getTableSchema failed for ${buildingId}/${connectionId}/${database}/${table}:`, message);
171
+ res.status(500).json({ error: message });
172
+ }
173
+ });
174
+ // POST /api/database/buildings/:buildingId/connections/:connectionId/query
175
+ // Body: { database: string, query: string, limit?: number, recordHistory?: boolean }
176
+ router.post('/buildings/:buildingId/connections/:connectionId/query', async (req, res) => {
177
+ const buildingId = String(req.params.buildingId);
178
+ const connectionId = String(req.params.connectionId);
179
+ const { database, query, limit, recordHistory } = req.body ?? {};
180
+ if (typeof database !== 'string' || !database) {
181
+ res.status(400).json({ error: 'Body field "database" is required' });
182
+ return;
183
+ }
184
+ if (typeof query !== 'string' || !query.trim()) {
185
+ res.status(400).json({ error: 'Body field "query" is required' });
186
+ return;
187
+ }
188
+ const connection = getConnection(buildingId, connectionId);
189
+ if (!connection) {
190
+ res.status(404).json({ error: 'Connection not found' });
191
+ return;
192
+ }
193
+ const effectiveLimit = Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : DEFAULT_QUERY_LIMIT), MAX_QUERY_LIMIT);
194
+ try {
195
+ const result = await databaseService.executeQuery(connection, database, query, effectiveLimit);
196
+ if (recordHistory && result.status === 'success') {
197
+ databaseService.addToHistory(buildingId, result);
198
+ }
199
+ const status = result.status === 'success' ? 200 : 400;
200
+ res.status(status).json(result);
201
+ }
202
+ catch (err) {
203
+ const message = err instanceof Error ? err.message : 'Unknown error';
204
+ log.error(`executeQuery failed for ${buildingId}/${connectionId}:`, message);
205
+ res.status(500).json({ status: 'error', error: message });
206
+ }
207
+ });
208
+ // GET /api/database/buildings/:buildingId/history?limit=100
209
+ router.get('/buildings/:buildingId/history', (req, res) => {
210
+ const buildingId = String(req.params.buildingId);
211
+ const building = getDatabaseBuilding(buildingId);
212
+ if (!building) {
213
+ res.status(404).json({ error: 'Database building not found' });
214
+ return;
215
+ }
216
+ const rawLimit = Number(req.query.limit);
217
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 500) : 100;
218
+ const history = databaseService.getHistory(buildingId, limit);
219
+ res.json({ history });
220
+ });
221
+ export default router;