diff --git a/backend/src/controllers/syncController.ts b/backend/src/controllers/syncController.ts new file mode 100644 index 0000000..9cc1cf7 --- /dev/null +++ b/backend/src/controllers/syncController.ts @@ -0,0 +1,296 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { mainPool } from '../config/database'; + +/** + * GET /api/sync/pull + * Returns all endpoints + folders + database name mappings for CLI sync + */ +export const syncPull = async (req: AuthRequest, res: Response) => { + try { + // Get all endpoints with folder and database names + const endpointsResult = await mainPool.query(` + SELECT + e.*, + f.name as folder_name, + d.name as database_name, + d.type as database_type + FROM endpoints e + LEFT JOIN folders f ON e.folder_id = f.id + LEFT JOIN databases d ON e.database_id = d.id + ORDER BY e.created_at ASC + `); + + // Get all folders with parent info + const foldersResult = await mainPool.query(` + SELECT f.*, pf.name as parent_name + FROM folders f + LEFT JOIN folders pf ON f.parent_id = pf.id + ORDER BY f.created_at ASC + `); + + // Get database name->id mapping (active only) + const databasesResult = await mainPool.query( + 'SELECT id, name, type FROM databases WHERE is_active = true ORDER BY name' + ); + + // Resolve script_queries database names + const endpoints = await Promise.all(endpointsResult.rows.map(async (ep: any) => { + const scriptQueries = ep.script_queries || []; + const resolvedScriptQueries = []; + + for (const sq of scriptQueries) { + let sqDbName: string | null = null; + let sqDbType: string | null = null; + if (sq.database_id) { + const sqDb = databasesResult.rows.find((d: any) => d.id === sq.database_id); + if (sqDb) { + sqDbName = sqDb.name; + sqDbType = sqDb.type; + } + } + resolvedScriptQueries.push({ + ...sq, + database_name: sqDbName, + database_type: sqDbType, + }); + } + + return { + id: ep.id, + name: ep.name, + description: ep.description, + method: ep.method, + path: ep.path, + execution_type: ep.execution_type || 'sql', + database_name: ep.database_name, + database_type: ep.database_type, + database_id: ep.database_id, + sql_query: ep.sql_query, + parameters: ep.parameters || [], + script_language: ep.script_language, + script_code: ep.script_code, + script_queries: resolvedScriptQueries, + aql_method: ep.aql_method, + aql_endpoint: ep.aql_endpoint, + aql_body: ep.aql_body, + aql_query_params: ep.aql_query_params, + is_public: ep.is_public, + enable_logging: ep.enable_logging, + detailed_response: ep.detailed_response, + response_schema: ep.response_schema, + folder_id: ep.folder_id, + folder_name: ep.folder_name, + created_at: ep.created_at, + updated_at: ep.updated_at, + }; + })); + + const folders = foldersResult.rows.map((f: any) => ({ + id: f.id, + name: f.name, + parent_id: f.parent_id, + parent_name: f.parent_name, + created_at: f.created_at, + updated_at: f.updated_at, + })); + + res.json({ + endpoints, + folders, + databases: databasesResult.rows, + server_time: new Date().toISOString(), + }); + } catch (error) { + console.error('Sync pull error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +/** + * POST /api/sync/status + * Accepts {endpoints: [{id, updated_at}], folders: [{id, updated_at}]} + * Returns which items changed on server since the given timestamps + */ +export const syncStatus = async (req: AuthRequest, res: Response) => { + try { + const { endpoints: clientEndpoints, folders: clientFolders } = req.body; + + // Get current server state + const serverEndpoints = await mainPool.query( + 'SELECT id, name, updated_at FROM endpoints' + ); + const serverFolders = await mainPool.query( + 'SELECT id, name, updated_at FROM folders' + ); + + const clientEndpointMap = new Map( + (clientEndpoints || []).map((e: any) => [e.id, e.updated_at]) + ); + const clientFolderMap = new Map( + (clientFolders || []).map((f: any) => [f.id, f.updated_at]) + ); + + // Find changed/new/deleted endpoints + const changedEndpoints: any[] = []; + const newEndpoints: any[] = []; + for (const ep of serverEndpoints.rows) { + const clientUpdatedAt = clientEndpointMap.get(ep.id); + if (!clientUpdatedAt) { + newEndpoints.push({ id: ep.id, name: ep.name }); + } else if (new Date(ep.updated_at).getTime() > new Date(clientUpdatedAt).getTime()) { + changedEndpoints.push({ id: ep.id, name: ep.name, server_updated_at: ep.updated_at }); + } + } + + const serverEndpointIds = new Set(serverEndpoints.rows.map((e: any) => e.id)); + const deletedEndpoints = (clientEndpoints || []) + .filter((e: any) => !serverEndpointIds.has(e.id)) + .map((e: any) => ({ id: e.id })); + + // Find changed/new/deleted folders + const changedFolders: any[] = []; + const newFolders: any[] = []; + for (const f of serverFolders.rows) { + const clientUpdatedAt = clientFolderMap.get(f.id); + if (!clientUpdatedAt) { + newFolders.push({ id: f.id, name: f.name }); + } else if (new Date(f.updated_at).getTime() > new Date(clientUpdatedAt).getTime()) { + changedFolders.push({ id: f.id, name: f.name, server_updated_at: f.updated_at }); + } + } + + const serverFolderIds = new Set(serverFolders.rows.map((f: any) => f.id)); + const deletedFolders = (clientFolders || []) + .filter((f: any) => !serverFolderIds.has(f.id)) + .map((f: any) => ({ id: f.id })); + + res.json({ + endpoints: { + changed: changedEndpoints, + new: newEndpoints, + deleted: deletedEndpoints, + }, + folders: { + changed: changedFolders, + new: newFolders, + deleted: deletedFolders, + }, + }); + } catch (error) { + console.error('Sync status error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +/** + * POST /api/sync/push + * Accepts array of endpoints to create/update with conflict detection + */ +export const syncPush = async (req: AuthRequest, res: Response) => { + try { + const { endpoints: pushEndpoints, force } = req.body; + + if (!pushEndpoints || !Array.isArray(pushEndpoints)) { + return res.status(400).json({ error: 'endpoints array is required' }); + } + + const conflicts: any[] = []; + const results: any[] = []; + + for (const ep of pushEndpoints) { + // Check if endpoint exists + if (ep.id) { + const existing = await mainPool.query( + 'SELECT id, updated_at, name FROM endpoints WHERE id = $1', + [ep.id] + ); + + if (existing.rows.length > 0) { + const serverUpdatedAt = new Date(existing.rows[0].updated_at).getTime(); + const clientBaseUpdatedAt = ep._base_updated_at + ? new Date(ep._base_updated_at).getTime() + : 0; + + // Conflict: server was modified after client's last sync + if (!force && clientBaseUpdatedAt && serverUpdatedAt > clientBaseUpdatedAt) { + conflicts.push({ + id: ep.id, + name: existing.rows[0].name, + server_updated_at: existing.rows[0].updated_at, + client_base_updated_at: ep._base_updated_at, + }); + continue; + } + + // Update existing endpoint + const result = await mainPool.query( + `UPDATE endpoints + SET name = $1, description = $2, method = $3, path = $4, + database_id = $5, sql_query = $6, parameters = $7, + folder_id = $8, is_public = $9, enable_logging = $10, + execution_type = $11, script_language = $12, script_code = $13, + script_queries = $14, aql_method = $15, aql_endpoint = $16, + aql_body = $17, aql_query_params = $18, detailed_response = $19, + response_schema = $20, updated_at = CURRENT_TIMESTAMP + WHERE id = $21 + RETURNING *`, + [ + ep.name, ep.description || '', ep.method, ep.path, + ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []), + ep.folder_id || null, ep.is_public || false, ep.enable_logging || false, + ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null, + JSON.stringify(ep.script_queries || []), ep.aql_method || null, + ep.aql_endpoint || null, ep.aql_body || null, + JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false, + ep.response_schema ? JSON.stringify(ep.response_schema) : null, + ep.id, + ] + ); + results.push({ action: 'updated', endpoint: result.rows[0] }); + } else { + // Endpoint with this ID doesn't exist on server — create it + const result = await mainPool.query( + `INSERT INTO endpoints ( + id, name, description, method, path, database_id, sql_query, parameters, + folder_id, user_id, is_public, enable_logging, + execution_type, script_language, script_code, script_queries, + aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + RETURNING *`, + [ + ep.id, ep.name, ep.description || '', ep.method, ep.path, + ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []), + ep.folder_id || null, req.user!.id, ep.is_public || false, ep.enable_logging || false, + ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null, + JSON.stringify(ep.script_queries || []), ep.aql_method || null, + ep.aql_endpoint || null, ep.aql_body || null, + JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false, + ep.response_schema ? JSON.stringify(ep.response_schema) : null, + ] + ); + results.push({ action: 'created', endpoint: result.rows[0] }); + } + } + } + + if (conflicts.length > 0) { + return res.status(409).json({ + error: 'Conflicts detected', + conflicts, + applied: results, + }); + } + + res.json({ + success: true, + results, + }); + } catch (error: any) { + console.error('Sync push error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Endpoint path already exists' }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts new file mode 100644 index 0000000..54fa770 --- /dev/null +++ b/backend/src/routes/sync.ts @@ -0,0 +1,13 @@ +import express from 'express'; +import { authMiddleware } from '../middleware/auth'; +import { syncPull, syncPush, syncStatus } from '../controllers/syncController'; + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/pull', syncPull); +router.post('/status', syncStatus); +router.post('/push', syncPush); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 014b76c..aa94e72 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -20,6 +20,7 @@ import userRoutes from './routes/users'; import logsRoutes from './routes/logs'; import sqlInterfaceRoutes from './routes/sqlInterface'; import dynamicRoutes from './routes/dynamic'; +import syncRoutes from './routes/sync'; const app: Express = express(); @@ -93,6 +94,7 @@ app.use('/api/db-management', databaseManagementRoutes); app.use('/api/users', userRoutes); app.use('/api/logs', logsRoutes); app.use('/api/workbench', sqlInterfaceRoutes); +app.use('/api/sync', syncRoutes); // Dynamic API routes (user-created endpoints) app.use('/api/v1', dynamicRoutes); diff --git a/cli b/cli new file mode 160000 index 0000000..f899eeb --- /dev/null +++ b/cli @@ -0,0 +1 @@ +Subproject commit f899eeb4efc6acd564c3826e4cebb2d0839b0716