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
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.
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');
26// Apply authentication and tenant context to all routes
27router.use(authenticateToken, setTenantContext);
29// ===========================
30// Configuration Management
31// ===========================
34 * @api {get} /hosting/config Get DigitalOcean configuration
35 * @apiName GetHostingConfig
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
48router.get('/config', async (req, res) => {
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`,
57 if (result.rows.length === 0) {
58 return res.status(404).json({ error: 'DigitalOcean configuration not found' });
61 res.json({ config: result.rows[0] });
63 console.error('[Hosting] Error fetching config:', error);
64 res.status(500).json({ error: 'Failed to fetch configuration' });
69 * @api {put} /hosting/config Update DigitalOcean configuration
70 * @apiName UpdateHostingConfig
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
81router.put('/config', async (req, res) => {
82 const { api_token, team_id } = req.body;
85 return res.status(400).json({ error: 'API token is required' });
89 // TODO: Encrypt api_token before storing
90 const encrypted_token = api_token; // Implement encryption
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)
97 api_token_encrypted = EXCLUDED.api_token_encrypted,
98 team_id = EXCLUDED.team_id,
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]
106 message: 'DigitalOcean configuration saved successfully',
107 config: result.rows[0]
110 console.error('[Hosting] Error saving config:', error);
111 res.status(500).json({ error: 'Failed to save configuration' });
116 * @api {post} /hosting/config/test Test DigitalOcean API connection
117 * @apiName TestHostingConnection
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
126router.post('/config/test', async (req, res) => {
128 const doService = new DigitalOceanService(req.tenant.id);
129 const result = await doService.testConnection();
132 console.error('[Hosting] Connection test failed:', error);
133 res.status(500).json({
141 * @api {get} /hosting/projects List all DigitalOcean projects
142 * @apiName ListProjects
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
149router.get('/projects', async (req, res) => {
151 const doService = new DigitalOceanService(req.tenant.id);
152 const projects = await doService.listProjects();
153 res.json({ projects });
155 console.error('[Hosting] Error fetching projects:', error);
156 res.status(500).json({ error: error.message });
160// ===========================
162// ===========================
165 * @api {get} /hosting/apps List all hosting apps for tenant
166 * @apiName ListHostingApps
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
181router.get('/apps', async (req, res) => {
183 if (!req.tenant || !req.tenant.id) {
184 return res.status(400).json({ error: 'Tenant context not available' });
187 // Get last sync time
188 const syncResult = await pool.query(
189 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
192 const lastSync = syncResult.rows[0]?.last_sync_at;
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(
199 c.name as customer_name,
200 ct.title as contract_name
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]
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: [] });
220 res.status(500).json({ error: 'Failed to fetch apps' });
225 * @api {get} /api/hosting/apps/:id Get app details
226 * @apiName GetHostingApp
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):
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",
260 * "live_url": "https://wordpress-example-site-abc123.ondigitalocean.app",
261 * "customer_name": "Acme Corp",
262 * "contract_name": "Managed Hosting Plan",
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"
273router.get('/apps/:id', async (req, res) => {
274 const { id } = req.params;
277 const result = await pool.query(
280 c.name as customer_name,
281 ct.title as contract_name
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)`,
289 if (result.rows.length === 0) {
290 return res.status(404).json({ error: 'App not found' });
293 const app = result.rows[0];
295 // If this is a WordPress app, fetch WordPress-specific details
296 if (app.app_name && app.app_name.startsWith('wordpress-')) {
298 const wpResult = await pool.query(
307 WHERE app_id = $1 AND tenant_id = $2`,
308 [app.do_app_id, req.tenant.id]
311 if (wpResult.rows.length > 0) {
312 app.wordpress = wpResult.rows[0];
315 console.error('[Hosting] Error fetching WordPress details:', wpError);
316 // Continue without WordPress details if query fails
322 console.error('[Hosting] Error fetching app:', error);
323 res.status(500).json({ error: 'Failed to fetch app' });
328 * @api {put} /api/hosting/apps/:id Update app assignments
329 * @apiName UpdateHostingApp
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" \
347 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
348 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab"
350 * @apiSuccessExample {json} Success-Response:
354 * "message": "App updated successfully",
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"
364router.put('/apps/:id', async (req, res) => {
365 const { id } = req.params;
366 const { customer_id, contract_id, assigned_tenant_id } = req.body;
370 const result = await pool.query(
372 SET customer_id = $1,
374 assigned_tenant_id = $3,
375 updated_at = CURRENT_TIMESTAMP
376 WHERE app_id = $4 AND tenant_id = $5
378 [customer_id || null, contract_id || null, assigned_tenant_id || null, id, req.tenant.id]
381 if (result.rows.length === 0) {
382 return res.status(404).json({ error: 'App not found' });
387 message: 'App updated successfully',
391 console.error('[Hosting] Error updating app:', error);
392 res.status(500).json({ error: 'Failed to update app' });
397 * @api {post} /api/hosting/sync Sync DigitalOcean resources
398 * @apiName SyncHostingResources
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:
422 * "message": "DigitalOcean resources synced successfully",
436router.post('/sync', async (req, res) => {
438 if (!req.tenant || !req.tenant.id) {
439 return res.status(400).json({ error: 'Tenant context not available' });
441 const doService = new DigitalOceanService(req.tenant.id);
442 const resources = await doService.getAllResources();
451 // =====================
453 // =====================
454 const activeAppIds = resources.apps.map(app => app.id);
456 for (const app of resources.apps) {
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
463 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
464 ON CONFLICT (do_app_id)
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`,
477 app.spec?.name || 'Unknown App',
478 app.spec?.services?.[0]?.source_dir ? 'nodejs' : 'static',
479 app.live_url_base ? 'active' : 'pending',
483 JSON.stringify(app.spec?.domains || []),
490 // Mark apps as deleted if they no longer exist in DO
491 if (activeAppIds.length > 0) {
494 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
496 AND do_app_id NOT IN (${activeAppIds.map((_, i) => `$${i + 2}`).join(', ')})
497 AND status != 'deleted'`,
498 [req.tenant.id, ...activeAppIds]
502 // =====================
503 // Sync Databases + Metrics
504 // =====================
505 const databases = resources.databases || [];
506 const activeDatabaseIds = databases.map(db => db.id);
508 for (const db of databases) {
509 // Fetch metrics for this database
512 metrics = await doService.getDatabaseMetrics(db.id);
515 console.error(`[Hosting] Failed to fetch metrics for database ${db.id}:`, err.message);
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
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)
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`,
552 db.connection?.host || null,
553 db.connection?.port || null,
554 db.connection?.database || null,
555 db.connection?.user || null,
557 metrics?.cpu_count || null,
558 metrics?.memory_mb || null,
559 metrics?.disk_gb || null,
560 metrics ? new Date() : null,
565 // Store metrics history for trends
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]
575 syncStats.databases++;
578 // Mark databases as deleted if they no longer exist
579 if (activeDatabaseIds.length > 0) {
581 `UPDATE hosting_databases
582 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
584 AND do_database_id NOT IN (${activeDatabaseIds.map((_, i) => `$${i + 2}`).join(', ')})
585 AND status != 'deleted'`,
586 [req.tenant.id, ...activeDatabaseIds]
590 // =====================
591 // Sync Droplets + Metrics
592 // =====================
593 const droplets = resources.droplets || [];
594 const activeDropletIds = droplets.map(d => d.id.toString());
596 for (const droplet of droplets) {
597 // Fetch CPU metrics for this droplet (last hour average)
598 let cpuMetrics = null;
600 cpuMetrics = await doService.getDropletMetrics(droplet.id, 'cpu');
603 // Calculate average CPU from last hour
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;
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
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)
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`,
634 droplet.id.toString(),
639 droplet.networks?.v4?.[0]?.ip_address || null,
640 droplet.networks?.v6?.[0]?.ip_address || null,
644 droplet.image?.slug || droplet.image?.name || null,
648 JSON.stringify(droplet)
652 // Store metrics history
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]
663 console.error(`[Hosting] Failed to fetch metrics for droplet ${droplet.id}:`, err.message);
665 // Still save droplet without metrics
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
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)
675 droplet_name = EXCLUDED.droplet_name,
676 status = EXCLUDED.status,
677 metadata = EXCLUDED.metadata,
678 updated_at = CURRENT_TIMESTAMP`,
681 droplet.id.toString(),
686 droplet.networks?.v4?.[0]?.ip_address || null,
687 droplet.networks?.v6?.[0]?.ip_address || null,
691 droplet.image?.slug || droplet.image?.name || null,
693 JSON.stringify(droplet)
698 syncStats.droplets++;
701 // Mark droplets as deleted if they no longer exist
702 if (activeDropletIds.length > 0) {
704 `UPDATE hosting_droplets
705 SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
707 AND do_droplet_id NOT IN (${activeDropletIds.map((_, i) => `$${i + 2}`).join(', ')})
708 AND status != 'deleted'`,
709 [req.tenant.id, ...activeDropletIds]
713 // Update last sync time
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`,
723 message: 'DigitalOcean resources synced successfully',
724 summary: resources.summary,
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.'
735 res.status(500).json({ error: error.message || 'Failed to sync resources' });
739// ===========================
740// Live DO API Queries
741// ===========================
744 * @api {get} /api/hosting/do/apps Get live DO apps
745 * @apiName GetLiveDigitalOceanApps
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:
760 * "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
762 * "name": "wordpress-example",
765 * "live_url": "https://wordpress-example-abc123.ondigitalocean.app",
766 * "created_at": "2026-01-15T08:00:00Z"
771router.get('/do/apps', async (req, res) => {
773 if (!req.tenant || !req.tenant.id) {
774 return res.status(400).json({ error: 'Tenant context not available' });
776 const doService = new DigitalOceanService(req.tenant.id);
777 const apps = await doService.listApps();
780 console.error('[Hosting] Error fetching DO apps:', error);
781 res.status(500).json({ error: error.message });
786 * @api {get} /api/hosting/droplets List droplets
787 * @apiName ListHostingDroplets
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:
816 * "droplet_id": "123e4567-e89b-12d3-a456-426614174000",
817 * "do_droplet_id": "123456789",
818 * "droplet_name": "web-server-01",
819 * "status": "active",
821 * "size": "s-2vcpu-4gb",
822 * "ip_address": "192.0.2.100",
826 * "cpu_usage_percent": 35.5,
827 * "customer_name": "Acme Corp"
830 * "lastSync": "2026-03-12T10:25:00.000Z",
834router.get('/droplets', async (req, res) => {
836 if (!req.tenant || !req.tenant.id) {
837 return res.status(400).json({ error: 'Tenant context not available' });
840 // Get last sync time
841 const syncResult = await pool.query(
842 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
845 const lastSync = syncResult.rows[0]?.last_sync_at;
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(
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]
861 droplets: result.rows,
866 console.error('[Hosting] Error fetching droplets:', error);
867 if (error.code === '42P01') {
868 return res.json({ droplets: [], lastSync: null, ttl: '5 minutes' });
870 res.status(500).json({ error: 'Failed to fetch droplets' });
875 * @api {get} /api/hosting/databases List databases
876 * @apiName ListHostingDatabases
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
901 * @apiExample {curl} Example:
902 * curl -X GET http://localhost:3000/api/hosting/databases \
903 * -H "Authorization: Bearer YOUR_TOKEN"
905 * @apiSuccessExample {json} Success-Response:
910 * "database_id": "123e4567-e89b-12d3-a456-426614174000",
911 * "do_database_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
912 * "database_name": "production-pg",
915 * "status": "online",
917 * "size": "db-s-2vcpu-4gb",
919 * "connection_host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
920 * "connection_port": 25060,
924 * "customer_name": "Acme Corp"
927 * "lastSync": "2026-03-12T10:25:00.000Z",
931router.get('/databases', async (req, res) => {
933 if (!req.tenant || !req.tenant.id) {
934 return res.status(400).json({ error: 'Tenant context not available' });
937 // Get last sync time
938 const syncResult = await pool.query(
939 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
942 const lastSync = syncResult.rows[0]?.last_sync_at;
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(
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]
958 databases: result.rows,
963 console.error('[Hosting] Error fetching databases:', error);
964 if (error.code === '42P01') {
965 return res.json({ databases: [], lastSync: null, ttl: '5 minutes' });
967 res.status(500).json({ error: 'Failed to fetch databases' });
972 * @api {get} /api/hosting/databases/:id Get database details
973 * @apiName GetHostingDatabase
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:
1001 * "database_id": "123e4567-e89b-12d3-a456-426614174000",
1002 * "do_database_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1003 * "database_name": "production-pg",
1006 * "status": "online",
1007 * "connection_host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
1008 * "connection_port": 25060,
1009 * "connection_user": "doadmin",
1011 * "memory_mb": 3845,
1013 * "customer_name": "Acme Corp"
1017router.get('/databases/:id', async (req, res) => {
1018 const { id } = req.params;
1021 const result = await pool.query(
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`,
1031 if (result.rows.length === 0) {
1032 return res.status(404).json({ error: 'Database not found' });
1035 const database = result.rows[0];
1036 res.json({ database });
1038 console.error('[Hosting] Error fetching database:', error);
1039 res.status(500).json({ error: 'Failed to fetch database' });
1044 * @api {put} /api/hosting/databases/:id Update database assignments
1045 * @apiName UpdateHostingDatabase
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" \
1063 * "customer_id": "789e0123-ab45-67cd-89ef-012345678901",
1064 * "contract_id": "abc12345-de67-89ab-cdef-0123456789ab"
1066 * @apiSuccessExample {json} Success-Response:
1070 * "message": "Database updated successfully",
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"
1079router.put('/databases/:id', async (req, res) => {
1080 const { id } = req.params;
1081 const { customer_id, contract_id, assigned_tenant_id } = req.body;
1084 // Update the database
1085 const result = await pool.query(
1086 `UPDATE hosting_databases
1087 SET customer_id = $1,
1089 assigned_tenant_id = $3,
1090 updated_at = CURRENT_TIMESTAMP
1091 WHERE database_id = $4 AND tenant_id = $5
1093 [customer_id || null, contract_id || null, assigned_tenant_id || null, id, req.tenant.id]
1096 if (result.rows.length === 0) {
1097 return res.status(404).json({ error: 'Database not found' });
1102 message: 'Database updated successfully',
1103 database: result.rows[0]
1106 console.error('[Hosting] Error updating database:', error);
1107 res.status(500).json({ error: 'Failed to update database' });
1112 * @api {get} /api/hosting/droplets/:id Get droplet details
1113 * @apiName GetHostingDroplet
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"
1139 * @apiSuccessExample {json} Success-Response:
1143 * "droplet_id": "123e4567-e89b-12d3-a456-426614174000",
1144 * "do_droplet_id": "123456789",
1145 * "droplet_name": "web-server-01",
1146 * "status": "active",
1148 * "size": "s-2vcpu-4gb",
1149 * "ip_address": "192.0.2.100",
1153 * "image": "ubuntu-22-04-x64",
1154 * "cpu_usage_percent": 35.5,
1155 * "customer_name": "Acme Corp"
1159router.get('/droplets/:id', async (req, res) => {
1160 const { id } = req.params;
1163 const result = await pool.query(
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`,
1173 if (result.rows.length === 0) {
1174 return res.status(404).json({ error: 'Droplet not found' });
1177 const droplet = result.rows[0];
1178 res.json({ droplet });
1180 console.error('[Hosting] Error fetching droplet:', error);
1181 res.status(500).json({ error: 'Failed to fetch droplet' });
1186 * @api {put} /api/hosting/droplets/:id Update droplet assignments
1187 * @apiName UpdateHostingDroplet
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:
1208 * "message": "Droplet updated successfully",
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"
1216router.put('/droplets/:id', async (req, res) => {
1217 const { id } = req.params;
1218 const { customer_id, assigned_tenant_id } = req.body;
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
1229 [customer_id || null, assigned_tenant_id || null, id, req.tenant.id]
1232 if (result.rows.length === 0) {
1233 return res.status(404).json({ error: 'Droplet not found' });
1238 message: 'Droplet updated successfully',
1239 droplet: result.rows[0]
1242 console.error('[Hosting] Error updating droplet:', error);
1243 res.status(500).json({ error: 'Failed to update droplet' });
1248 * @api {get} /api/hosting/do/droplets Get live DO droplets
1249 * @apiName GetLiveDigitalOceanDroplets
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:
1265 * "name": "web-server-01",
1266 * "status": "active",
1267 * "region": {"slug": "nyc3"},
1268 * "size_slug": "s-2vcpu-4gb",
1273 * "v4": [{"ip_address": "192.0.2.100"}]
1279router.get('/do/droplets', async (req, res) => {
1281 if (!req.tenant || !req.tenant.id) {
1282 return res.status(400).json({ error: 'Tenant context not available' });
1284 const doService = new DigitalOceanService(req.tenant.id);
1285 const droplets = await doService.listDroplets();
1286 res.json({ droplets });
1288 console.error('[Hosting] Error fetching DO droplets:', error);
1289 res.status(500).json({ error: error.message });
1294 * @api {get} /api/hosting/do/databases Get live DO databases
1295 * @apiName GetLiveDigitalOceanDatabases
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:
1310 * "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1311 * "name": "production-pg",
1314 * "status": "online",
1316 * "size": "db-s-2vcpu-4gb",
1319 * "host": "production-pg-do-user-123-0.b.db.ondigitalocean.com",
1326router.get('/do/databases', async (req, res) => {
1328 if (!req.tenant || !req.tenant.id) {
1329 return res.status(400).json({ error: 'Tenant context not available' });
1331 const doService = new DigitalOceanService(req.tenant.id);
1332 const databases = await doService.listDatabases();
1333 res.json({ databases });
1335 console.error('[Hosting] Error fetching DO databases:', error);
1336 res.status(500).json({ error: error.message });
1341 * @api {get} /api/hosting/do/resources Get all live DO resources
1342 * @apiName GetLiveDigitalOceanResources
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:
1362 * "databases": [...],
1363 * "droplets": [...],
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);
1378 console.error('[Hosting] Error fetching DO resources:', error);
1379 res.status(500).json({ error: error.message });
1383// ===========================
1385// ===========================
1388 * @api {post} /api/hosting/apps/:id/deploy Trigger app deployment
1389 * @apiName DeployHostingApp
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:
1410 * "message": "Deployment triggered successfully",
1412 * "id": "deploy-abc123",
1413 * "phase": "PENDING_BUILD",
1414 * "created_at": "2026-03-12T10:30:00Z",
1421router.post('/apps/:id/deploy', async (req, res) => {
1422 const { id } = req.params;
1423 const { force_rebuild } = req.body;
1427 const appResult = await pool.query(
1428 'SELECT do_app_id FROM hosting_apps WHERE app_id = $1 AND tenant_id = $2',
1432 if (appResult.rows.length === 0) {
1433 return res.status(404).json({ error: 'App not found' });
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
1442 // Record deployment in database
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]
1451 message: 'Deployment triggered successfully',
1455 console.error('[Hosting] Error triggering deployment:', error);
1456 res.status(500).json({ error: error.message });
1461 * @api {get} /api/hosting/apps/:id/deployments Get app deployment history
1462 * @apiName GetAppDeployments
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:
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"
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"
1497router.get('/apps/:id/deployments', async (req, res) => {
1498 const { id } = req.params;
1501 const result = await pool.query(
1502 `SELECT * FROM hosting_deployments
1504 ORDER BY started_at DESC
1509 res.json({ deployments: result.rows });
1511 console.error('[Hosting] Error fetching deployments:', error);
1512 res.status(500).json({ error: 'Failed to fetch deployments' });
1517 * @api {post} /api/hosting/apps/rename-all Rename all apps with prefixes
1518 * @apiName RenameAllApps
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:
1540 * "message": "Renamed 5 apps, organized 15 into projects",
1545 * "appId": "a1b2c3d4",
1546 * "oldName": "example-site",
1547 * "newName": "wordpress-example-site",
1548 * "type": "wordpress"
1552 * {"appId": "b2c3d4e5", "name": "nodejs-api", "reason": "Already has prefix"}
1555 * {"appId": "a1b2c3d4", "name": "wordpress-example", "project": "WordPress"}
1561router.post('/apps/rename-all', async (req, res) => {
1563 if (!req.tenant || !req.tenant.id) {
1564 return res.status(400).json({ error: 'Tenant context not available' });
1567 const doService = new DigitalOceanService(req.tenant.id);
1568 const apps = await doService.listApps();
1570 // Get WordPress and Node.js projects
1571 let wordpressProjectId = null;
1572 let nodejsProjectId = null;
1575 const projects = await doService.listProjects();
1577 const wordpressProject = projects.find(p =>
1578 p.name.toLowerCase().includes('wordpress')
1580 const nodejsProject = projects.find(p =>
1581 p.name.toLowerCase().includes('nodejs') || p.name.toLowerCase().includes('node.js')
1584 if (wordpressProject) {
1585 wordpressProjectId = wordpressProject.id;
1586 console.log(`[Hosting] Using WordPress project: ${wordpressProject.name}`);
1588 if (nodejsProject) {
1589 nodejsProjectId = nodejsProject.id;
1590 console.log(`[Hosting] Using Node.js project: ${nodejsProject.name}`);
1593 console.warn('[Hosting] Could not fetch projects:', error.message);
1604 for (const app of apps) {
1606 const currentName = app.spec?.name || 'unknown';
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;
1613 // Check if needs renaming
1614 const needsRename = !currentName.startsWith('wordpress-') && !currentName.startsWith('nodejs-');
1617 // Determine new name
1618 const prefix = isWordPress ? 'wordpress-' : 'nodejs-';
1619 const newName = `${prefix}${currentName}`;
1621 // Update app spec with new name
1622 const updatedSpec = {
1627 // Update app in DigitalOcean
1628 await doService.updateApp(app.id, updatedSpec);
1630 results.renamed.push({
1632 oldName: currentName,
1634 type: isWordPress ? 'wordpress' : 'nodejs'
1637 console.log(`[Hosting] Renamed app ${app.id}: ${currentName} -> ${newName}`);
1639 results.skipped.push({
1642 reason: 'Already has prefix'
1646 // Assign app to appropriate project
1649 await doService.apiRequest('POST', `/projects/${projectId}/resources`, {
1650 resources: [`do:app:${app.id}`]
1653 results.organized.push({
1656 project: isWordPress ? 'WordPress' : 'Node.js'
1659 console.log(`[Hosting] Assigned app ${app.id} to ${isWordPress ? 'WordPress' : 'Node.js'} project`);
1661 console.warn(`[Hosting] Could not assign app ${app.id} to project:`, error.message);
1665 console.error(`[Hosting] Error processing app ${app.id}:`, error);
1666 results.errors.push({
1668 name: app.spec?.name || 'unknown',
1669 error: error.message
1674 // Trigger a sync to update database
1675 if (results.renamed.length > 0) {
1677 await doService.getAllResources().then(async (resources) => {
1678 for (const app of resources.apps) {
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
1685 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
1686 ON CONFLICT (do_app_id)
1688 app_name = EXCLUDED.app_name,
1689 status = EXCLUDED.status,
1690 updated_at = CURRENT_TIMESTAMP`,
1694 app.spec?.name || 'Unknown App',
1695 app.spec?.services?.[0]?.github?.repo?.includes('wordpress') ? 'wordpress' : 'nodejs',
1696 app.live_url_base ? 'active' : 'pending',
1699 app.default_ingress,
1700 JSON.stringify(app.spec?.domains || []),
1706 console.log('[Hosting] Database synced after rename-all');
1708 console.error('[Hosting] Error syncing after rename:', err);
1714 message: `Renamed ${results.renamed.length} apps, organized ${results.organized.length} into projects`,
1718 console.error('[Hosting] Error in rename-all:', error);
1719 res.status(500).json({ error: error.message });
1724 * @api {post} /api/hosting/apps/organize Organize apps into projects
1725 * @apiName OrganizeApps
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:
1747 * "message": "Organized 15 apps into projects",
1751 * {"appId": "a1b2c3d4", "name": "wordpress-example", "project": "WordPress"},
1752 * {"appId": "b2c3d4e5", "name": "nodejs-api", "project": "Node.js"}
1759router.post('/apps/organize', async (req, res) => {
1761 if (!req.tenant || !req.tenant.id) {
1762 return res.status(400).json({ error: 'Tenant context not available' });
1765 const doService = new DigitalOceanService(req.tenant.id);
1766 const apps = await doService.listApps();
1768 // Get WordPress and Node.js projects
1769 let wordpressProjectId = null;
1770 let nodejsProjectId = null;
1773 const projects = await doService.listProjects();
1775 const wordpressProject = projects.find(p =>
1776 p.name.toLowerCase().includes('wordpress')
1778 const nodejsProject = projects.find(p =>
1779 p.name.toLowerCase().includes('nodejs') || p.name.toLowerCase().includes('node.js')
1782 if (wordpressProject) {
1783 wordpressProjectId = wordpressProject.id;
1784 console.log(`[Hosting] Using WordPress project: ${wordpressProject.name}`);
1786 console.warn('[Hosting] No WordPress project found');
1789 if (nodejsProject) {
1790 nodejsProjectId = nodejsProject.id;
1791 console.log(`[Hosting] Using Node.js project: ${nodejsProject.name}`);
1793 console.warn('[Hosting] No Node.js project found');
1796 if (!wordpressProjectId && !nodejsProjectId) {
1797 return res.status(404).json({
1798 error: 'No WordPress or Node.js projects found in DigitalOcean'
1802 console.error('[Hosting] Error fetching projects:', error);
1803 return res.status(500).json({ error: 'Could not fetch DigitalOcean projects' });
1813 for (const app of apps) {
1815 const appName = app.spec?.name || 'unknown';
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');
1823 const projectId = isWordPress ? wordpressProjectId : nodejsProjectId;
1824 const projectType = isWordPress ? 'WordPress' : 'Node.js';
1827 results.skipped.push({
1830 reason: `No ${projectType} project found`
1835 // Assign app to appropriate project
1836 await doService.apiRequest('POST', `/projects/${projectId}/resources`, {
1837 resources: [`do:app:${app.id}`]
1840 results.organized.push({
1843 project: projectType
1846 console.log(`[Hosting] Assigned app ${app.id} (${appName}) to ${projectType} project`);
1849 console.error(`[Hosting] Error organizing app ${app.id}:`, error);
1850 results.errors.push({
1852 name: app.spec?.name || 'unknown',
1853 error: error.message
1860 message: `Organized ${results.organized.length} apps into projects`,
1864 console.error('[Hosting] Error in organize:', error);
1865 res.status(500).json({ error: error.message });
1870 * GET /api/hosting/apps
1871 * Get all apps from database (cached, synced every 5 minutes)
1872 * Excludes WordPress apps
1874router.get('/apps', async (req, res) => {
1876 if (!req.tenant || !req.tenant.id) {
1877 return res.status(400).json({ error: 'Tenant context not available' });
1880 // Get last sync time
1881 const syncResult = await pool.query(
1882 `SELECT last_sync_at FROM digitalocean_config WHERE tenant_id = $1`,
1885 const lastSync = syncResult.rows[0]?.last_sync_at;
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(
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]
1908 console.error('[Hosting] Error fetching apps:', error);
1909 if (error.code === '42P01') {
1910 return res.json({ apps: [], lastSync: null, ttl: '5 minutes' });
1912 res.status(500).json({ error: 'Failed to fetch apps' });
1917 * @api {get} /api/hosting/database/:id/metrics Get database metrics
1918 * @apiName GetDatabaseMetrics
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:
1937 * "memory_mb": 3845,
1942router.get('/database/:id/metrics', async (req, res) => {
1944 if (!req.tenant || !req.tenant.id) {
1945 return res.status(400).json({ error: 'Tenant context not available' });
1948 const { id } = req.params;
1949 const doService = new DigitalOceanService(req.tenant.id);
1950 const metrics = await doService.getDatabaseMetrics(id);
1952 res.json({ metrics });
1954 console.error('[Hosting] Error fetching database metrics:', error);
1955 res.status(500).json({ error: error.message });
1960 * @api {get} /api/hosting/droplet/:id/metrics Get droplet metrics
1961 * @apiName GetDropletMetrics
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:
1982 * "status": "success",
1984 * "resultType": "matrix",
1987 * "metric": {"host_id": "123456789"},
1989 * [1678886400, "35.5"],
1990 * [1678890000, "42.3"]
1998router.get('/droplet/:id/metrics', async (req, res) => {
2000 if (!req.tenant || !req.tenant.id) {
2001 return res.status(400).json({ error: 'Tenant context not available' });
2004 const { id } = req.params;
2005 const { metric = 'cpu', start, end } = req.query;
2007 const doService = new DigitalOceanService(req.tenant.id);
2008 const metrics = await doService.getDropletMetrics(
2011 start ? parseInt(start) : null,
2012 end ? parseInt(end) : null
2015 res.json({ metrics });
2017 console.error('[Hosting] Error fetching droplet metrics:', error);
2018 res.status(500).json({ error: error.message });
2023 * @api {get} /api/hosting/app/:id/metrics Get app metrics
2024 * @apiName GetAppMetrics
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:
2046 * "cpu_percentage": 25.5,
2047 * "memory_percentage": 60.2,
2048 * "restart_count": 0
2055router.get('/app/:id/metrics', async (req, res) => {
2057 if (!req.tenant || !req.tenant.id) {
2058 return res.status(400).json({ error: 'Tenant context not available' });
2061 const { id } = req.params;
2062 const doService = new DigitalOceanService(req.tenant.id);
2063 const metrics = await doService.getAppMetrics(id);
2065 res.json({ metrics });
2067 console.error('[Hosting] Error fetching app metrics:', error);
2068 res.status(500).json({ error: error.message });
2072module.exports = router;