← back to Designer Wallcoverings

DW-Agents/dw-agents/agent-executive-cto/cto-dashboard-agent.ts

551 lines

/**
 * DW-Agents: CTO Executive Dashboard
 *
 * Technology-focused executive dashboard:
 * - Catalog quality & data pipeline monitoring
 * - Vendor integration completeness tracking
 * - Data quality scorecard per vendor
 * - Technology sub-agent oversight
 * - AI-powered technical architect assistant
 * - Inter-agent messaging and action items
 *
 * Port: 7124
 * PM2: dw-executive-cto
 */

import cookieParser from 'cookie-parser';
import Anthropic from '@anthropic-ai/sdk';
import express, { Request, Response } from 'express';
import { requireGlobalAuth } from '../shared-global-auth';
import session from 'express-session';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { chatMiddleware } from '../shared-chat-integration';

import {
  pool, queryDB, getPM2Status, getVendorStats,
  EXECUTIVE_AGENTS, SERVER_IP, VENDOR_TABLES,
  sendMessage, createMessageStore, createActionItemStore,
  execDashboardHead, execNavBar, execChatPanel,
  formatUptime, formatNumber, timeAgo,
  autoRefreshScript, notifySlack,
  AgentStatus
} from '../shared-executive-core';

// ES module __dirname fix
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

dotenv.config({ path: path.join(__dirname, '..', '.env') });

// Global error handlers
process.on('uncaughtException', (error) => {
  console.error('UNCAUGHT EXCEPTION:', error);
  process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION at:', promise, 'reason:', reason);
  process.exit(1);
});

const app = express();
const PORT = 7124;

// Anthropic AI
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY || '',
});

// Message & action item stores
const messageStore = createMessageStore();
const actionStore = createActionItemStore();

// Middleware
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use(chatMiddleware({
  agentId: 'agent-executive-cto',
  agentName: 'CTO Dashboard',
  port: PORT,
  category: 'executive'
}));

declare module 'express-session' {
  interface SessionData {
    authenticated: boolean;
    chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
  }
}

app.use(session({
  secret: 'dw-cto-dashboard-2025-technology',
  resave: false,
  saveUninitialized: true,
  rolling: true,
  cookie: { secure: false, httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000 }
}));

// ---- Logout ----
app.get('/logout', (req, res) => {
  req.session.destroy(() => res.redirect('/login'));
});

// ---- Helper: Get detailed data quality per vendor ----
async function getDataQuality(): Promise<Array<{
  vendor: string;
  table: string;
  total: number;
  missingImages: number;
  missingShopifyId: number;
  missingAiColors: number;
}>> {
  const results: Array<{
    vendor: string;
    table: string;
    total: number;
    missingImages: number;
    missingShopifyId: number;
    missingAiColors: number;
  }> = [];

  for (const v of VENDOR_TABLES) {
    try {
      // First check if ai_colors column exists
      const colCheck = await queryDB(`
        SELECT column_name FROM information_schema.columns
        WHERE table_name = $1 AND column_name = 'ai_colors'
      `, [v.table]);
      const hasAiColors = colCheck.length > 0;

      const aiColorsSelect = hasAiColors
        ? `COUNT(CASE WHEN ai_colors IS NULL OR array_length(ai_colors, 1) IS NULL THEN 1 END) as missing_ai_colors`
        : `COUNT(*) as missing_ai_colors`;

      const rows = await queryDB(`
        SELECT
          COUNT(*) as total,
          COUNT(CASE WHEN image_url IS NULL OR image_url = '' THEN 1 END) as missing_images,
          COUNT(CASE WHEN shopify_product_id IS NULL OR shopify_product_id = '' THEN 1 END) as missing_shopify,
          ${aiColorsSelect}
        FROM ${v.table}
      `);

      if (rows[0]) {
        results.push({
          vendor: v.name,
          table: v.table,
          total: parseInt(rows[0].total),
          missingImages: parseInt(rows[0].missing_images),
          missingShopifyId: parseInt(rows[0].missing_shopify),
          missingAiColors: parseInt(rows[0].missing_ai_colors)
        });
      }
    } catch { /* table may not exist, skip */ }
  }

  return results;
}

// ---- Main Dashboard ----
app.get('/', requireGlobalAuth, async (req: Request, res: Response) => {
  try {
    // Fetch live data in parallel
    const [vendorStats, dataQuality] = await Promise.all([
      getVendorStats(),
      getDataQuality()
    ]);

    // Compute catalog KPIs
    const totalProducts = vendorStats.reduce((sum, v) => sum + v.total, 0);
    const totalImported = vendorStats.reduce((sum, v) => sum + v.imported, 0);
    const totalWithImages = vendorStats.reduce((sum, v) => sum + v.withImages, 0);
    const importRate = totalProducts > 0 ? Math.round((totalImported / totalProducts) * 100) : 0;

    // Auto-detect action items from live data
    if (importRate < 50) {
      actionStore.add('Data Pipeline', 'warning', `Overall Import Rate: ${importRate}%`,
        `Only ${importRate}% of catalog products are imported to Shopify. Prioritize vendor imports to increase coverage.`);
    }

    vendorStats.forEach(v => {
      if (v.total > 0 && v.imported === 0) {
        actionStore.add('Vendor Pipeline', 'critical', `${v.vendor}: 0 Imports`,
          `${v.vendor} has ${v.total} products in the catalog but zero have been imported to Shopify.`);
      }
    });

    // Build vendor pipeline table with color coding
    const vendorPipelineRows = vendorStats.map(v => {
      const pct = v.total > 0 ? Math.round((v.imported / v.total) * 100) : 0;
      const rowBg = pct >= 80 ? '' : pct >= 50 ? 'background:rgba(245,158,11,0.06);' : 'background:rgba(239,68,68,0.06);';
      const pctColor = pct >= 80 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444';
      return `<tr style="${rowBg}">
        <td>${v.vendor}</td>
        <td>${formatNumber(v.total)}</td>
        <td>${formatNumber(v.imported)}</td>
        <td>${formatNumber(v.withImages)}</td>
        <td>
          <div style="display:flex;align-items:center;gap:8px;">
            <div style="flex:1;background:#1e293b;border-radius:4px;height:6px;overflow:hidden;">
              <div style="width:${pct}%;background:${pctColor};height:100%;"></div>
            </div>
            <span style="color:${pctColor};font-weight:700;min-width:40px;text-align:right;">${pct}%</span>
          </div>
        </td>
      </tr>`;
    }).join('');

    // Build data quality scorecard
    const dataQualityRows = dataQuality.map(dq => {
      const imgPct = dq.total > 0 ? Math.round(((dq.total - dq.missingImages) / dq.total) * 100) : 0;
      const shopifyPct = dq.total > 0 ? Math.round(((dq.total - dq.missingShopifyId) / dq.total) * 100) : 0;
      const aiPct = dq.total > 0 ? Math.round(((dq.total - dq.missingAiColors) / dq.total) * 100) : 0;

      const colorFor = (pct: number) => pct >= 80 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444';

      return `<tr>
        <td>${dq.vendor}</td>
        <td>${formatNumber(dq.total)}</td>
        <td style="color:${dq.missingImages > 0 ? '#ef4444' : '#22c55e'}">${formatNumber(dq.missingImages)}</td>
        <td style="color:${dq.missingShopifyId > 0 ? '#f59e0b' : '#22c55e'}">${formatNumber(dq.missingShopifyId)}</td>
        <td style="color:${dq.missingAiColors > 0 ? '#f59e0b' : '#22c55e'}">${formatNumber(dq.missingAiColors)}</td>
        <td>
          <div style="display:flex;gap:6px;">
            <span style="font-size:11px;color:${colorFor(imgPct)};" title="Images">\u{1F5BC}${imgPct}%</span>
            <span style="font-size:11px;color:${colorFor(shopifyPct)};" title="Shopify">\u{1F6D2}${shopifyPct}%</span>
            <span style="font-size:11px;color:${colorFor(aiPct)};" title="AI Colors">\u{1F3A8}${aiPct}%</span>
          </div>
        </td>
      </tr>`;
    }).join('');

    // Build messages HTML
    const messages = messageStore.getAll();
    const messagesHTML = messages.length > 0
      ? messages.slice(0, 10).map(m => `
        <div class="msg-item ${m.read ? '' : 'unread'}">
          <span class="from">${m.from}</span>
          <div>
            <div class="subject">${m.subject} <span class="priority-badge ${m.priority}">${m.priority}</span></div>
            <div class="body">${m.body.substring(0, 120)}${m.body.length > 120 ? '...' : ''}</div>
          </div>
          <span class="time">${timeAgo(m.timestamp)}</span>
        </div>`).join('')
      : '<div style="padding:16px;color:#64748b;">No messages yet.</div>';

    // Build action items HTML
    const actions = actionStore.getUnresolved();
    const actionsHTML = actions.length > 0
      ? actions.slice(0, 8).map(a => `
        <div class="action-item ${a.severity}">
          <div class="title">${a.title}</div>
          <div class="desc">${a.description}</div>
          <div class="meta">${a.source} - ${timeAgo(a.timestamp)}</div>
        </div>`).join('')
      : '<div style="padding:16px;color:#64748b;">No action items. Technology pipelines healthy.</div>';

    // Technology sub-agents
    const subAgents = [
      { name: 'Product Scheduler', port: 9600, desc: 'Automated product scheduling pipeline' },
      { name: 'Shopify Expert', port: 9887, desc: 'Shopify API & data sync management' },
      { name: 'AI Analyzer', port: 9955, desc: 'Gemini 3.0 image analysis engine' },
      { name: 'Product Agent', port: 9604, desc: 'Product creation & updates' }
    ];

    const subAgentsHTML = subAgents.map(sa => `
      <div class="sub-agent">
        <a href="http://${SERVER_IP}:${sa.port}" target="_blank">${sa.name}</a>
        <div class="desc">${sa.desc}</div>
      </div>`).join('');

    const html = `
${execDashboardHead('CTO Technology Dashboard', '@cto')}
${execNavBar('CTO Technology Dashboard', '\u{1F527}', '@cto', messageStore.unreadCount())}
<div class="main">
  <div class="content">
    <!-- KPI Cards -->
    <div class="kpi-grid">
      <div class="kpi-card blue">
        <div class="label">Total Catalog Products</div>
        <div class="value">${formatNumber(totalProducts)}</div>
        <div class="sub">across ${vendorStats.length} vendors</div>
      </div>
      <div class="kpi-card ${totalImported > 0 ? 'green' : 'red'}">
        <div class="label">Imported to Shopify</div>
        <div class="value">${formatNumber(totalImported)}</div>
        <div class="sub">${importRate}% of catalog</div>
      </div>
      <div class="kpi-card blue">
        <div class="label">With Images</div>
        <div class="value">${formatNumber(totalWithImages)}</div>
        <div class="sub">${totalProducts > 0 ? Math.round((totalWithImages / totalProducts) * 100) : 0}% coverage</div>
      </div>
      <div class="kpi-card ${importRate >= 80 ? 'green' : importRate >= 50 ? 'amber' : 'red'}">
        <div class="label">Import Rate</div>
        <div class="value">${importRate}%</div>
        <div class="sub">${importRate >= 80 ? 'Healthy pipeline' : importRate >= 50 ? 'Needs improvement' : 'Critical - pipeline stalled'}</div>
      </div>
    </div>

    <!-- Vendor Pipeline Status -->
    <div class="section">
      <h2>\u{1F4CA} Vendor Pipeline Status</h2>
      <div style="overflow-x:auto;">
        <table class="agent-table">
          <thead>
            <tr>
              <th>Vendor</th>
              <th>Total</th>
              <th>Imported</th>
              <th>Images</th>
              <th>Completion</th>
            </tr>
          </thead>
          <tbody>${vendorPipelineRows}</tbody>
        </table>
      </div>
    </div>

    <!-- Data Quality Scorecard -->
    <div class="section">
      <h2>\u{1F50D} Data Quality Scorecard</h2>
      <div style="overflow-x:auto;">
        <table class="agent-table">
          <thead>
            <tr>
              <th>Vendor</th>
              <th>Total</th>
              <th>Missing Images</th>
              <th>Missing Shopify ID</th>
              <th>Missing AI Colors</th>
              <th>Health</th>
            </tr>
          </thead>
          <tbody>${dataQualityRows}</tbody>
        </table>
      </div>
    </div>

    <!-- Technology Sub-Agents -->
    <div class="section">
      <h2>\u{1F4BB} Technology Sub-Agents</h2>
      <div class="sub-agents">${subAgentsHTML}</div>
    </div>

    <!-- Skills & Capabilities -->
    <div class="section">
      <details style="cursor:pointer;">
        <summary style="font-size:16px;font-weight:700;color:#f8fafc;padding:8px 0;list-style:none;display:flex;align-items:center;gap:8px;">
          <span style="transition:transform 0.2s;display:inline-block;">&#9654;</span> &#128736;&#65039; Skills & Capabilities
          <span style="font-size:12px;color:#94a3b8;margin-left:auto;">(10 skills)</span>
        </summary>
        <table class="skills-table">
          <thead>
            <tr>
              <th>Skill</th>
              <th>Description</th>
              <th>Status</th>
            </tr>
          </thead>
          <tbody>
            <tr><td>Catalog Health Monitoring</td><td>Track products across 15+ vendor catalogs</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Vendor Pipeline Status</td><td>Per-vendor: crawled, imported, AI analyzed, published</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Data Quality Scoring</td><td>Completeness % for images, specs, metafields, tags</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Shopify Product Audit</td><td>Missing images, descriptions, metafields</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Product Scheduler Integration</td><td>Catalog orchestration job monitoring (port 9600)</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>AI Analysis Pipeline</td><td>Gemini image analysis completion tracking</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Import Rate Monitoring</td><td>Vendor import rates with anomaly detection</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>AI Technical Architect</td><td>Claude-powered technical strategy insights</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>API Integration Monitor</td><td>Shopify, Gemini, vendor scraper health</td><td><span class="skill-status">Active</span></td></tr>
            <tr><td>Vendor Data Completeness</td><td>Per-vendor data quality breakdowns</td><td><span class="skill-status">Active</span></td></tr>
          </tbody>
        </table>
      </details>
    </div>

    <!-- Messages & Action Items -->
    <div class="section" id="messages-section">
      <h2>\u{1F4EC} Messages & Action Items</h2>
      <div class="tab-bar">
        <button class="active" onclick="showTab('msgs',this)">Messages (${messages.length})</button>
        <button onclick="showTab('actions',this)">Action Items (${actions.length})</button>
      </div>
      <div id="tab-msgs">${messagesHTML}</div>
      <div id="tab-actions" style="display:none">${actionsHTML}</div>
    </div>
  </div>

  <!-- Chat Panel -->
  ${execChatPanel('CTO', 'technical architect')}
</div>

<script>
function showTab(tab, btn) {
  document.getElementById('tab-msgs').style.display = tab === 'msgs' ? 'block' : 'none';
  document.getElementById('tab-actions').style.display = tab === 'actions' ? 'block' : 'none';
  btn.parentElement.querySelectorAll('button').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}
</script>

${autoRefreshScript(30000)}
</body></html>`;

    res.send(html);
  } catch (error: any) {
    console.error('Dashboard render error:', error);
    res.status(500).send('Dashboard error: ' + error.message);
  }
});

// ---- API: Metrics ----
app.get('/api/metrics', requireGlobalAuth, (req: Request, res: Response) => {
  res.json({
    status: 'online',
    agent: 'CTO Dashboard',
    port: PORT,
    uptime: process.uptime(),
    lastActivity: new Date().toISOString(),
    messages: messageStore.count(),
    unreadMessages: messageStore.unreadCount(),
    actionItems: actionStore.count()
  });
});

// ---- API: Messages ----
app.get('/api/messages', requireGlobalAuth, (req: Request, res: Response) => {
  res.json({ messages: messageStore.getAll(), unread: messageStore.unreadCount() });
});

// ---- API: Receive Message (inter-agent) ----
app.post('/api/message', (req: Request, res: Response) => {
  try {
    const msg = req.body;
    if (msg && msg.id) {
      messageStore.receive(msg);
      res.json({ success: true });
    } else {
      res.status(400).json({ error: 'Invalid message format' });
    }
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ---- API: Send Message (to another agent) ----
app.post('/api/send-message', requireGlobalAuth, async (req: Request, res: Response) => {
  try {
    const { to, subject, body, priority } = req.body;
    const success = await sendMessage('@cto', to, subject, body, priority || 'normal');
    res.json({ success });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ---- API: Chat ----
app.post('/api/chat', requireGlobalAuth, async (req: Request, res: Response) => {
  try {
    const { message, history } = req.body;

    if (!req.session.chatHistory) {
      req.session.chatHistory = [];
    }

    req.session.chatHistory.push({ role: 'user', content: message });

    // Gather live technology context
    let techContext = '';
    try {
      const [vendorStats, dataQuality] = await Promise.all([
        getVendorStats(),
        getDataQuality()
      ]);

      const totalProducts = vendorStats.reduce((s, v) => s + v.total, 0);
      const totalImported = vendorStats.reduce((s, v) => s + v.imported, 0);
      const totalImages = vendorStats.reduce((s, v) => s + v.withImages, 0);
      const importRate = totalProducts > 0 ? Math.round((totalImported / totalProducts) * 100) : 0;

      const zeroImports = vendorStats.filter(v => v.total > 0 && v.imported === 0);
      const lowQuality = dataQuality.filter(dq => dq.total > 0 && dq.missingImages > dq.total * 0.5);

      techContext = `\n\nLIVE TECHNOLOGY DATA:
- Total catalog: ${totalProducts} products across ${vendorStats.length} vendors
- Imported to Shopify: ${totalImported} (${importRate}%)
- With images: ${totalImages} (${totalProducts > 0 ? Math.round((totalImages / totalProducts) * 100) : 0}%)
- Vendors with zero imports: ${zeroImports.map(v => v.vendor).join(', ') || 'None'}
- Vendors with >50% missing images: ${lowQuality.map(v => v.vendor).join(', ') || 'None'}
- Vendor breakdown: ${vendorStats.map(v => `${v.vendor}(${v.imported}/${v.total})`).join(', ')}
- Data quality issues: ${dataQuality.filter(dq => dq.missingAiColors > dq.total * 0.5).map(dq => `${dq.vendor}: ${dq.missingAiColors} missing AI colors`).join('; ') || 'None critical'}`;
    } catch { /* context fetch failed */ }

    const systemPrompt = `You are the CTO AI assistant for Designer Wallcoverings. You specialize in TECHNOLOGY, data pipelines, and vendor integrations.

Your expertise includes:
- Product catalog data architecture and quality
- Vendor integration pipelines (scraping, parsing, importing)
- Shopify API and product data management
- PostgreSQL database optimization and queries
- AI/ML image analysis pipeline (Gemini 3.0)
- Data quality scoring and remediation
- ETL pipeline design and monitoring
- SKU registry and duplicate prevention systems

You have access to live vendor catalog statistics and data quality metrics.
${techContext}

Provide strategic technology insights. When data quality is poor, suggest specific SQL queries or pipeline fixes.
Prioritize catalog completeness and data integrity.
Reference specific vendor tables when discussing data issues.`;

    const chatHistory = (history || req.session.chatHistory).slice(-16);

    const response = await anthropic.messages.create({
      model: 'claude-opus-4-8',
      max_tokens: 1500,
      system: systemPrompt,
      messages: chatHistory.map((m: any) => ({ role: m.role, content: m.content }))
    });

    const assistantMessage = response.content[0].type === 'text'
      ? response.content[0].text
      : 'Unable to process request.';

    req.session.chatHistory.push({ role: 'assistant', content: assistantMessage });

    if (req.session.chatHistory.length > 20) {
      req.session.chatHistory = req.session.chatHistory.slice(-20);
    }

    res.json({ response: assistantMessage });
  } catch (error: any) {
    console.error('Chat error:', error);
    res.status(500).json({ response: 'Error processing chat request: ' + error.message });
  }
});

// ---- Start Server ----
const server = app.listen(PORT, '0.0.0.0', () => {
  console.log('');
  console.log('\u{1F527} CTO Technology Dashboard');
  console.log('\u2501'.repeat(45));
  console.log(`\u{1F30D} External: http://${SERVER_IP}:${PORT}`);
  console.log(`\u{1F3E0} Local: http://localhost:${PORT}`);
  console.log('');
  console.log('\u2705 CTO Dashboard ready...');
});

server.on('error', (error: any) => {
  console.error('SERVER ERROR:', error);
  if (error.code === 'EADDRINUSE') {
    console.error(`Port ${PORT} is already in use`);
  }
  process.exit(1);
});