EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
hosting.js
Go to the documentation of this file.
1/**
2 * @file hosting.js
3 * @module HostingRoutes
4 * @description DigitalOcean hosting management API routes. Manages App Platform applications, Droplets (VMs), and managed databases through DigitalOcean API integration. Supports configuration management, resource synchronization, deployment operations, and multi-tenant isolation.
5 * @see {@link ../services/digitalOceanService} for DigitalOcean API integration
6 * @see {@link ../db} for database connection
7 * @see {@link ../middleware/auth} for authentication middleware
8 * @see {@link ../middleware/tenant} for tenant context utilities
9 * @apiDefine HostingGroup Hosting
10 * @apiGroup Hosting
11 * @apiHeader {string} Authorization Bearer token required.
12 * @apiHeader {string} X-Tenant-ID Tenant context header required.
13 * @apiError (Error 401) Unauthorized Missing or invalid token.
14 * @apiError (Error 403) Forbidden Tenant context missing or invalid.
15 * @apiError (Error 404) NotFound Configuration or resource not found.
16 * @apiError (Error 500) ServerError Internal server error or DigitalOcean API error.
17 */
18
19const express = require('express');
20const router = express.Router();
21const authenticateToken = require('../middleware/auth');
22const { setTenantContext } = require('../middleware/tenant');
23const DigitalOceanService = require('../services/digitalOceanService');
24const pool = require('../db');
25
26// Apply authentication and tenant context to all routes
27router.use(authenticateToken, setTenantContext);
28
29// ===========================
30// Configuration Management
31// ===========================
32
33/**
34 * @api {get} /hosting/config Get DigitalOcean configuration
35 * @apiName GetHostingConfig
36 * @apiGroup Hosting
37 * @apiDescription Retrieve DigitalOcean API configuration for the current tenant (without exposing API token).
38 * @apiSuccess {object} config Configuration object.
39 * @apiSuccess {string} config.config_id Configuration ID.
40 * @apiSuccess {string} config.tenant_id Tenant ID.
41 * @apiSuccess {string} config.team_id DigitalOcean team ID.
42 * @apiSuccess {boolean} config.is_active Whether configuration is active.
43 * @apiSuccess {Date} config.last_sync_at Last synchronization timestamp.
44 * @apiError (Error 404) NotFound Configuration not found.
45 * @apiExample {curl} Example usage:
46 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/hosting/config
47 */
48router.get('/config', async (req, res) => {
49 try {
50 const result = await pool.query(
51 `SELECT config_id, tenant_id, team_id, is_active, last_sync_at, created_at, updated_at
52 FROM digitalocean_config
53 WHERE tenant_id = $1`,
54 [req.tenant.id]
55 );
56
57 if (result.rows.length === 0) {
58 return res.status(404).json({ error: 'DigitalOcean configuration not found' });
59 }
60
61 res.json({ config: result.rows[0] });
62 } catch (error) {
63 console.error('[Hosting] Error fetching config:', error);
64 res.status(500).json({ error: 'Failed to fetch configuration' });
65 }
66});
67
68/**
69 * @api {put} /hosting/config Update DigitalOcean configuration
70 * @apiName UpdateHostingConfig
71 * @apiGroup Hosting
72 * @apiDescription Update or create DigitalOcean API configuration for the current tenant. Stores API token and team ID for DigitalOcean resource management.
73 * @apiParam {string} api_token DigitalOcean API token.
74 * @apiParam {string} [team_id] DigitalOcean team ID (optional).
75 * @apiSuccess {string} message Success confirmation.
76 * @apiSuccess {object} config Saved configuration object.
77 * @apiError (Error 400) BadRequest api_token is required.
78 * @apiExample {curl} Example usage:
79 * curl -X PUT -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" -d '{"api_token":"dop_v1_abc123","team_id":"team-123"}' https://api.example.com/hosting/config
80 */
81router.put('/config', async (req, res) => {
82 const { api_token, team_id } = req.body;
83
84 if (!api_token) {
85 return res.status(400).json({ error: 'API token is required' });
86 }
87
88 try {
89 // TODO: Encrypt api_token before storing
90 const encrypted_token = api_token; // Implement encryption
91
92 const result = await pool.query(
93 `INSERT INTO digitalocean_config (tenant_id, api_token_encrypted, team_id, is_active)
94 VALUES ($1, $2, $3, true)
95 ON CONFLICT (tenant_id)
96 DO UPDATE SET
97 api_token_encrypted = EXCLUDED.api_token_encrypted,
98 team_id = EXCLUDED.team_id,
99 is_active = true,
100 updated_at = CURRENT_TIMESTAMP
101 RETURNING config_id, tenant_id, team_id, is_active, created_at, updated_at`,
102 [req.tenant.id, encrypted_token, team_id || null]
103 );
104
105 res.json({
106 message: 'DigitalOcean configuration saved successfully',
107 config: result.rows[0]
108 });
109 } catch (error) {
110 console.error('[Hosting] Error saving config:', error);
111 res.status(500).json({ error: 'Failed to save configuration' });
112 }
113});
114
115/**
116 * @api {post} /hosting/config/test Test DigitalOcean API connection
117 * @apiName TestHostingConnection
118 * @apiGroup Hosting
119 * @apiDescription Test the DigitalOcean API connection with current tenant's configuration. Verifies API token validity and connectivity.
120 * @apiSuccess {boolean} success Connection test result.
121 * @apiSuccess {string} [message] Status message.
122 * @apiError (Error 500) ServerError Connection test failed or configuration missing.
123 * @apiExample {curl} Example usage:
124 * curl -X POST -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/hosting/config/test
125 */
126router.post('/config/test', async (req, res) => {
127 try {
128 const doService = new DigitalOceanService(req.tenant.id);
129 const result = await doService.testConnection();
130 res.json(result);
131 } catch (error) {
132 console.error('[Hosting] Connection test failed:', error);
133 res.status(500).json({
134 success: false,
135 error: error.message
136 });
137 }
138});
139
140/**
141 * @api {get} /hosting/projects List all DigitalOcean projects
142 * @apiName ListProjects
143 * @apiGroup Hosting
144 * @apiDescription Retrieve all projects from DigitalOcean for the current tenant. Projects organize and group DigitalOcean resources.
145 * @apiSuccess {object[]} projects Array of project objects from DigitalOcean.
146 * @apiExample {curl} Example usage:
147 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/hosting/projects
148 */
149router.get('/projects', async (req, res) => {
150 try {
151 const doService = new DigitalOceanService(req.tenant.id);
152 const projects = await doService.listProjects();
153 res.json({ projects });
154 } catch (error) {
155 console.error('[Hosting] Error fetching projects:', error);
156 res.status(500).json({ error: error.message });
157 }
158});
159
160// ===========================
161// Apps Management
162// ===========================
163
164/**
165 * @api {get} /hosting/apps List all hosting apps for tenant
166 * @apiName ListHostingApps
167 * @apiGroup Hosting
168 * @apiDescription Retrieve all App Platform applications for the current tenant with customer and contract details. Root tenant sees all apps; other tenants see only assigned apps. Excludes deleted apps.
169 * @apiSuccess {object[]} apps Array of hosting app objects.
170 * @apiSuccess {string} apps.app_id App ID.
171 * @apiSuccess {string} apps.app_name App name.
172 * @apiSuccess {string} apps.do_app_id DigitalOcean app ID.
173 * @apiSuccess {string} apps.status App status (active, building, error, etc.).
174 * @apiSuccess {string} apps.customer_name Customer name.
175 * @apiSuccess {string} apps.contract_name Contract title.
176 * @apiSuccess {Date} lastSync Last synchronization timestamp.
177 * @apiSuccess {string} ttl Cache TTL ("5 minutes").
178 * @apiExample {curl} Example usage:
179 * curl -H "Authorization: Bearer <token>" -H "X-Tenant-ID: <id>" https://api.example.com/hosting/apps
180 */
181router.get('/apps', async (req, res) => {
182 try {
183 if (!req.tenant || !req.tenant.id) {
184 return res.status(400).json({ error: 'Tenant context not available' });
185 }
186
187 // Get last sync time
188 const syncResult = await pool.query(
189 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
190 [req.tenant.id]
191 );
192 const lastSync = syncResult.rows[0]?.last_sync_at;
193
194 // Root tenant sees all apps, other tenants only see their assigned apps
195 const isRootTenant = req.tenant.id === '00000000-0000-0000-0000-000000000001';
196 const result = await pool.query(
197 `SELECT
198 ha.*,
199 c.name as customer_name,
200 ct.title as contract_name
201 FROM hosting_apps ha
202 LEFT JOIN customers c ON ha.customer_id = c.customer_id
203 LEFT JOIN contracts ct ON ha.contract_id = ct.contract_id
204 WHERE ha.status != 'deleted' ${isRootTenant ? '' : 'AND (ha.tenant_id = $1 OR ha.assigned_tenant_id = $1)'}
205 ORDER BY ha.created_at DESC`,
206 isRootTenant ? [] : [req.tenant.id]
207 );
208
209 res.json({
210 apps: result.rows,
211 lastSync,
212 ttl: '5 minutes'
213 });
214 } catch (error) {
215 console.error('[Hosting] Error fetching apps:', error);
216 // Return empty array if table doesn't exist yet
217 if (error.code === '42P01') { // undefined_table
218 return res.json({ apps: [] });
219 }
220 res.status(500).json({ error: 'Failed to fetch apps' });
221 }
222});
223
224/**
225 * @api {get} /api/hosting/apps/:id Get app details
226 * @apiName GetHostingApp
227 * @apiGroup Hosting
228 * @apiDescription Get detailed information about a specific DigitalOcean app, including
229 * optional WordPress site details if applicable. Requires tenant context.
230 * @apiParam {UUID} id App ID (internal database ID)
231 * @apiSuccess {object} app App details object
232 * @apiSuccess {UUID} app.app_id Internal app ID
233 * @apiSuccess {string} app.do_app_id DigitalOcean app ID
234 * @apiSuccess {string} app.app_name App name
235 * @apiSuccess {string} app.app_type App type (nodejs, static, wordpress)
236 * @apiSuccess {string} app.status App status (active, pending, deleted)
237 * @apiSuccess {string} app.region Region slug
238 * @apiSuccess {string} app.live_url Live URL
239 * @apiSuccess {string} app.customer_name Customer name (joined)
240 * @apiSuccess {string} app.contract_name Contract name (joined)
241 * @apiSuccess {Object} [app.wordpress] WordPress-specific details (if applicable)
242 * @apiSuccess {UUID} app.wordpress.site_id WordPress site ID
243 * @apiSuccess {string} app.wordpress.db_name Database name
244 * @apiSuccess {string} app.wordpress.wp_admin_user WordPress admin username
245 * @apiError {string} error="App not found" (404) App not found for tenant
246 * @apiError {string} error="Failed to fetch app" (500) Database query failed
247 * @apiExample {curl} Example:
248 * curl -X GET http://localhost:3000/api/hosting/apps/123e4567-e89b-12d3-a456-426614174000 \
249 * -H "Authorization: Bearer YOUR_TOKEN"
250 * @apiSuccessExample {json} Success-Response (with WordPress details):
251 * HTTP/1.1 200 OK
252 * {
253 * "app": {
254 * "app_id": "123e4567-e89b-12d3-a456-426614174000",
255 * "do_app_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
256 * "app_name": "wordpress-example-site",
257 * "app_type": "wordpress",
258 * "status": "active",
259 * "region": "nyc3",
260 * "live_url": "https://wordpress-example-site-abc123.ondigitalocean.app",
261 * "customer_name": "Acme Corp",
262 * "contract_name": "Managed Hosting Plan",
263 * "wordpress": {
264 * "site_id": "456e7890-ab12-34cd-5678-901234567890",
265 * "db_name": "wordpress_db",
266 * "spaces_path": "wp-content/uploads",
267 * "wp_admin_user": "admin",
268 * "wp_admin_email": "admin@example.com"
269 * }
270 * }
271 * }
272 */
273router.get('/apps/:id', async (req, res) => {
274 const { id } = req.params;
275
276 try {
277 const result = await pool.query(
278 `SELECT
279 ha.*,
280 c.name as customer_name,
281 ct.title as contract_name
282 FROM hosting_apps ha
283 LEFT JOIN customers c ON ha.customer_id = c.customer_id
284 LEFT JOIN contracts ct ON ha.contract_id = ct.contract_id
285 WHERE ha.app_id = $1 AND (ha.tenant_id = $2 OR ha.assigned_tenant_id = $2)`,
286 [id, req.tenant.id]
287 );
288
289 if (result.rows.length === 0) {
290 return res.status(404).json({ error: 'App not found' });
291 }
292
293 const app = result.rows[0];
294
295 // If this is a WordPress app, fetch WordPress-specific details
296 if (app.app_name && app.app_name.startsWith('wordpress-')) {
297 try {
298 const wpResult = await pool.query(
299 `SELECT
300 site_id,
301 db_name,
302 spaces_path,
303 wp_admin_user,
304 wp_admin_password,
305 wp_admin_email
306 FROM wordpress_sites
307 WHERE app_id = $1 AND tenant_id = $2`,
308 [app.do_app_id, req.tenant.id]
309 );
310
311 if (wpResult.rows.length > 0) {
312 app.wordpress = wpResult.rows[0];
313 }
314 } catch (wpError) {
315 console.error('[Hosting] Error fetching WordPress details:', wpError);
316 // Continue without WordPress details if query fails
317 }
318 }
319
320 res.json({ app });
321 } catch (error) {
322 console.error('[Hosting] Error fetching app:', error);
323 res.status(500).json({ error: 'Failed to fetch app' });
324 }
325});
326
327/**
328 * @api {put} /api/hosting/apps/:id Update app assignments
329 * @apiName UpdateHostingApp
330 * @apiGroup Hosting
331 * @apiDescription Update customer, contract, and tenant assignments for a hosting app.
332 * Used for organizing apps in multi-tenant environment.
333 * @apiParam {UUID} id App ID (internal database ID)
334 * @apiParam {UUID} [customer_id] Customer ID to assign app to
335 * @apiParam {UUID} [contract_id] Contract ID to link app with
336 * @apiParam {UUID} [assigned_tenant_id] Tenant ID to assign app to
337 * @apiSuccess {boolean} success=true Operation successful
338 * @apiSuccess {string} message="App updated successfully"
339 * @apiSuccess {object} app Updated app object
340 * @apiError {string} error="App not found" (404) App not found for tenant
341 * @apiError {string} error="Failed to update app" (500) Database update failed
342 * @apiExample {curl} Example:
343 * curl -X PUT http://localhost:3000/api/hosting/apps/123e4567-e89b-12d3-a456-426614174000 \
344 * -H "Authorization: Bearer YOUR_TOKEN" \
345 * -H "Content-Type: application/json" \
346 * -d '{
347 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
348 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab"
349 * }'
350 * @apiSuccessExample {json} Success-Response:
351 * HTTP/1.1 200 OK
352 * {
353 * "success": true,
354 * "message": "App updated successfully",
355 * "app": {
356 * "app_id": "123e4567-e89b-12d3-a456-426614174000",
357 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
358 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab",
359 * "assigned_tenant_id": null,
360 * "updated_at": "2026-03-12T10:30:00.000Z"
361 * }
362 * }
363 */
364router.put('/apps/:id', async (req, res) => {
365 const { id } = req.params;
366 const { customer_id, contract_id, assigned_tenant_id } = req.body;
367
368 try {
369 // Update the app
370 const result = await pool.query(
371 `UPDATE hosting_apps
372 SET customer_id = $1,
373 contract_id = $2,
374 assigned_tenant_id = $3,
375 updated_at = CURRENT_TIMESTAMP
376 WHERE app_id = $4 AND tenant_id = $5
377 RETURNING *`,
378 [customer_id || null, contract_id || null, assigned_tenant_id || null, id, req.tenant.id]
379 );
380
381 if (result.rows.length === 0) {
382 return res.status(404).json({ error: 'App not found' });
383 }
384
385 res.json({
386 success: true,
387 message: 'App updated successfully',
388 app: result.rows[0]
389 });
390 } catch (error) {
391 console.error('[Hosting] Error updating app:', error);
392 res.status(500).json({ error: 'Failed to update app' });
393 }
394});
395
396/**
397 * @api {post} /api/hosting/sync Sync DigitalOcean resources
398 * @apiName SyncHostingResources
399 * @apiGroup Hosting
400 * @apiDescription Synchronize all DigitalOcean resources (apps, databases, droplets) to local
401 * database with current metrics. Called manually via UI button or automatically by Redis worker
402 * every 30 minutes. Marks resources as deleted if removed from DigitalOcean.
403 * @apiSuccess {string} message="DigitalOcean resources synced successfully"
404 * @apiSuccess {object} summary Resource count summary from DigitalOcean API
405 * @apiSuccess {number} summary.apps Total apps in DO
406 * @apiSuccess {number} summary.databases Total databases in DO
407 * @apiSuccess {number} summary.droplets Total droplets in DO
408 * @apiSuccess {object} syncStats Statistics of synced resources
409 * @apiSuccess {number} syncStats.apps Apps synced to database
410 * @apiSuccess {number} syncStats.databases Databases synced to database
411 * @apiSuccess {number} syncStats.droplets Droplets synced to database
412 * @apiSuccess {number} syncStats.metrics Metric API calls made
413 * @apiError {string} error="Tenant context not available" (400) No tenant in request
414 * @apiError {string} error="DigitalOcean API token not configured" (400) DO_API_TOKEN missing
415 * @apiError {string} error (500) Sync operation failed
416 * @apiExample {curl} Example:
417 * curl -X POST http://localhost:3000/api/hosting/sync \
418 * -H "Authorization: Bearer YOUR_TOKEN"
419 * @apiSuccessExample {json} Success-Response:
420 * HTTP/1.1 200 OK
421 * {
422 * "message": "DigitalOcean resources synced successfully",
423 * "summary": {
424 * "apps": 15,
425 * "databases": 3,
426 * "droplets": 8
427 * },
428 * "syncStats": {
429 * "apps": 15,
430 * "databases": 3,
431 * "droplets": 8,
432 * "metrics": 11
433 * }
434 * }
435 */
436router.post('/sync', async (req, res) => {
437 try {
438 if (!req.tenant || !req.tenant.id) {
439 return res.status(400).json({ error: 'Tenant context not available' });
440 }
441 const doService = new DigitalOceanService(req.tenant.id);
442 const resources = await doService.getAllResources();
443
444 let syncStats = {
445 apps: 0,
446 databases: 0,
447 droplets: 0,
448 metrics: 0
449 };
450
451 // =====================
452 // Sync Apps
453 // =====================
454 const activeAppIds = resources.apps.map(app => app.id);
455
456 for (const app of resources.apps) {
457 await pool.query(
458 `INSERT INTO hosting_apps (
459 tenant_id, do_app_id, app_name, app_type, status, region,
460 live_url, default_domain, custom_domains, metadata,
461 created_at, updated_at
462 )
463 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
464 ON CONFLICT (do_app_id)
465 DO UPDATE SET
466 app_name = EXCLUDED.app_name,
467 status = EXCLUDED.status,
468 region = EXCLUDED.region,
469 live_url = EXCLUDED.live_url,
470 default_domain = EXCLUDED.default_domain,
471 custom_domains = EXCLUDED.custom_domains,
472 metadata = EXCLUDED.metadata,
473 updated_at = CURRENT_TIMESTAMP`,
474 [
475 req.tenant.id,
476 app.id,
477 app.spec?.name || 'Unknown App',
478 app.spec?.services?.[0]?.source_dir ? 'nodejs' : 'static',
479 app.live_url_base ? 'active' : 'pending',
480 app.region,
481 app.live_url,
482 app.default_ingress,
483 JSON.stringify(app.spec?.domains || []),
484 JSON.stringify(app)
485 ]
486 );
487 syncStats.apps++;
488 }
489
490 // Mark apps as deleted if they no longer exist in DO
491 if (activeAppIds.length > 0) {
492 await pool.query(
493 `UPDATE hosting_apps
494 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
495 WHERE tenant_id = $1
496 AND do_app_id NOT IN (${activeAppIds.map((_, i) => `$${i + 2}`).join(', ')})
497 AND status != 'deleted'`,
498 [req.tenant.id, ...activeAppIds]
499 );
500 }
501
502 // =====================
503 // Sync Databases + Metrics
504 // =====================
505 const databases = resources.databases || [];
506 const activeDatabaseIds = databases.map(db => db.id);
507
508 for (const db of databases) {
509 // Fetch metrics for this database
510 let metrics = null;
511 try {
512 metrics = await doService.getDatabaseMetrics(db.id);
513 syncStats.metrics++;
514 } catch (err) {
515 console.error(`[Hosting] Failed to fetch metrics for database ${db.id}:`, err.message);
516 }
517
518 await pool.query(
519 `INSERT INTO hosting_databases (
520 tenant_id, do_database_id, database_name, engine, version, status,
521 region, size, num_nodes, connection_host, connection_port,
522 connection_database, connection_user, tags,
523 cpu_count, memory_mb, disk_gb, metrics_last_updated,
524 metadata, created_at, updated_at
525 )
526 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
527 ON CONFLICT (do_database_id)
528 DO UPDATE SET
529 database_name = EXCLUDED.database_name,
530 status = EXCLUDED.status,
531 version = EXCLUDED.version,
532 size = EXCLUDED.size,
533 num_nodes = EXCLUDED.num_nodes,
534 connection_host = EXCLUDED.connection_host,
535 connection_port = EXCLUDED.connection_port,
536 cpu_count = EXCLUDED.cpu_count,
537 memory_mb = EXCLUDED.memory_mb,
538 disk_gb = EXCLUDED.disk_gb,
539 metrics_last_updated = EXCLUDED.metrics_last_updated,
540 metadata = EXCLUDED.metadata,
541 updated_at = CURRENT_TIMESTAMP`,
542 [
543 req.tenant.id,
544 db.id,
545 db.name,
546 db.engine,
547 db.version,
548 db.status,
549 db.region,
550 db.size,
551 db.num_nodes || 1,
552 db.connection?.host || null,
553 db.connection?.port || null,
554 db.connection?.database || null,
555 db.connection?.user || null,
556 db.tags || [],
557 metrics?.cpu_count || null,
558 metrics?.memory_mb || null,
559 metrics?.disk_gb || null,
560 metrics ? new Date() : null,
561 JSON.stringify(db)
562 ]
563 );
564
565 // Store metrics history for trends
566 if (metrics) {
567 await pool.query(
568 `INSERT INTO hosting_metrics_history (
569 resource_type, resource_id, tenant_id, cpu_value, memory_value, disk_value
570 ) VALUES ($1, $2, $3, $4, $5, $6)`,
571 ['database', db.id, req.tenant.id, metrics.cpu_count, metrics.memory_mb, metrics.disk_gb]
572 );
573 }
574
575 syncStats.databases++;
576 }
577
578 // Mark databases as deleted if they no longer exist
579 if (activeDatabaseIds.length > 0) {
580 await pool.query(
581 `UPDATE hosting_databases
582 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
583 WHERE tenant_id = $1
584 AND do_database_id NOT IN (${activeDatabaseIds.map((_, i) => `$${i + 2}`).join(', ')})
585 AND status != 'deleted'`,
586 [req.tenant.id, ...activeDatabaseIds]
587 );
588 }
589
590 // =====================
591 // Sync Droplets + Metrics
592 // =====================
593 const droplets = resources.droplets || [];
594 const activeDropletIds = droplets.map(d => d.id.toString());
595
596 for (const droplet of droplets) {
597 // Fetch CPU metrics for this droplet (last hour average)
598 let cpuMetrics = null;
599 try {
600 cpuMetrics = await doService.getDropletMetrics(droplet.id, 'cpu');
601 syncStats.metrics++;
602
603 // Calculate average CPU from last hour
604 let avgCpu = null;
605 if (cpuMetrics?.data?.result?.[0]?.values) {
606 const values = cpuMetrics.data.result[0].values;
607 const sum = values.reduce((acc, [, value]) => acc + parseFloat(value), 0);
608 avgCpu = values.length > 0 ? (sum / values.length).toFixed(2) : null;
609 }
610
611 await pool.query(
612 `INSERT INTO hosting_droplets (
613 tenant_id, do_droplet_id, droplet_name, status, region, size,
614 ip_address, ipv6_address, vcpus, memory, disk, image, tags,
615 cpu_usage_percent, metrics_last_updated,
616 metadata, created_at, updated_at
617 )
618 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
619 ON CONFLICT (do_droplet_id)
620 DO UPDATE SET
621 droplet_name = EXCLUDED.droplet_name,
622 status = EXCLUDED.status,
623 size = EXCLUDED.size,
624 ip_address = EXCLUDED.ip_address,
625 vcpus = EXCLUDED.vcpus,
626 memory = EXCLUDED.memory,
627 disk = EXCLUDED.disk,
628 cpu_usage_percent = EXCLUDED.cpu_usage_percent,
629 metrics_last_updated = EXCLUDED.metrics_last_updated,
630 metadata = EXCLUDED.metadata,
631 updated_at = CURRENT_TIMESTAMP`,
632 [
633 req.tenant.id,
634 droplet.id.toString(),
635 droplet.name,
636 droplet.status,
637 droplet.region.slug,
638 droplet.size_slug,
639 droplet.networks?.v4?.[0]?.ip_address || null,
640 droplet.networks?.v6?.[0]?.ip_address || null,
641 droplet.vcpus,
642 droplet.memory,
643 droplet.disk,
644 droplet.image?.slug || droplet.image?.name || null,
645 droplet.tags || [],
646 avgCpu,
647 new Date(),
648 JSON.stringify(droplet)
649 ]
650 );
651
652 // Store metrics history
653 if (avgCpu) {
654 await pool.query(
655 `INSERT INTO hosting_metrics_history (
656 resource_type, resource_id, tenant_id, cpu_value
657 ) VALUES ($1, $2, $3, $4)`,
658 ['droplet', droplet.id.toString(), req.tenant.id, avgCpu]
659 );
660 }
661
662 } catch (err) {
663 console.error(`[Hosting] Failed to fetch metrics for droplet ${droplet.id}:`, err.message);
664
665 // Still save droplet without metrics
666 await pool.query(
667 `INSERT INTO hosting_droplets (
668 tenant_id, do_droplet_id, droplet_name, status, region, size,
669 ip_address, ipv6_address, vcpus, memory, disk, image, tags,
670 metadata, created_at, updated_at
671 )
672 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
673 ON CONFLICT (do_droplet_id)
674 DO UPDATE SET
675 droplet_name = EXCLUDED.droplet_name,
676 status = EXCLUDED.status,
677 metadata = EXCLUDED.metadata,
678 updated_at = CURRENT_TIMESTAMP`,
679 [
680 req.tenant.id,
681 droplet.id.toString(),
682 droplet.name,
683 droplet.status,
684 droplet.region.slug,
685 droplet.size_slug,
686 droplet.networks?.v4?.[0]?.ip_address || null,
687 droplet.networks?.v6?.[0]?.ip_address || null,
688 droplet.vcpus,
689 droplet.memory,
690 droplet.disk,
691 droplet.image?.slug || droplet.image?.name || null,
692 droplet.tags || [],
693 JSON.stringify(droplet)
694 ]
695 );
696 }
697
698 syncStats.droplets++;
699 }
700
701 // Mark droplets as deleted if they no longer exist
702 if (activeDropletIds.length > 0) {
703 await pool.query(
704 `UPDATE hosting_droplets
705 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
706 WHERE tenant_id = $1
707 AND do_droplet_id NOT IN (${activeDropletIds.map((_, i) => `$${i + 2}`).join(', ')})
708 AND status != 'deleted'`,
709 [req.tenant.id, ...activeDropletIds]
710 );
711 }
712
713 // Update last sync time
714 await pool.query(
715 `INSERT INTO digitalocean_config (tenant_id, last_sync_at)
716 VALUES ($1, CURRENT_TIMESTAMP)
717 ON CONFLICT (tenant_id)
718 DO UPDATE SET last_sync_at = CURRENT_TIMESTAMP`,
719 [req.tenant.id]
720 );
721
722 res.json({
723 message: 'DigitalOcean resources synced successfully',
724 summary: resources.summary,
725 syncStats
726 });
727 } catch (error) {
728 console.error('[Hosting] Error syncing resources:', error);
729 // Check if it's a missing config error
730 if (error.message && error.message.includes('configuration not found')) {
731 return res.status(400).json({
732 error: 'DigitalOcean API token not configured. Please add DO_API_TOKEN to backend environment variables.'
733 });
734 }
735 res.status(500).json({ error: error.message || 'Failed to sync resources' });
736 }
737});
738
739// ===========================
740// Live DO API Queries
741// ===========================
742
743/**
744 * @api {get} /api/hosting/do/apps Get live DO apps
745 * @apiName GetLiveDigitalOceanApps
746 * @apiGroup Hosting
747 * @apiDescription Query DigitalOcean API directly for current app list (live, no cache).
748 * Use this endpoint sparingly - prefer cached GET /api/hosting/apps endpoint.
749 * @apiSuccess {Array} apps Array of app objects from DigitalOcean API
750 * @apiError {string} error="Tenant context not available" (400) No tenant in request
751 * @apiError {string} error (500) DigitalOcean API request failed
752 * @apiExample {curl} Example:
753 * curl -X GET http://localhost:3000/api/hosting/do/apps \
754 * -H "Authorization: Bearer YOUR_TOKEN"
755 * @apiSuccessExample {json} Success-Response:
756 * HTTP/1.1 200 OK
757 * {
758 * "apps": [
759 * {
760 * "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
761 * "spec": {
762 * "name": "wordpress-example",
763 * "region": "nyc3"
764 * },
765 * "live_url": "https://wordpress-example-abc123.ondigitalocean.app",
766 * "created_at": "2026-01-15T08:00:00Z"
767 * }
768 * ]
769 * }
770 */
771router.get('/do/apps', async (req, res) => {
772 try {
773 if (!req.tenant || !req.tenant.id) {
774 return res.status(400).json({ error: 'Tenant context not available' });
775 }
776 const doService = new DigitalOceanService(req.tenant.id);
777 const apps = await doService.listApps();
778 res.json({ apps });
779 } catch (error) {
780 console.error('[Hosting] Error fetching DO apps:', error);
781 res.status(500).json({ error: error.message });
782 }
783});
784
785/**
786 * @api {get} /api/hosting/droplets List droplets
787 * @apiName ListHostingDroplets
788 * @apiGroup Hosting
789 * @apiDescription Get all DigitalOcean Droplets (VMs) from cached database. Root tenant sees
790 * all droplets, other tenants only see their own. Synced every 5 minutes via background worker.
791 * @apiSuccess {Array} droplets Array of droplet objects with customer details
792 * @apiSuccess {DateTime} lastSync Last successful sync timestamp
793 * @apiSuccess {string} ttl="5 minutes" Cache TTL
794 * @apiSuccess {UUID} droplets.droplet_id Internal droplet ID
795 * @apiSuccess {string} droplets.do_droplet_id DigitalOcean droplet ID
796 * @apiSuccess {string} droplets.droplet_name Droplet name
797 * @apiSuccess {string} droplets.status Status (active, off, new, deleted)
798 * @apiSuccess {string} droplets.region Region slug
799 * @apiSuccess {string} droplets.size Size slug (s-1vcpu-1gb, etc)
800 * @apiSuccess {string} droplets.ip_address Primary IPv4 address
801 * @apiSuccess {number} droplets.vcpus Number of vCPUs
802 * @apiSuccess {number} droplets.memory RAM in MB
803 * @apiSuccess {number} droplets.disk Disk size in GB
804 * @apiSuccess {number} [droplets.cpu_usage_percent] Average CPU usage from last hour
805 * @apiSuccess {string} droplets.customer_name Customer name (joined)
806 * @apiError {string} error="Tenant context not available" (400) No tenant in request
807 * @apiError {string} error="Failed to fetch droplets" (500) Database query failed
808 * @apiExample {curl} Example:
809 * curl -X GET http://localhost:3000/api/hosting/droplets \
810 * -H "Authorization: Bearer YOUR_TOKEN"
811 * @apiSuccessExample {json} Success-Response:
812 * HTTP/1.1 200 OK
813 * {
814 * "droplets": [
815 * {
816 * "droplet_id": "123e4567-e89b-12d3-a456-426614174000",
817 * "do_droplet_id": "123456789",
818 * "droplet_name": "web-server-01",
819 * "status": "active",
820 * "region": "nyc3",
821 * "size": "s-2vcpu-4gb",
822 * "ip_address": "192.0.2.100",
823 * "vcpus": 2,
824 * "memory": 4096,
825 * "disk": 80,
826 * "cpu_usage_percent": 35.5,
827 * "customer_name": "Acme Corp"
828 * }
829 * ],
830 * "lastSync": "2026-03-12T10:25:00.000Z",
831 * "ttl": "5 minutes"
832 * }
833 */
834router.get('/droplets', async (req, res) => {
835 try {
836 if (!req.tenant || !req.tenant.id) {
837 return res.status(400).json({ error: 'Tenant context not available' });
838 }
839
840 // Get last sync time
841 const syncResult = await pool.query(
842 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
843 [req.tenant.id]
844 );
845 const lastSync = syncResult.rows[0]?.last_sync_at;
846
847 // Root tenant sees all droplets, other tenants only see their own
848 const isRootTenant = req.tenant.id === '00000000-0000-0000-0000-000000000001';
849 const result = await pool.query(
850 `SELECT
851 hd.*,
852 c.name as customer_name
853 FROM hosting_droplets hd
854 LEFT JOIN customers c ON hd.customer_id = c.customer_id
855 ${isRootTenant ? '' : 'WHERE hd.tenant_id = $1'}
856 ORDER BY hd.created_at DESC`,
857 isRootTenant ? [] : [req.tenant.id]
858 );
859
860 res.json({
861 droplets: result.rows,
862 lastSync,
863 ttl: '5 minutes'
864 });
865 } catch (error) {
866 console.error('[Hosting] Error fetching droplets:', error);
867 if (error.code === '42P01') {
868 return res.json({ droplets: [], lastSync: null, ttl: '5 minutes' });
869 }
870 res.status(500).json({ error: 'Failed to fetch droplets' });
871 }
872});
873
874/**
875 * @api {get} /api/hosting/databases List databases
876 * @apiName ListHostingDatabases
877 * @apiGroup Hosting
878 * @apiDescription Get all DigitalOcean managed databases from cached database. Root tenant
879 * sees all databases, other tenants only see their own. Synced every 5 minutes.
880 * @apiSuccess {Array} databases Array of database objects with customer details
881 * @apiSuccess {DateTime} lastSync Last successful sync timestamp
882 * @apiSuccess {string} ttl="5 minutes" Cache TTL
883 * @apiSuccess {UUID} databases.database_id Internal database ID
884 * @apiSuccess {string} databases.do_database_id DigitalOcean database cluster ID
885 * @apiSuccess {string} databases.database_name Database cluster name
886 * @apiSuccess {string} databases.engine Engine type (pg, mysql, redis, mongodb)
887 * @apiSuccess {string} databases.version Engine version
888 * @apiSuccess {string} databases.status Status (online, creating, deleted)
889 * @apiSuccess {string} databases.region Region slug
890 * @apiSuccess {string} databases.size Size slug (db-s-1vcpu-1gb, etc)
891 * @apiSuccess {number} databases.num_nodes Number of nodes in cluster
892 * @apiSuccess {string} databases.connection_host Connection hostname
893 * @apiSuccess {number} databases.connection_port Connection port
894 * @apiSuccess {number} [databases.cpu_count] CPU count from metrics
895 * @apiSuccess {number} [databases.memory_mb] Memory in MB from metrics
896 * @apiSuccess {number} [databases.disk_gb] Disk usage in GB from metrics
897 * @apiSuccess {string} databases.customer_name Customer name (joined)
898 * @apiError {string} error="Tenant context not available" (400) No tenant in request
899 * @apiError {string} error="Failed to fetch databases" (500) Database query failed
900 *
901 * @apiExample {curl} Example:
902 * curl -X GET http://localhost:3000/api/hosting/databases \
903 * -H "Authorization: Bearer YOUR_TOKEN"
904 *
905 * @apiSuccessExample {json} Success-Response:
906 * HTTP/1.1 200 OK
907 * {
908 * "databases": [
909 * {
910 * "database_id": "123e4567-e89b-12d3-a456-426614174000",
911 * "do_database_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
912 * "database_name": "production-pg",
913 * "engine": "pg",
914 * "version": "15",
915 * "status": "online",
916 * "region": "nyc3",
917 * "size": "db-s-2vcpu-4gb",
918 * "num_nodes": 1,
919 * "connection_host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
920 * "connection_port": 25060,
921 * "cpu_count": 2,
922 * "memory_mb": 3845,
923 * "disk_gb": 12.5,
924 * "customer_name": "Acme Corp"
925 * }
926 * ],
927 * "lastSync": "2026-03-12T10:25:00.000Z",
928 * "ttl": "5 minutes"
929 * }
930 */
931router.get('/databases', async (req, res) => {
932 try {
933 if (!req.tenant || !req.tenant.id) {
934 return res.status(400).json({ error: 'Tenant context not available' });
935 }
936
937 // Get last sync time
938 const syncResult = await pool.query(
939 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
940 [req.tenant.id]
941 );
942 const lastSync = syncResult.rows[0]?.last_sync_at;
943
944 // Root tenant sees all databases, other tenants only see their own
945 const isRootTenant = req.tenant.id === '00000000-0000-0000-0000-000000000001';
946 const result = await pool.query(
947 `SELECT
948 hdb.*,
949 c.name as customer_name
950 FROM hosting_databases hdb
951 LEFT JOIN customers c ON hdb.customer_id = c.customer_id
952 ${isRootTenant ? '' : 'WHERE hdb.tenant_id = $1'}
953 ORDER BY hdb.created_at DESC`,
954 isRootTenant ? [] : [req.tenant.id]
955 );
956
957 res.json({
958 databases: result.rows,
959 lastSync,
960 ttl: '5 minutes'
961 });
962 } catch (error) {
963 console.error('[Hosting] Error fetching databases:', error);
964 if (error.code === '42P01') {
965 return res.json({ databases: [], lastSync: null, ttl: '5 minutes' });
966 }
967 res.status(500).json({ error: 'Failed to fetch databases' });
968 }
969});
970
971/**
972 * @api {get} /api/hosting/databases/:id Get database details
973 * @apiName GetHostingDatabase
974 * @apiGroup Hosting
975 * @apiDescription Get detailed information about a specific DigitalOcean managed database
976 * cluster including customer and metrics.
977 * @apiParam {UUID} id Database ID (internal database ID)
978 * @apiSuccess {object} database Database details object
979 * @apiSuccess {UUID} database.database_id Internal database ID
980 * @apiSuccess {string} database.do_database_id DigitalOcean database cluster ID
981 * @apiSuccess {string} database.database_name Database cluster name
982 * @apiSuccess {string} database.engine Engine type (pg, mysql, redis, mongodb)
983 * @apiSuccess {string} database.version Engine version
984 * @apiSuccess {string} database.status Status
985 * @apiSuccess {string} database.connection_host Connection hostname
986 * @apiSuccess {number} database.connection_port Connection port
987 * @apiSuccess {string} database.connection_user Connection username
988 * @apiSuccess {number} [database.cpu_count] CPU count from metrics
989 * @apiSuccess {number} [database.memory_mb] Memory in MB from metrics
990 * @apiSuccess {number} [database.disk_gb] Disk usage in GB from metrics
991 * @apiSuccess {string} database.customer_name Customer name (joined)
992 * @apiError {string} error="Database not found" (404) Database not found for tenant
993 * @apiError {string} error="Failed to fetch database" (500) Database query failed
994 * @apiExample {curl} Example:
995 * curl -X GET http://localhost:3000/api/hosting/databases/123e4567-e89b-12d3-a456-426614174000 \
996 * -H "Authorization: Bearer YOUR_TOKEN"
997 * @apiSuccessExample {json} Success-Response:
998 * HTTP/1.1 200 OK
999 * {
1000 * "database": {
1001 * "database_id": "123e4567-e89b-12d3-a456-426614174000",
1002 * "do_database_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1003 * "database_name": "production-pg",
1004 * "engine": "pg",
1005 * "version": "15",
1006 * "status": "online",
1007 * "connection_host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
1008 * "connection_port": 25060,
1009 * "connection_user": "doadmin",
1010 * "cpu_count": 2,
1011 * "memory_mb": 3845,
1012 * "disk_gb": 12.5,
1013 * "customer_name": "Acme Corp"
1014 * }
1015 * }
1016 */
1017router.get('/databases/:id', async (req, res) => {
1018 const { id } = req.params;
1019
1020 try {
1021 const result = await pool.query(
1022 `SELECT
1023 hdb.*,
1024 c.name as customer_name
1025 FROM hosting_databases hdb
1026 LEFT JOIN customers c ON hdb.customer_id = c.customer_id
1027 WHERE hdb.database_id = $1 AND hdb.tenant_id = $2`,
1028 [id, req.tenant.id]
1029 );
1030
1031 if (result.rows.length === 0) {
1032 return res.status(404).json({ error: 'Database not found' });
1033 }
1034
1035 const database = result.rows[0];
1036 res.json({ database });
1037 } catch (error) {
1038 console.error('[Hosting] Error fetching database:', error);
1039 res.status(500).json({ error: 'Failed to fetch database' });
1040 }
1041});
1042
1043/**
1044 * @api {put} /api/hosting/databases/:id Update database assignments
1045 * @apiName UpdateHostingDatabase
1046 * @apiGroup Hosting
1047 * @apiDescription Update customer, contract, and tenant assignments for a managed database.
1048 * Used for organizing databases in multi-tenant environment.
1049 * @apiParam {UUID} id Database ID (internal database ID)
1050 * @apiParam {UUID} [customer_id] Customer ID to assign database to
1051 * @apiParam {UUID} [contract_id] Contract ID to link database with
1052 * @apiParam {UUID} [assigned_tenant_id] Tenant ID to assign database to
1053 * @apiSuccess {boolean} success=true Operation successful
1054 * @apiSuccess {string} message="Database updated successfully"
1055 * @apiSuccess {object} database Updated database object
1056 * @apiError {string} error="Database not found" (404) Database not found for tenant
1057 * @apiError {string} error="Failed to update database" (500) Database update failed
1058 * @apiExample {curl} Example:
1059 * curl -X PUT http://localhost:3000/api/hosting/databases/123e4567-e89b-12d3-a456-426614174000 \
1060 * -H "Authorization: Bearer YOUR_TOKEN" \
1061 * -H "Content-Type: application/json" \
1062 * -d '{
1063 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
1064 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab"
1065 * }'
1066 * @apiSuccessExample {json} Success-Response:
1067 * HTTP/1.1 200 OK
1068 * {
1069 * "success": true,
1070 * "message": "Database updated successfully",
1071 * "database": {
1072 * "database_id": "123e4567-e89b-12d3-a456-426614174000",
1073 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
1074 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab",
1075 * "updated_at": "2026-03-12T10:30:00.000Z"
1076 * }
1077 * }
1078 */
1079router.put('/databases/:id', async (req, res) => {
1080 const { id } = req.params;
1081 const { customer_id, contract_id, assigned_tenant_id } = req.body;
1082
1083 try {
1084 // Update the database
1085 const result = await pool.query(
1086 `UPDATE hosting_databases
1087 SET customer_id = $1,
1088 contract_id = $2,
1089 assigned_tenant_id = $3,
1090 updated_at = CURRENT_TIMESTAMP
1091 WHERE database_id = $4 AND tenant_id = $5
1092 RETURNING *`,
1093 [customer_id || null, contract_id || null, assigned_tenant_id || null, id, req.tenant.id]
1094 );
1095
1096 if (result.rows.length === 0) {
1097 return res.status(404).json({ error: 'Database not found' });
1098 }
1099
1100 res.json({
1101 success: true,
1102 message: 'Database updated successfully',
1103 database: result.rows[0]
1104 });
1105 } catch (error) {
1106 console.error('[Hosting] Error updating database:', error);
1107 res.status(500).json({ error: 'Failed to update database' });
1108 }
1109});
1110
1111/**
1112 * @api {get} /api/hosting/droplets/:id Get droplet details
1113 * @apiName GetHostingDroplet
1114 * @apiGroup Hosting
1115 * @apiDescription Get detailed information about a specific DigitalOcean Droplet (VM)
1116 * including customer assignment.
1117 * @apiParam {UUID} id Droplet ID (internal database ID)
1118 * @apiSuccess {object} droplet Droplet details object
1119 * @apiSuccess {UUID} droplet.droplet_id Internal droplet ID
1120 * @apiSuccess {string} droplet.do_droplet_id DigitalOcean droplet ID
1121 * @apiSuccess {string} droplet.droplet_name Droplet name
1122 * @apiSuccess {string} droplet.status Status (active, off, new)
1123 * @apiSuccess {string} droplet.region Region slug
1124 * @apiSuccess {string} droplet.size Size slug
1125 * @apiSuccess {string} droplet.ip_address Primary IPv4 address
1126 * @apiSuccess {string} [droplet.ipv6_address] IPv6 address
1127 * @apiSuccess {number} droplet.vcpus Number of vCPUs
1128 * @apiSuccess {number} droplet.memory RAM in MB
1129 * @apiSuccess {number} droplet.disk Disk size in GB
1130 * @apiSuccess {string} droplet.image Image slug or name
1131 * @apiSuccess {number} [droplet.cpu_usage_percent] Average CPU usage
1132 * @apiSuccess {string} droplet.customer_name Customer name (joined)
1133 * @apiError {string} error="Droplet not found" (404) Droplet not found for tenant
1134 * @apiError {string} error="Failed to fetch droplet" (500) Database query failed
1135 * @apiExample {curl} Example:
1136 * curl -X GET http://localhost:3000/api/hosting/droplets/123e4567-e89b-12d3-a456-426614174000 \
1137 * -H "Authorization: Bearer YOUR_TOKEN"
1138 *
1139 * @apiSuccessExample {json} Success-Response:
1140 * HTTP/1.1 200 OK
1141 * {
1142 * "droplet": {
1143 * "droplet_id": "123e4567-e89b-12d3-a456-426614174000",
1144 * "do_droplet_id": "123456789",
1145 * "droplet_name": "web-server-01",
1146 * "status": "active",
1147 * "region": "nyc3",
1148 * "size": "s-2vcpu-4gb",
1149 * "ip_address": "192.0.2.100",
1150 * "vcpus": 2,
1151 * "memory": 4096,
1152 * "disk": 80,
1153 * "image": "ubuntu-22-04-x64",
1154 * "cpu_usage_percent": 35.5,
1155 * "customer_name": "Acme Corp"
1156 * }
1157 * }
1158 */
1159router.get('/droplets/:id', async (req, res) => {
1160 const { id } = req.params;
1161
1162 try {
1163 const result = await pool.query(
1164 `SELECT
1165 hd.*,
1166 c.name as customer_name
1167 FROM hosting_droplets hd
1168 LEFT JOIN customers c ON hd.customer_id = c.customer_id
1169 WHERE hd.droplet_id = $1 AND hd.tenant_id = $2`,
1170 [id, req.tenant.id]
1171 );
1172
1173 if (result.rows.length === 0) {
1174 return res.status(404).json({ error: 'Droplet not found' });
1175 }
1176
1177 const droplet = result.rows[0];
1178 res.json({ droplet });
1179 } catch (error) {
1180 console.error('[Hosting] Error fetching droplet:', error);
1181 res.status(500).json({ error: 'Failed to fetch droplet' });
1182 }
1183});
1184
1185/**
1186 * @api {put} /api/hosting/droplets/:id Update droplet assignments
1187 * @apiName UpdateHostingDroplet
1188 * @apiGroup Hosting
1189 * @apiDescription Update customer and tenant assignments for a Droplet (VM).
1190 * Used for organizing droplets in multi-tenant environment.
1191 * @apiParam {UUID} id Droplet ID (internal database ID)
1192 * @apiParam {UUID} [customer_id] Customer ID to assign droplet to
1193 * @apiParam {UUID} [assigned_tenant_id] Tenant ID to assign droplet to
1194 * @apiSuccess {boolean} success=true Operation successful
1195 * @apiSuccess {string} message="Droplet updated successfully"
1196 * @apiSuccess {object} droplet Updated droplet object
1197 * @apiError {string} error="Droplet not found" (404) Droplet not found for tenant
1198 * @apiError {string} error="Failed to update droplet" (500) Database update failed
1199 * @apiExample {curl} Example:
1200 * curl -X PUT http://localhost:3000/api/hosting/droplets/123e4567-e89b-12d3-a456-426614174000 \
1201 * -H "Authorization: Bearer YOUR_TOKEN" \
1202 * -H "Content-Type: application/json" \
1203 * -d '{"customer_id": "789e0123-ab45-67cd-89ef-012345678901"}'
1204 * @apiSuccessExample {json} Success-Response:
1205 * HTTP/1.1 200 OK
1206 * {
1207 * "success": true,
1208 * "message": "Droplet updated successfully",
1209 * "droplet": {
1210 * "droplet_id": "123e4567-e89b-12d3-a456-426614174000",
1211 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
1212 * "updated_at": "2026-03-12T10:30:00.000Z"
1213 * }
1214 * }
1215 */
1216router.put('/droplets/:id', async (req, res) => {
1217 const { id } = req.params;
1218 const { customer_id, assigned_tenant_id } = req.body;
1219
1220 try {
1221 // Update the droplet
1222 const result = await pool.query(
1223 `UPDATE hosting_droplets
1224 SET customer_id = $1,
1225 assigned_tenant_id = $2,
1226 updated_at = CURRENT_TIMESTAMP
1227 WHERE droplet_id = $3 AND tenant_id = $4
1228 RETURNING *`,
1229 [customer_id || null, assigned_tenant_id || null, id, req.tenant.id]
1230 );
1231
1232 if (result.rows.length === 0) {
1233 return res.status(404).json({ error: 'Droplet not found' });
1234 }
1235
1236 res.json({
1237 success: true,
1238 message: 'Droplet updated successfully',
1239 droplet: result.rows[0]
1240 });
1241 } catch (error) {
1242 console.error('[Hosting] Error updating droplet:', error);
1243 res.status(500).json({ error: 'Failed to update droplet' });
1244 }
1245});
1246
1247/**
1248 * @api {get} /api/hosting/do/droplets Get live DO droplets
1249 * @apiName GetLiveDigitalOceanDroplets
1250 * @apiGroup Hosting
1251 * @apiDescription Query DigitalOcean API directly for current droplet list (live, no cache).
1252 * Use sparingly - prefer cached GET /api/hosting/droplets endpoint.
1253 * @apiSuccess {Array} droplets Array of droplet objects from DigitalOcean API
1254 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1255 * @apiError {string} error (500) DigitalOcean API request failed
1256 * @apiExample {curl} Example:
1257 * curl -X GET http://localhost:3000/api/hosting/do/droplets \
1258 * -H "Authorization: Bearer YOUR_TOKEN"
1259 * @apiSuccessExample {json} Success-Response:
1260 * HTTP/1.1 200 OK
1261 * {
1262 * "droplets": [
1263 * {
1264 * "id": 123456789,
1265 * "name": "web-server-01",
1266 * "status": "active",
1267 * "region": {"slug": "nyc3"},
1268 * "size_slug": "s-2vcpu-4gb",
1269 * "vcpus": 2,
1270 * "memory": 4096,
1271 * "disk": 80,
1272 * "networks": {
1273 * "v4": [{"ip_address": "192.0.2.100"}]
1274 * }
1275 * }
1276 * ]
1277 * }
1278 */
1279router.get('/do/droplets', async (req, res) => {
1280 try {
1281 if (!req.tenant || !req.tenant.id) {
1282 return res.status(400).json({ error: 'Tenant context not available' });
1283 }
1284 const doService = new DigitalOceanService(req.tenant.id);
1285 const droplets = await doService.listDroplets();
1286 res.json({ droplets });
1287 } catch (error) {
1288 console.error('[Hosting] Error fetching DO droplets:', error);
1289 res.status(500).json({ error: error.message });
1290 }
1291});
1292
1293/**
1294 * @api {get} /api/hosting/do/databases Get live DO databases
1295 * @apiName GetLiveDigitalOceanDatabases
1296 * @apiGroup Hosting
1297 * @apiDescription Query DigitalOcean API directly for current database cluster list
1298 * (live, no cache). Use sparingly - prefer cached GET /api/hosting/databases endpoint.
1299 * @apiSuccess {Array} databases Array of database cluster objects from DigitalOcean API
1300 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1301 * @apiError {string} error (500) DigitalOcean API request failed
1302 * @apiExample {curl} Example:
1303 * curl -X GET http://localhost:3000/api/hosting/do/databases \
1304 * -H "Authorization: Bearer YOUR_TOKEN"
1305 * @apiSuccessExample {json} Success-Response:
1306 * HTTP/1.1 200 OK
1307 * {
1308 * "databases": [
1309 * {
1310 * "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1311 * "name": "production-pg",
1312 * "engine": "pg",
1313 * "version": "15",
1314 * "status": "online",
1315 * "region": "nyc3",
1316 * "size": "db-s-2vcpu-4gb",
1317 * "num_nodes": 1,
1318 * "connection": {
1319 * "host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
1320 * "port": 25060
1321 * }
1322 * }
1323 * ]
1324 * }
1325 */
1326router.get('/do/databases', async (req, res) => {
1327 try {
1328 if (!req.tenant || !req.tenant.id) {
1329 return res.status(400).json({ error: 'Tenant context not available' });
1330 }
1331 const doService = new DigitalOceanService(req.tenant.id);
1332 const databases = await doService.listDatabases();
1333 res.json({ databases });
1334 } catch (error) {
1335 console.error('[Hosting] Error fetching DO databases:', error);
1336 res.status(500).json({ error: error.message });
1337 }
1338});
1339
1340/**
1341 * @api {get} /api/hosting/do/resources Get all live DO resources
1342 * @apiName GetLiveDigitalOceanResources
1343 * @apiGroup Hosting
1344 * @apiDescription Query DigitalOcean API directly for all resources (apps, databases, droplets)
1345 * with summary counts. Live query, no caching. Use for initial sync or troubleshooting.
1346 * @apiSuccess {Array} apps Array of app objects
1347 * @apiSuccess {Array} databases Array of database cluster objects
1348 * @apiSuccess {Array} droplets Array of droplet objects
1349 * @apiSuccess {object} summary Resource count summary
1350 * @apiSuccess {number} summary.apps Total apps
1351 * @apiSuccess {number} summary.databases Total databases
1352 * @apiSuccess {number} summary.droplets Total droplets
1353 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1354 * @apiError {string} error (500) DigitalOcean API request failed
1355 * @apiExample {curl} Example:
1356 * curl -X GET http://localhost:3000/api/hosting/do/resources \
1357 * -H "Authorization: Bearer YOUR_TOKEN"
1358 * @apiSuccessExample {json} Success-Response:
1359 * HTTP/1.1 200 OK
1360 * {
1361 * "apps": [...],
1362 * "databases": [...],
1363 * "droplets": [...],
1364 * "summary": {
1365 * "apps": 15,
1366 * "databases": 3,
1367 * "droplets": 8
1368 * }
1369 * }
1370 */
1371router.get('/do/resources', async (req, res) => {
1372 try { if (!req.tenant || !req.tenant.id) {
1373 return res.status(400).json({ error: 'Tenant context not available' });
1374 } const doService = new DigitalOceanService(req.tenant.id);
1375 const resources = await doService.getAllResources();
1376 res.json(resources);
1377 } catch (error) {
1378 console.error('[Hosting] Error fetching DO resources:', error);
1379 res.status(500).json({ error: error.message });
1380 }
1381});
1382
1383// ===========================
1384// App Operations
1385// ===========================
1386
1387/**
1388 * @api {post} /api/hosting/apps/:id/deploy Trigger app deployment
1389 * @apiName DeployHostingApp
1390 * @apiGroup Hosting
1391 * @apiDescription Trigger a new deployment for a DigitalOcean app. Optionally force rebuild
1392 * from source. Records deployment in database for history tracking.
1393 * @apiParam {UUID} id App ID (internal database ID)
1394 * @apiParam {boolean} [force_rebuild=false] Force complete rebuild from source
1395 * @apiSuccess {string} message="Deployment triggered successfully"
1396 * @apiSuccess {object} deployment Deployment object from DigitalOcean API
1397 * @apiSuccess {string} deployment.id Deployment ID
1398 * @apiSuccess {string} deployment.phase Deployment phase (PENDING_BUILD, BUILDING, DEPLOYING, ACTIVE)
1399 * @apiSuccess {DateTime} deployment.created_at Deployment start time
1400 * @apiError {string} error="App not found" (404) App not found for tenant
1401 * @apiError {string} error (500) Deployment trigger failed
1402 * @apiExample {curl} Example:
1403 * curl -X POST http://localhost:3000/api/hosting/apps/123e4567-e89b-12d3-a456-426614174000/deploy \
1404 * -H "Authorization: Bearer YOUR_TOKEN" \
1405 * -H "Content-Type: application/json" \
1406 * -d '{"force_rebuild": true}'
1407 * @apiSuccessExample {json} Success-Response:
1408 * HTTP/1.1 200 OK
1409 * {
1410 * "message": "Deployment triggered successfully",
1411 * "deployment": {
1412 * "id": "deploy-abc123",
1413 * "phase": "PENDING_BUILD",
1414 * "created_at": "2026-03-12T10:30:00Z",
1415 * "progress": {
1416 * "steps": []
1417 * }
1418 * }
1419 * }
1420 */
1421router.post('/apps/:id/deploy', async (req, res) => {
1422 const { id } = req.params;
1423 const { force_rebuild } = req.body;
1424
1425 try {
1426 // Get app's DO ID
1427 const appResult = await pool.query(
1428 'SELECT do_app_id FROM hosting_apps WHERE app_id = $1 AND tenant_id = $2',
1429 [id, req.tenant.id]
1430 );
1431
1432 if (appResult.rows.length === 0) {
1433 return res.status(404).json({ error: 'App not found' });
1434 }
1435
1436 const doService = new DigitalOceanService(req.tenant.id);
1437 const deployment = await doService.createDeployment(
1438 appResult.rows[0].do_app_id,
1439 force_rebuild || false
1440 );
1441
1442 // Record deployment in database
1443 await pool.query(
1444 `INSERT INTO hosting_deployments (
1445 app_id, do_deployment_id, status, started_at
1446 ) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)`,
1447 [id, deployment.id, deployment.phase]
1448 );
1449
1450 res.json({
1451 message: 'Deployment triggered successfully',
1452 deployment
1453 });
1454 } catch (error) {
1455 console.error('[Hosting] Error triggering deployment:', error);
1456 res.status(500).json({ error: error.message });
1457 }
1458});
1459
1460/**
1461 * @api {get} /api/hosting/apps/:id/deployments Get app deployment history
1462 * @apiName GetAppDeployments
1463 * @apiGroup Hosting
1464 * @apiDescription Get deployment history for an app (last 50 deployments).
1465 * @apiParam {UUID} id App ID (internal database ID)
1466 * @apiSuccess {Array} deployments Array of deployment objects
1467 * @apiSuccess {UUID} deployments.deployment_id Internal deployment ID
1468 * @apiSuccess {string} deployments.do_deployment_id DigitalOcean deployment ID
1469 * @apiSuccess {string} deployments.status Deployment status/phase
1470 * @apiSuccess {DateTime} deployments.started_at Deployment start time
1471 * @apiSuccess {DateTime} [deployments.completed_at] Deployment completion time
1472 * @apiError {string} error="Failed to fetch deployments" (500) Database query failed
1473 * @apiExample {curl} Example:
1474 * curl -X GET http://localhost:3000/api/hosting/apps/123e4567-e89b-12d3-a456-426614174000/deployments \
1475 * -H "Authorization: Bearer YOUR_TOKEN"
1476 * @apiSuccessExample {json} Success-Response:
1477 * HTTP/1.1 200 OK
1478 * {
1479 * "deployments": [
1480 * {
1481 * "deployment_id": "123e4567-e89b-12d3-a456-426614174000",
1482 * "do_deployment_id": "deploy-abc123",
1483 * "status": "ACTIVE",
1484 * "started_at": "2026-03-12T10:30:00.000Z",
1485 * "completed_at": "2026-03-12T10:35:00.000Z"
1486 * },
1487 * {
1488 * "deployment_id": "456e7890-ab12-34cd-5678-901234567890",
1489 * "do_deployment_id": "deploy-def456",
1490 * "status": "ACTIVE",
1491 * "started_at": "2026-03-11T15:20:00.000Z",
1492 * "completed_at": "2026-03-11T15:24:00.000Z"
1493 * }
1494 * ]
1495 * }
1496 */
1497router.get('/apps/:id/deployments', async (req, res) => {
1498 const { id } = req.params;
1499
1500 try {
1501 const result = await pool.query(
1502 `SELECT * FROM hosting_deployments
1503 WHERE app_id = $1
1504 ORDER BY started_at DESC
1505 LIMIT 50`,
1506 [id]
1507 );
1508
1509 res.json({ deployments: result.rows });
1510 } catch (error) {
1511 console.error('[Hosting] Error fetching deployments:', error);
1512 res.status(500).json({ error: 'Failed to fetch deployments' });
1513 }
1514});
1515
1516/**
1517 * @api {post} /api/hosting/apps/rename-all Rename all apps with prefixes
1518 * @apiName RenameAllApps
1519 * @apiGroup Hosting
1520 * @apiDescription Batch rename all DigitalOcean apps with appropriate prefixes (wordpress- or
1521 * nodejs-) and assign them to respective projects. WordPress apps detected by GitHub repo.
1522 * Also syncs renamed apps to database. Use for initial organization or cleanup.
1523 * @apiSuccess {boolean} success=true Operation successful
1524 * @apiSuccess {string} message Summary message
1525 * @apiSuccess {object} results Operation results
1526 * @apiSuccess {number} results.total Total apps processed
1527 * @apiSuccess {Array} results.renamed Apps that were renamed
1528 * @apiSuccess {Array} results.skipped Apps skipped (already have prefix)
1529 * @apiSuccess {Array} results.organized Apps assigned to projects
1530 * @apiSuccess {Array} results.errors Errors encountered
1531 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1532 * @apiError {string} error (500) Batch operation failed
1533 * @apiExample {curl} Example:
1534 * curl -X POST http://localhost:3000/api/hosting/apps/rename-all \
1535 * -H "Authorization: Bearer YOUR_TOKEN"
1536 * @apiSuccessExample {json} Success-Response:
1537 * HTTP/1.1 200 OK
1538 * {
1539 * "success": true,
1540 * "message": "Renamed 5 apps, organized 15 into projects",
1541 * "results": {
1542 * "total": 15,
1543 * "renamed": [
1544 * {
1545 * "appId": "a1b2c3d4",
1546 * "oldName": "example-site",
1547 * "newName": "wordpress-example-site",
1548 * "type": "wordpress"
1549 * }
1550 * ],
1551 * "skipped": [
1552 * {"appId": "b2c3d4e5", "name": "nodejs-api", "reason": "Already has prefix"}
1553 * ],
1554 * "organized": [
1555 * {"appId": "a1b2c3d4", "name": "wordpress-example", "project": "WordPress"}
1556 * ],
1557 * "errors": []
1558 * }
1559 * }
1560 */
1561router.post('/apps/rename-all', async (req, res) => {
1562 try {
1563 if (!req.tenant || !req.tenant.id) {
1564 return res.status(400).json({ error: 'Tenant context not available' });
1565 }
1566
1567 const doService = new DigitalOceanService(req.tenant.id);
1568 const apps = await doService.listApps();
1569
1570 // Get WordPress and Node.js projects
1571 let wordpressProjectId = null;
1572 let nodejsProjectId = null;
1573
1574 try {
1575 const projects = await doService.listProjects();
1576
1577 const wordpressProject = projects.find(p =>
1578 p.name.toLowerCase().includes('wordpress')
1579 );
1580 const nodejsProject = projects.find(p =>
1581 p.name.toLowerCase().includes('nodejs') || p.name.toLowerCase().includes('node.js')
1582 );
1583
1584 if (wordpressProject) {
1585 wordpressProjectId = wordpressProject.id;
1586 console.log(`[Hosting] Using WordPress project: ${wordpressProject.name}`);
1587 }
1588 if (nodejsProject) {
1589 nodejsProjectId = nodejsProject.id;
1590 console.log(`[Hosting] Using Node.js project: ${nodejsProject.name}`);
1591 }
1592 } catch (error) {
1593 console.warn('[Hosting] Could not fetch projects:', error.message);
1594 }
1595
1596 const results = {
1597 total: apps.length,
1598 renamed: [],
1599 skipped: [],
1600 organized: [],
1601 errors: []
1602 };
1603
1604 for (const app of apps) {
1605 try {
1606 const currentName = app.spec?.name || 'unknown';
1607
1608 // Detect app type based on GitHub repo
1609 const githubRepo = app.spec?.services?.[0]?.github?.repo || '';
1610 const isWordPress = githubRepo.includes('wordpress-egg') || githubRepo.includes('wordpress');
1611 const projectId = isWordPress ? wordpressProjectId : nodejsProjectId;
1612
1613 // Check if needs renaming
1614 const needsRename = !currentName.startsWith('wordpress-') && !currentName.startsWith('nodejs-');
1615
1616 if (needsRename) {
1617 // Determine new name
1618 const prefix = isWordPress ? 'wordpress-' : 'nodejs-';
1619 const newName = `${prefix}${currentName}`;
1620
1621 // Update app spec with new name
1622 const updatedSpec = {
1623 ...app.spec,
1624 name: newName
1625 };
1626
1627 // Update app in DigitalOcean
1628 await doService.updateApp(app.id, updatedSpec);
1629
1630 results.renamed.push({
1631 appId: app.id,
1632 oldName: currentName,
1633 newName: newName,
1634 type: isWordPress ? 'wordpress' : 'nodejs'
1635 });
1636
1637 console.log(`[Hosting] Renamed app ${app.id}: ${currentName} -> ${newName}`);
1638 } else {
1639 results.skipped.push({
1640 appId: app.id,
1641 name: currentName,
1642 reason: 'Already has prefix'
1643 });
1644 }
1645
1646 // Assign app to appropriate project
1647 if (projectId) {
1648 try {
1649 await doService.apiRequest('POST', `/projects/${projectId}/resources`, {
1650 resources: [`do:app:${app.id}`]
1651 });
1652
1653 results.organized.push({
1654 appId: app.id,
1655 name: currentName,
1656 project: isWordPress ? 'WordPress' : 'Node.js'
1657 });
1658
1659 console.log(`[Hosting] Assigned app ${app.id} to ${isWordPress ? 'WordPress' : 'Node.js'} project`);
1660 } catch (error) {
1661 console.warn(`[Hosting] Could not assign app ${app.id} to project:`, error.message);
1662 }
1663 }
1664 } catch (error) {
1665 console.error(`[Hosting] Error processing app ${app.id}:`, error);
1666 results.errors.push({
1667 appId: app.id,
1668 name: app.spec?.name || 'unknown',
1669 error: error.message
1670 });
1671 }
1672 }
1673
1674 // Trigger a sync to update database
1675 if (results.renamed.length > 0) {
1676 try {
1677 await doService.getAllResources().then(async (resources) => {
1678 for (const app of resources.apps) {
1679 await pool.query(
1680 `INSERT INTO hosting_apps (
1681 tenant_id, do_app_id, app_name, app_type, status, region,
1682 live_url, default_domain, custom_domains, metadata,
1683 created_at, updated_at
1684 )
1685 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
1686 ON CONFLICT (do_app_id)
1687 DO UPDATE SET
1688 app_name = EXCLUDED.app_name,
1689 status = EXCLUDED.status,
1690 updated_at = CURRENT_TIMESTAMP`,
1691 [
1692 req.tenant.id,
1693 app.id,
1694 app.spec?.name || 'Unknown App',
1695 app.spec?.services?.[0]?.github?.repo?.includes('wordpress') ? 'wordpress' : 'nodejs',
1696 app.live_url_base ? 'active' : 'pending',
1697 app.region,
1698 app.live_url,
1699 app.default_ingress,
1700 JSON.stringify(app.spec?.domains || []),
1701 JSON.stringify(app)
1702 ]
1703 );
1704 }
1705 });
1706 console.log('[Hosting] Database synced after rename-all');
1707 } catch (err) {
1708 console.error('[Hosting] Error syncing after rename:', err);
1709 }
1710 }
1711
1712 res.json({
1713 success: true,
1714 message: `Renamed ${results.renamed.length} apps, organized ${results.organized.length} into projects`,
1715 results
1716 });
1717 } catch (error) {
1718 console.error('[Hosting] Error in rename-all:', error);
1719 res.status(500).json({ error: error.message });
1720 }
1721});
1722
1723/**
1724 * @api {post} /api/hosting/apps/organize Organize apps into projects
1725 * @apiName OrganizeApps
1726 * @apiGroup Hosting
1727 * @apiDescription Assign all apps to their appropriate DigitalOcean projects without renaming.
1728 * WordPress apps assigned to WordPress project, Node.js apps to Node.js project.
1729 * Requires projects to exist in DigitalOcean.
1730 * @apiSuccess {boolean} success=true Operation successful
1731 * @apiSuccess {string} message Summary message
1732 * @apiSuccess {object} results Operation results
1733 * @apiSuccess {number} results.total Total apps processed
1734 * @apiSuccess {Array} results.organized Apps successfully organized
1735 * @apiSuccess {Array} results.skipped Apps skipped (no matching project)
1736 * @apiSuccess {Array} results.errors Errors encountered
1737 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1738 * @apiError {string} error="No WordPress or Node.js projects found" (404) Required projects missing
1739 * @apiError {string} error (500) Batch operation failed
1740 * @apiExample {curl} Example:
1741 * curl -X POST http://localhost:3000/api/hosting/apps/organize \
1742 * -H "Authorization: Bearer YOUR_TOKEN"
1743 * @apiSuccessExample {json} Success-Response:
1744 * HTTP/1.1 200 OK
1745 * {
1746 * "success": true,
1747 * "message": "Organized 15 apps into projects",
1748 * "results": {
1749 * "total": 15,
1750 * "organized": [
1751 * {"appId": "a1b2c3d4", "name": "wordpress-example", "project": "WordPress"},
1752 * {"appId": "b2c3d4e5", "name": "nodejs-api", "project": "Node.js"}
1753 * ],
1754 * "skipped": [],
1755 * "errors": []
1756 * }
1757 * }
1758 */
1759router.post('/apps/organize', async (req, res) => {
1760 try {
1761 if (!req.tenant || !req.tenant.id) {
1762 return res.status(400).json({ error: 'Tenant context not available' });
1763 }
1764
1765 const doService = new DigitalOceanService(req.tenant.id);
1766 const apps = await doService.listApps();
1767
1768 // Get WordPress and Node.js projects
1769 let wordpressProjectId = null;
1770 let nodejsProjectId = null;
1771
1772 try {
1773 const projects = await doService.listProjects();
1774
1775 const wordpressProject = projects.find(p =>
1776 p.name.toLowerCase().includes('wordpress')
1777 );
1778 const nodejsProject = projects.find(p =>
1779 p.name.toLowerCase().includes('nodejs') || p.name.toLowerCase().includes('node.js')
1780 );
1781
1782 if (wordpressProject) {
1783 wordpressProjectId = wordpressProject.id;
1784 console.log(`[Hosting] Using WordPress project: ${wordpressProject.name}`);
1785 } else {
1786 console.warn('[Hosting] No WordPress project found');
1787 }
1788
1789 if (nodejsProject) {
1790 nodejsProjectId = nodejsProject.id;
1791 console.log(`[Hosting] Using Node.js project: ${nodejsProject.name}`);
1792 } else {
1793 console.warn('[Hosting] No Node.js project found');
1794 }
1795
1796 if (!wordpressProjectId && !nodejsProjectId) {
1797 return res.status(404).json({
1798 error: 'No WordPress or Node.js projects found in DigitalOcean'
1799 });
1800 }
1801 } catch (error) {
1802 console.error('[Hosting] Error fetching projects:', error);
1803 return res.status(500).json({ error: 'Could not fetch DigitalOcean projects' });
1804 }
1805
1806 const results = {
1807 total: apps.length,
1808 organized: [],
1809 skipped: [],
1810 errors: []
1811 };
1812
1813 for (const app of apps) {
1814 try {
1815 const appName = app.spec?.name || 'unknown';
1816
1817 // Detect app type based on name or GitHub repo
1818 const githubRepo = app.spec?.services?.[0]?.github?.repo || '';
1819 const isWordPress = appName.includes('wordpress') ||
1820 githubRepo.includes('wordpress-egg') ||
1821 githubRepo.includes('wordpress');
1822
1823 const projectId = isWordPress ? wordpressProjectId : nodejsProjectId;
1824 const projectType = isWordPress ? 'WordPress' : 'Node.js';
1825
1826 if (!projectId) {
1827 results.skipped.push({
1828 appId: app.id,
1829 name: appName,
1830 reason: `No ${projectType} project found`
1831 });
1832 continue;
1833 }
1834
1835 // Assign app to appropriate project
1836 await doService.apiRequest('POST', `/projects/${projectId}/resources`, {
1837 resources: [`do:app:${app.id}`]
1838 });
1839
1840 results.organized.push({
1841 appId: app.id,
1842 name: appName,
1843 project: projectType
1844 });
1845
1846 console.log(`[Hosting] Assigned app ${app.id} (${appName}) to ${projectType} project`);
1847
1848 } catch (error) {
1849 console.error(`[Hosting] Error organizing app ${app.id}:`, error);
1850 results.errors.push({
1851 appId: app.id,
1852 name: app.spec?.name || 'unknown',
1853 error: error.message
1854 });
1855 }
1856 }
1857
1858 res.json({
1859 success: true,
1860 message: `Organized ${results.organized.length} apps into projects`,
1861 results
1862 });
1863 } catch (error) {
1864 console.error('[Hosting] Error in organize:', error);
1865 res.status(500).json({ error: error.message });
1866 }
1867});
1868
1869/**
1870 * GET /api/hosting/apps
1871 * Get all apps from database (cached, synced every 5 minutes)
1872 * Excludes WordPress apps
1873 */
1874router.get('/apps', async (req, res) => {
1875 try {
1876 if (!req.tenant || !req.tenant.id) {
1877 return res.status(400).json({ error: 'Tenant context not available' });
1878 }
1879
1880 // Get last sync time
1881 const syncResult = await pool.query(
1882 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
1883 [req.tenant.id]
1884 );
1885 const lastSync = syncResult.rows[0]?.last_sync_at;
1886
1887 // Root tenant sees all apps, other tenants only see their assigned apps
1888 // Exclude WordPress apps (identified by app_name containing 'wordpress')
1889 const isRootTenant = req.tenant.id === '00000000-0000-0000-0000-000000000001';
1890 const result = await pool.query(
1891 `SELECT
1892 ha.*,
1893 c.name as customer_name
1894 FROM hosting_apps ha
1895 LEFT JOIN customers c ON ha.customer_id = c.customer_id
1896 WHERE ha.app_name NOT ILIKE '%wordpress%'
1897 ${isRootTenant ? '' : 'AND (ha.tenant_id = $1 OR ha.assigned_tenant_id = $1)'}
1898 ORDER BY ha.created_at DESC`,
1899 isRootTenant ? [] : [req.tenant.id]
1900 );
1901
1902 res.json({
1903 apps: result.rows,
1904 lastSync,
1905 ttl: '5 minutes'
1906 });
1907 } catch (error) {
1908 console.error('[Hosting] Error fetching apps:', error);
1909 if (error.code === '42P01') {
1910 return res.json({ apps: [], lastSync: null, ttl: '5 minutes' });
1911 }
1912 res.status(500).json({ error: 'Failed to fetch apps' });
1913 }
1914});
1915
1916/**
1917 * @api {get} /api/hosting/database/:id/metrics Get database metrics
1918 * @apiName GetDatabaseMetrics
1919 * @apiGroup Hosting
1920 * @apiDescription Fetch current metrics for a managed database cluster from DigitalOcean API.
1921 * Returns CPU count, memory usage, and disk usage.
1922 * @apiParam {string} id DigitalOcean database cluster ID (do_database_id)
1923 * @apiSuccess {object} metrics Database metrics object
1924 * @apiSuccess {number} metrics.cpu_count Number of CPUs
1925 * @apiSuccess {number} metrics.memory_mb Memory usage in MB
1926 * @apiSuccess {number} metrics.disk_gb Disk usage in GB
1927 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1928 * @apiError {string} error (500) Metrics fetch failed
1929 * @apiExample {curl} Example:
1930 * curl -X GET http://localhost:3000/api/hosting/database/a1b2c3d4-e5f6-7890-abcd-ef1234567890/metrics \
1931 * -H "Authorization: Bearer YOUR_TOKEN"
1932 * @apiSuccessExample {json} Success-Response:
1933 * HTTP/1.1 200 OK
1934 * {
1935 * "metrics": {
1936 * "cpu_count": 2,
1937 * "memory_mb": 3845,
1938 * "disk_gb": 12.5
1939 * }
1940 * }
1941 */
1942router.get('/database/:id/metrics', async (req, res) => {
1943 try {
1944 if (!req.tenant || !req.tenant.id) {
1945 return res.status(400).json({ error: 'Tenant context not available' });
1946 }
1947
1948 const { id } = req.params;
1949 const doService = new DigitalOceanService(req.tenant.id);
1950 const metrics = await doService.getDatabaseMetrics(id);
1951
1952 res.json({ metrics });
1953 } catch (error) {
1954 console.error('[Hosting] Error fetching database metrics:', error);
1955 res.status(500).json({ error: error.message });
1956 }
1957});
1958
1959/**
1960 * @api {get} /api/hosting/droplet/:id/metrics Get droplet metrics
1961 * @apiName GetDropletMetrics
1962 * @apiGroup Hosting
1963 * @apiDescription Fetch time-series metrics for a Droplet from DigitalOcean Monitoring API.
1964 * Supports CPU, memory, disk, and network metrics with custom time ranges.
1965 * @apiParam {string} id DigitalOcean droplet ID (numeric)
1966 * @apiParam {string} [metric=cpu] Metric type (cpu, memory_available, disk_read, disk_write, public_inbound, public_outbound)
1967 * @apiParam {number} [start] Unix timestamp for start of time range
1968 * @apiParam {number} [end] Unix timestamp for end of time range
1969 * @apiSuccess {object} metrics Prometheus-style metrics object
1970 * @apiSuccess {string} metrics.status="success"
1971 * @apiSuccess {object} metrics.data Metrics data
1972 * @apiSuccess {Array} metrics.data.result Array of metric result objects
1973 * @apiError {string} error="Tenant context not available" (400) No tenant in request
1974 * @apiError {string} error (500) Metrics fetch failed
1975 * @apiExample {curl} Example:
1976 * curl -X GET "http://localhost:3000/api/hosting/droplet/123456789/metrics?metric=cpu&start=1678886400&end=1678972800" \
1977 * -H "Authorization: Bearer YOUR_TOKEN"
1978 * @apiSuccessExample {json} Success-Response:
1979 * HTTP/1.1 200 OK
1980 * {
1981 * "metrics": {
1982 * "status": "success",
1983 * "data": {
1984 * "resultType": "matrix",
1985 * "result": [
1986 * {
1987 * "metric": {"host_id": "123456789"},
1988 * "values": [
1989 * [1678886400, "35.5"],
1990 * [1678890000, "42.3"]
1991 * ]
1992 * }
1993 * ]
1994 * }
1995 * }
1996 * }
1997 */
1998router.get('/droplet/:id/metrics', async (req, res) => {
1999 try {
2000 if (!req.tenant || !req.tenant.id) {
2001 return res.status(400).json({ error: 'Tenant context not available' });
2002 }
2003
2004 const { id } = req.params;
2005 const { metric = 'cpu', start, end } = req.query;
2006
2007 const doService = new DigitalOceanService(req.tenant.id);
2008 const metrics = await doService.getDropletMetrics(
2009 parseInt(id),
2010 metric,
2011 start ? parseInt(start) : null,
2012 end ? parseInt(end) : null
2013 );
2014
2015 res.json({ metrics });
2016 } catch (error) {
2017 console.error('[Hosting] Error fetching droplet metrics:', error);
2018 res.status(500).json({ error: error.message });
2019 }
2020});
2021
2022/**
2023 * @api {get} /api/hosting/app/:id/metrics Get app metrics
2024 * @apiName GetAppMetrics
2025 * @apiGroup Hosting
2026 * @apiDescription Fetch metrics for a DigitalOcean App Platform app. Returns CPU, memory,
2027 * and bandwidth metrics for the app's components.
2028 * @apiParam {string} id DigitalOcean app ID (do_app_id)
2029 * @apiSuccess {object} metrics App metrics object
2030 * @apiSuccess {Array} metrics.components Array of component metrics
2031 * @apiSuccess {string} metrics.components.name Component name
2032 * @apiSuccess {object} metrics.components.metrics Metrics for component
2033 * @apiError {string} error="Tenant context not available" (400) No tenant in request
2034 * @apiError {string} error (500) Metrics fetch failed
2035 * @apiExample {curl} Example:
2036 * curl -X GET http://localhost:3000/api/hosting/app/a1b2c3d4-e5f6-7890-abcd-ef1234567890/metrics \
2037 * -H "Authorization: Bearer YOUR_TOKEN"
2038 * @apiSuccessExample {json} Success-Response:
2039 * HTTP/1.1 200 OK
2040 * {
2041 * "metrics": {
2042 * "components": [
2043 * {
2044 * "name": "web",
2045 * "metrics": {
2046 * "cpu_percentage": 25.5,
2047 * "memory_percentage": 60.2,
2048 * "restart_count": 0
2049 * }
2050 * }
2051 * ]
2052 * }
2053 * }
2054 */
2055router.get('/app/:id/metrics', async (req, res) => {
2056 try {
2057 if (!req.tenant || !req.tenant.id) {
2058 return res.status(400).json({ error: 'Tenant context not available' });
2059 }
2060
2061 const { id } = req.params;
2062 const doService = new DigitalOceanService(req.tenant.id);
2063 const metrics = await doService.getAppMetrics(id);
2064
2065 res.json({ metrics });
2066 } catch (error) {
2067 console.error('[Hosting] Error fetching app metrics:', error);
2068 res.status(500).json({ error: error.message });
2069 }
2070});
2071
2072module.exports = router;