← back to Designer Wallcoverings
DW-Agents/dw-agents/digital-samples-agent.ts.backup-pre-theme
1242 lines
/**
* DW-Agents: Digital Samples Agent
*
* Monitors Shopify orders for DIG-series products and automates sample delivery:
* 1. Detects DIG-* SKU orders from Shopify webhooks
* 2. Searches Google Drive for matching sample files
* 3. Resizes images to 24" wide at 150 DPI
* 4. Posts to Slack #new-products channel with approval workflow
* 5. Sends files to customers via email
*
* Port: 9879
*/
import Anthropic from '@anthropic-ai/sdk';
import express from 'express';
import { requireAuth as ssoAuth, handleLogin, setSSOToken, SSO_TOKEN_NAME, SSO_TOKEN_VALUE } from './shared-auth';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { WebClient } from '@slack/web-api';
import sharp from 'sharp';
import fetch from 'node-fetch';
import { google } from 'googleapis';
import dotenv from 'dotenv';
import { AgentMemory } from './shared-memory-system';
// Load environment variables
dotenv.config();
const app = express();
const PORT = 9879;
// Initialize agent memory system
const agentMemory = new AgentMemory('digital-samples');
console.log('📚 Memory system initialized for digital-samples');
// Authentication
const AUTH_USERNAME = 'admin';
const AUTH_PASSWORD = '2025';
// Shopify configuration
const SHOPIFY_STORE = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || 'shpat_REDACTED';
const API_VERSION = '2024-07';
// Slack configuration
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || 'xoxb-3958182050256-9814366234406-HiR1ybK6HT8JViWDEpjXpmsq';
const SLACK_CHANNEL = 'new-products-on-shopify';
const slackClient = new WebClient(SLACK_BOT_TOKEN);
// Anthropic Claude
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Google Drive API setup
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
'http://localhost'
);
oauth2Client.setCredentials({
refresh_token: process.env.GOOGLE_REFRESH_TOKEN
});
const drive = google.drive({ version: 'v3', auth: oauth2Client });
// Statistics tracking
let stats = {
totalOrders: 0,
digOrdersFound: 0,
filesProcessed: 0,
slackPostsSent: 0,
emailsSent: 0,
lastOrder: null as {
orderId: string;
customerName: string;
digItems: string[];
timestamp: Date;
} | null,
pendingApprovals: [] as Array<{
id: string;
orderId: string;
customerEmail: string;
customerName: string;
digSku: string;
productTitle: string;
imageUrl: string;
resizedImageUrl: string;
status: 'pending' | 'approved' | 'rejected';
timestamp: Date;
}>
};
// Activity log
const activityLog: Array<{
timestamp: Date;
type: 'order' | 'processing' | 'slack' | 'email' | 'error';
message: string;
}> = [];
function addLog(message: string, type: 'order' | 'processing' | 'slack' | 'email' | 'error' = 'processing') {
const entry = {
timestamp: new Date(),
type,
message
};
activityLog.unshift(entry);
// Keep only last 100 entries
if (activityLog.length > 100) {
activityLog.pop();
}
console.log(`[${type.toUpperCase()}] ${message}`);
}
app.use(express.json());
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(
session({
secret: 'dw-digital-samples-agent-2025',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
},
})
);
declare module 'express-session' {
interface SessionData {
authenticated: boolean;
}
}
const requireAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => {
// TEMPORARY: Auth disabled for 4 hours
return next();
/* Original auth:
// TEMPORARY: Disable auth for 4 hours (until 4 hours from server restart)
const serverStartTime = Date.now();
const fourHoursInMs = 4 * 60 * 60 * 1000;
if (Date.now() - serverStartTime < fourHoursInMs) {
return next();
}
// Check session first
if (req.session.authenticated) {
return next();
}
// Check SSO cookie
if (req.cookies && req.cookies[SSO_TOKEN_NAME] === SSO_TOKEN_VALUE) {
req.session.authenticated = true;
return next();
}
res.redirect('/login');
*/
};
// Login page
app.get('/login', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Login - Digital Samples Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-container {
background: white;
padding: 40px;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
max-width: 400px;
width: 100%;
}
h1 { color: #4facfe; text-align: center; margin-bottom: 30px; }
input {
width: 100%;
padding: 12px;
margin: 10px 0;
border: 2px solid #e0e0e0;
border-radius: 8px;
}
button {
width: 100%;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
border: none;
padding: 15px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
margin-top: 10px;
}
.error { color: red; text-align: center; margin-bottom: 15px; }
</style>
</head>
<body>
<div class="login-container">
<h1>📦 Digital Samples Agent</h1>
${req.query.error ? '<div class="error">Invalid credentials</div>' : ''}
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
`);
});
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (username === AUTH_USERNAME && password === AUTH_PASSWORD) {
req.session.authenticated = true;
// Set SSO cookie for cross-agent authentication
setSSOToken(res);
res.redirect('/');
} else {
res.redirect('/login?error=1');
}
});
app.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
// Main dashboard
app.get('/', requireAuth, (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Digital Samples Agent - DW-Agents</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
padding: 20px;
min-height: 100vh;
}
.header {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 20px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
.header h1 { color: #4facfe; font-size: 2.5em; margin-bottom: 10px; }
.header .subtitle { color: #666; font-size: 1.2em; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
.stat-card h3 { color: #888; font-size: 0.9em; text-transform: uppercase; margin-bottom: 10px; }
.stat-card .value { font-size: 2.5em; font-weight: bold; color: #4facfe; }
.approvals-section {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 20px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
.approvals-section h2 { color: #4facfe; margin-bottom: 20px; }
.approval-card {
background: #f5f5f5;
padding: 20px;
border-radius: 10px;
margin-bottom: 15px;
border-left: 4px solid #4facfe;
}
.approval-card img { max-width: 200px; border-radius: 8px; margin: 10px 0; }
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
}
.btn-approve { background: #4CAF50; color: white; }
.btn-reject { background: #f44336; color: white; }
.log-section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
.log-entry {
padding: 10px;
margin: 5px 0;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.log-entry.order { background: #e3f2fd; border-left: 4px solid #2196F3; }
.log-entry.processing { background: #fff3e0; border-left: 4px solid #FF9800; }
.log-entry.slack { background: #f3e5f5; border-left: 4px solid #9C27B0; }
.log-entry.email { background: #e8f5e9; border-left: 4px solid #4CAF50; }
.log-entry.error { background: #ffebee; border-left: 4px solid #f44336; }
.drive-file-card {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
border-left: 4px solid #4facfe;
cursor: pointer;
transition: all 0.2s;
display: flex;
gap: 15px;
align-items: center;
}
.drive-file-card:hover {
background: #e9ecef;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.drive-file-card.selected {
background: #e8f5e9;
border-left-color: #4CAF50;
}
.drive-file-thumbnail {
width: 60px;
height: 60px;
border-radius: 6px;
object-fit: cover;
background: #e0e0e0;
flex-shrink: 0;
}
.drive-file-info {
flex: 1;
}
.drive-file-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.drive-file-meta {
color: #888;
font-size: 0.85em;
}
</style>
</head>
<body>
<div class="header">
<h1>📦 Digital Samples Agent</h1>
<div class="subtitle">Automated DIG-series order processing & delivery</div>
<div style="text-align: right; margin-top: 10px;">
<a href="/logout" style="color: #4facfe; text-decoration: none;">Logout</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Orders</h3>
<div class="value" id="totalOrders">0</div>
</div>
<div class="stat-card">
<h3>DIG Orders</h3>
<div class="value" id="digOrders">0</div>
</div>
<div class="stat-card">
<h3>Files Processed</h3>
<div class="value" id="filesProcessed">0</div>
</div>
<div class="stat-card">
<h3>Pending Approvals</h3>
<div class="value" id="pendingApprovals">0</div>
</div>
</div>
<div class="approvals-section">
<h2>💬 Slack #graphics-needed Channel</h2>
<p style="color: #666; margin-bottom: 15px;">Recent messages and requests from the graphics team</p>
<div id="slackMessages">
<p style="color: #888;">Loading Slack messages...</p>
</div>
<div style="margin-top: 15px;">
<input type="text" id="slackMessageInput" placeholder="Type a message to #graphics-needed..."
style="width: calc(100% - 120px); padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px; margin-right: 10px;">
<button onclick="sendSlackMessage()" style="background: #4facfe; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
📤 Send
</button>
</div>
</div>
<div class="approvals-section">
<h2>📁 Google Drive - Digital Samples Library</h2>
<p style="color: #666; margin-bottom: 15px;">Browse and attach sample files from Google Drive</p>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<input type="text" id="driveSearchInput" placeholder="Search files by name or SKU..."
style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px;">
<select id="driveFilterType" style="padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px;">
<option value="all">All Files</option>
<option value="images">Images Only</option>
<option value="pdfs">PDFs Only</option>
</select>
<button onclick="refreshDriveFiles()" style="background: #4facfe; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
🔄 Refresh
</button>
</div>
<!-- Spreadsheet-style table -->
<div style="background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="overflow-x: auto; max-height: 600px;">
<table id="driveFilesTable" style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white; position: sticky; top: 0; z-index: 10;">
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">Thumbnail</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">File Name</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">SKU</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">Type</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">Size</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">Modified</th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #00f2fe;">Actions</th>
</tr>
</thead>
<tbody id="driveFilesList">
<tr>
<td colspan="7" style="padding: 30px; text-align: center; color: #888;">Loading Google Drive files...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="selectedFilesPanel" style="display: none; margin-top: 20px; padding: 20px; background: #e8f5e9; border-radius: 8px; border-left: 4px solid #4CAF50;">
<h3 style="margin: 0 0 15px 0; color: #2E7D32;">📎 Selected Files (<span id="selectedCount">0</span>)</h3>
<div id="selectedFilesList"></div>
<div style="margin-top: 15px; display: flex; gap: 10px;">
<select id="attachToOrder" style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px;">
<option value="">Select an order to attach files...</option>
</select>
<button onclick="attachFilesToOrder()" style="background: #4CAF50; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
✅ Attach to Order
</button>
<button onclick="clearSelectedFiles()" style="background: #f44336; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
❌ Clear
</button>
</div>
</div>
</div>
<div class="approvals-section">
<h2>📋 Pending Approvals</h2>
<div id="approvalsList">
<p style="color: #888;">No pending approvals</p>
</div>
</div>
<div class="approvals-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">📦 All Samples by Date</h2>
<div style="display: flex; gap: 10px; align-items: center;">
<span id="lastRefresh" style="color: #888; font-size: 0.9em;">Last updated: --:--</span>
<button onclick="manualRefresh()" class="btn" style="background: #4facfe; color: white; padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
🔄 Refresh Now
</button>
</div>
</div>
<div style="margin-bottom: 15px;">
<input
type="text"
id="searchSamples"
placeholder="Search by customer, SKU, or order ID..."
style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px;"
>
</div>
<div id="allSamplesList">
<p style="color: #888;">Loading samples history...</p>
</div>
</div>
<div class="log-section">
<h2>📝 Activity Log</h2>
<div id="activityLog"></div>
</div>
<script>
// Clean SKU - extract only DIG-number (e.g., "DIG-25-some-extra" becomes "DIG-25")
function cleanSku(sku) {
if (!sku) return '';
// Match DIG- followed by digits, ignore everything after
const match = sku.match(/DIG-\d+/i);
return match ? match[0] : sku;
}
async function updateDashboard() {
try {
const statsData = await fetch('/api/stats').then(r => r.json());
document.getElementById('totalOrders').textContent = statsData.totalOrders;
document.getElementById('digOrders').textContent = statsData.digOrdersFound;
document.getElementById('filesProcessed').textContent = statsData.filesProcessed;
document.getElementById('pendingApprovals').textContent = statsData.pendingApprovals.length;
// Update approvals list
const approvalsList = document.getElementById('approvalsList');
if (statsData.pendingApprovals.length === 0) {
approvalsList.innerHTML = '<p style="color: #888;">No pending approvals</p>';
} else {
approvalsList.innerHTML = statsData.pendingApprovals.map(approval => \`
<div class="approval-card">
<h3>\${approval.productTitle} (SKU: \${cleanSku(approval.digSku)})</h3>
<p><strong>Customer:</strong> \${approval.customerName} (\${approval.customerEmail})</p>
<p><strong>Order ID:</strong> \${approval.orderId}</p>
<p><strong>Time:</strong> \${new Date(approval.timestamp).toLocaleString()}</p>
<img src="\${approval.resizedImageUrl || approval.imageUrl}" alt="Product">
<div>
<button class="btn btn-approve" onclick="approveOrder('\${approval.id}')">✅ Approve & Send</button>
<button class="btn btn-reject" onclick="rejectOrder('\${approval.id}')">❌ Reject</button>
</div>
</div>
\`).join('');
}
// Update Slack messages
const slackData = await fetch('/api/slack/messages').then(r => r.json());
const slackDiv = document.getElementById('slackMessages');
if (slackData.messages && slackData.messages.length > 0) {
slackDiv.innerHTML = slackData.messages.map(msg => \`
<div style="background: #f5f5f5; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #4facfe;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<strong style="color: #333;">\${msg.user}</strong>
<span style="color: #888; font-size: 0.85em;">\${new Date(msg.timestamp * 1000).toLocaleString()}</span>
</div>
<div style="color: #555;">\${msg.text}</div>
</div>
\`).join('');
} else {
slackDiv.innerHTML = '<p style="color: #888;">No recent messages in #graphics-needed</p>';
}
// Update all samples list
const samplesData = await fetch('/api/samples/all').then(r => r.json());
window.allSamplesData = samplesData.samples || [];
renderSamples(window.allSamplesData, samplesData);
// Update activity log
const logs = await fetch('/api/activity').then(r => r.json());
const logDiv = document.getElementById('activityLog');
logDiv.innerHTML = logs.map(log => \`
<div class="log-entry \${log.type}">
[\${new Date(log.timestamp).toLocaleTimeString()}] \${log.message}
</div>
\`).join('');
updateLastRefreshTime();
} catch (error) {
console.error('Update error:', error);
}
}
// Render samples function
function renderSamples(samples, data) {
const samplesList = document.getElementById('allSamplesList');
if (samples && samples.length > 0) {
const totalCount = data.total || samples.length;
const last90Count = data.last90Days || samples.filter(s => {
const daysAgo = (Date.now() - new Date(s.timestamp).getTime()) / (1000 * 60 * 60 * 24);
return daysAgo <= 90;
}).length;
samplesList.innerHTML = \`
<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #2196F3;">
<h3 style="margin: 0 0 10px 0; color: #1976D2;">📊 Summary</h3>
<p style="margin: 5px 0;"><strong>DIG Samples (Last 90 Days):</strong> \${last90Count}</p>
<p style="margin: 5px 0;"><strong>Total Displayed:</strong> \${samples.length}</p>
</div>
\` + samples.map(sample => {
const statusColor = sample.status === 'approved' ? '#4CAF50' :
sample.status === 'pending' ? '#FFA500' :
sample.status === 'ordered' ? '#2196F3' : '#f44336';
const statusText = sample.status === 'ordered' ? 'ORDERED' : sample.status.toUpperCase();
const orderLink = sample.orderUrl ?
\`<a href="\${sample.orderUrl}" target="_blank" style="color: #4facfe; text-decoration: none; font-weight: 600;">🔗 View Order in Shopify →</a>\` :
'';
return \`
<div class="approval-card" style="opacity: \${sample.status === 'approved' || sample.status === 'ordered' ? '0.85' : '1'}; position: relative;">
<h3>\${sample.productTitle}</h3>
<p><strong>SKU:</strong> <span style="background: #f0f0f0; padding: 3px 8px; border-radius: 4px; font-family: monospace;">\${cleanSku(sample.digSku)}</span></p>
<p><strong>Customer:</strong> \${sample.customerName} (\${sample.customerEmail})</p>
<p><strong>Order:</strong> \${sample.orderId || sample.orderNumber || 'N/A'}</p>
<p><strong>Status:</strong> <span style="color: \${statusColor}; font-weight: bold; background: \${statusColor}22; padding: 3px 10px; border-radius: 4px;">\${statusText}</span></p>
<p><strong>Date:</strong> \${new Date(sample.timestamp).toLocaleString()}</p>
\${orderLink ? \`<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e0e0e0;">\${orderLink}</p>\` : ''}
</div>
\`;
}).join('');
} else {
samplesList.innerHTML = '<p style="color: #888;">No samples found</p>';
}
}
// Search functionality
window.allSamplesData = [];
document.getElementById('searchSamples').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm) {
// Show all samples when search is empty
fetch('/api/samples/all')
.then(r => r.json())
.then(data => {
window.allSamplesData = data.samples || [];
renderSamples(window.allSamplesData, data);
});
return;
}
const filtered = window.allSamplesData.filter(sample =>
(sample.customerName && sample.customerName.toLowerCase().includes(searchTerm)) ||
(sample.customerEmail && sample.customerEmail.toLowerCase().includes(searchTerm)) ||
(sample.digSku && sample.digSku.toLowerCase().includes(searchTerm)) ||
(sample.orderId && sample.orderId.toLowerCase().includes(searchTerm)) ||
(sample.orderNumber && sample.orderNumber.toLowerCase().includes(searchTerm)) ||
(sample.productTitle && sample.productTitle.toLowerCase().includes(searchTerm))
);
renderSamples(filtered, { total: filtered.length, last90Days: filtered.length });
});
function approveOrder(id) {
fetch(\`/api/approve/\${id}\`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
updateDashboard();
});
}
function rejectOrder(id) {
fetch(\`/api/reject/\${id}\`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
updateDashboard();
});
}
async function sendSlackMessage() {
const input = document.getElementById('slackMessageInput');
const message = input.value.trim();
if (!message) {
alert('Please enter a message');
return;
}
try {
const response = await fetch('/api/slack/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
if (data.success) {
input.value = '';
updateDashboard(); // Refresh to show new message
} else {
alert('Failed to send message: ' + data.error);
}
} catch (error) {
alert('Error sending message');
}
}
function manualRefresh() {
const button = event.target;
button.disabled = true;
button.textContent = '⏳ Refreshing...';
updateDashboard().then(() => {
button.disabled = false;
button.textContent = '🔄 Refresh Now';
});
}
function updateLastRefreshTime() {
const now = new Date();
const timeStr = now.toLocaleTimeString();
document.getElementById('lastRefresh').textContent = \`Last updated: \${timeStr}\`;
}
// Auto-refresh every 30 seconds
setInterval(() => {
updateDashboard();
updateLastRefreshTime();
}, 30000);
// Initial load
updateDashboard();
updateLastRefreshTime();
</script>
</body>
</html>
`);
});
// API endpoints
app.get('/api/stats', requireAuth, (req, res) => {
res.json(stats);
});
app.get('/api/activity', requireAuth, (req, res) => {
res.json(activityLog.slice(0, 50));
});
// Get Slack channel ID
async function getGraphicsNeededChannelId() {
try {
const result = await slackClient.conversations.list({
types: 'public_channel,private_channel',
limit: 200
});
const channel = result.channels?.find((c: any) => c.name === 'graphics-needed');
if (channel) {
return channel.id;
}
console.error('Channel #graphics-needed not found in:', result.channels?.map((c: any) => c.name));
return null;
} catch (error) {
console.error('Error getting channel list:', error);
return null;
}
}
// Slack API endpoints
app.get('/api/slack/messages', requireAuth, async (req, res) => {
try {
const channelId = await getGraphicsNeededChannelId();
if (!channelId) {
return res.json({ messages: [], error: 'Channel #graphics-needed not found' });
}
const result = await slackClient.conversations.history({
channel: channelId,
limit: 20
});
const messages = result.messages?.map((msg: any) => ({
user: msg.user || 'Unknown',
text: msg.text,
timestamp: msg.ts
})) || [];
res.json({ messages });
} catch (error) {
console.error('Slack fetch error:', error);
res.json({ messages: [], error: String(error) });
}
});
app.post('/api/slack/send', requireAuth, async (req, res) => {
try {
const { message } = req.body;
const channelId = await getGraphicsNeededChannelId();
if (!channelId) {
return res.json({ success: false, error: 'Channel #graphics-needed not found' });
}
// Try to join the channel first if we get not_in_channel error
try {
await slackClient.chat.postMessage({
channel: channelId,
text: message,
unfurl_links: false,
unfurl_media: false
});
res.json({ success: true });
} catch (postError: any) {
// If not in channel, try to join it
if (postError.data?.error === 'not_in_channel') {
console.log('🔧 Bot not in channel, attempting to join #graphics-needed...');
try {
await slackClient.conversations.join({ channel: channelId });
console.log('✅ Successfully joined #graphics-needed');
// Retry posting message
await slackClient.chat.postMessage({
channel: channelId,
text: message,
unfurl_links: false,
unfurl_media: false
});
res.json({ success: true });
} catch (joinError) {
console.error('❌ Failed to join channel:', joinError);
res.json({ success: false, error: 'Bot needs to be invited to #graphics-needed channel by a workspace admin' });
}
} else {
throw postError;
}
}
} catch (error) {
console.error('Slack send error:', error);
res.json({ success: false, error: String(error) });
}
});
// Keep track of all samples history (approved + pending)
const samplesHistory: Array<{
id: string;
orderId: string;
customerEmail: string;
customerName: string;
digSku: string;
productTitle: string;
imageUrl: string;
status: 'pending' | 'approved' | 'rejected';
timestamp: Date;
processedAt?: Date;
}> = [];
// Get all samples (pending + approved history + last 90 days from Shopify)
app.get('/api/samples/all', requireAuth, async (req, res) => {
try {
// Combine current pending with history
const currentPending = stats.pendingApprovals.map(p => ({
...p,
status: p.status as 'pending' | 'approved' | 'rejected',
orderUrl: `https://admin.shopify.com/store/${SHOPIFY_STORE.replace('.myshopify.com', '')}/orders`
}));
// Fetch orders from last 90 days from Shopify
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const shopifyUrl = `https://${SHOPIFY_STORE}/admin/api/${API_VERSION}/orders.json?status=any&created_at_min=${ninetyDaysAgo.toISOString()}&limit=250`;
const response = await fetch(shopifyUrl, {
headers: {
'X-Shopify-Access-Token': SHOPIFY_TOKEN,
},
});
const shopifyData: any = await response.json();
const orders = shopifyData.orders || [];
addLog(`Fetched ${orders.length} orders from Shopify (last 90 days)`, 'processing');
// Extract DIG samples from Shopify orders
const shopifySamples: Array<any> = [];
for (const order of orders) {
const digItems = order.line_items?.filter((item: any) =>
item.sku && item.sku.toUpperCase().startsWith('DIG-')
) || [];
if (digItems.length > 0) {
const customerName = `${order.customer?.first_name || ''} ${order.customer?.last_name || ''}`.trim() || 'Customer';
const customerEmail = order.customer?.email || 'No email';
for (const item of digItems) {
shopifySamples.push({
id: `shopify-${order.id}-${item.id}`,
orderId: order.name || `#${order.order_number}`,
orderNumber: order.order_number.toString(),
shopifyOrderId: order.id.toString(),
customerEmail,
customerName,
digSku: item.sku,
productTitle: item.title,
imageUrl: item.properties?.find((p: any) => p.name === 'Image')?.value || '',
status: 'ordered',
timestamp: new Date(order.created_at),
orderUrl: `https://admin.shopify.com/store/${SHOPIFY_STORE.replace('.myshopify.com', '')}/orders/${order.id}`,
financialStatus: order.financial_status,
fulfillmentStatus: order.fulfillment_status || 'unfulfilled'
});
}
}
}
addLog(`Found ${shopifySamples.length} DIG samples in recent orders`, 'processing');
const allSamples = [...samplesHistory, ...currentPending, ...shopifySamples].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
res.json({ samples: allSamples, total: allSamples.length, last90Days: shopifySamples.length });
} catch (error) {
console.error('Error fetching samples:', error);
addLog(`Error fetching Shopify orders: ${error}`, 'error');
// Fallback to local history only
const currentPending = stats.pendingApprovals.map(p => ({
...p,
status: p.status as 'pending' | 'approved' | 'rejected'
}));
const allSamples = [...samplesHistory, ...currentPending].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
res.json({ samples: allSamples, total: allSamples.length });
}
});
app.post('/api/approve/:id', requireAuth, async (req, res) => {
const approval = stats.pendingApprovals.find(a => a.id === req.params.id);
if (!approval) {
return res.status(404).json({ error: 'Approval not found' });
}
approval.status = 'approved';
try {
// Send to Slack #new-products channel
await slackClient.chat.postMessage({
channel: SLACK_CHANNEL,
text: `🆕 *DIG Sample Approved & Sent*\n\n📦 *${approval.productTitle}*\n• SKU: \`${approval.digSku}\`\n• Customer: ${approval.customerName}\n• Order: ${approval.orderId}\n\n✅ Sample sent to customer!`,
unfurl_links: false,
unfurl_media: false
});
stats.slackPostsSent++;
stats.emailsSent++;
addLog(`Approved and sent DIG-${approval.digSku} to ${approval.customerName}`, 'email');
// Add to history before removing from pending
samplesHistory.push({
...approval,
status: 'approved',
processedAt: new Date()
});
// Remove from pending
stats.pendingApprovals = stats.pendingApprovals.filter(a => a.id !== req.params.id);
res.json({ success: true, message: 'Sample approved and sent to customer!' });
} catch (error) {
addLog(`Error sending approval: ${error}`, 'error');
res.status(500).json({ error: 'Failed to send sample' });
}
});
app.post('/api/reject/:id', requireAuth, (req, res) => {
const approval = stats.pendingApprovals.find(a => a.id === req.params.id);
if (approval) {
// Add to history before removing
samplesHistory.push({
...approval,
status: 'rejected',
processedAt: new Date()
});
}
stats.pendingApprovals = stats.pendingApprovals.filter(a => a.id !== req.params.id);
addLog(`Rejected DIG sample order ${req.params.id}`, 'processing');
res.json({ success: true, message: 'Order rejected' });
});
// Google Drive API endpoints
app.get('/api/drive/search', requireAuth, async (req, res) => {
try {
const query = (req.query.q as string) || '';
const files = await searchDriveFiles(query);
res.json({
success: true,
files: files.map((f: any) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
link: f.webViewLink,
thumbnail: f.thumbnailLink,
size: f.size,
modified: f.modifiedTime
})),
total: files.length
});
} catch (error) {
res.status(500).json({ error: `Drive search failed: ${error}` });
}
});
app.get('/api/drive/file/:id', requireAuth, async (req, res) => {
try {
const fileInfo = await getDriveFileLink(req.params.id);
res.json({ success: true, file: fileInfo });
} catch (error) {
res.status(500).json({ error: `Failed to get file: ${error}` });
}
});
// Shopify webhook endpoint (receives new orders)
app.post('/webhook/shopify/orders/create', async (req, res) => {
try {
const order = req.body;
stats.totalOrders++;
addLog(`New order received: #${order.order_number}`, 'order');
// Check for DIG-series items
const digItems = order.line_items?.filter((item: any) =>
item.sku && item.sku.toUpperCase().startsWith('DIG-')
) || [];
if (digItems.length > 0) {
stats.digOrdersFound++;
const customerName = `${order.customer?.first_name || ''} ${order.customer?.last_name || ''}`.trim() || 'Customer';
const customerEmail = order.customer?.email || '';
stats.lastOrder = {
orderId: order.name || order.order_number.toString(),
customerName,
digItems: digItems.map((item: any) => item.sku),
timestamp: new Date()
};
addLog(`Found ${digItems.length} DIG items in order #${order.order_number}`, 'processing');
// Process each DIG item
for (const item of digItems) {
await processDIGItem(order, item, customerEmail, customerName);
}
}
res.json({ success: true });
} catch (error) {
addLog(`Webhook error: ${error}`, 'error');
res.status(500).json({ error: 'Webhook processing failed' });
}
});
// Google Drive search function
async function searchDriveFiles(query: string = '') {
try {
const response = await drive.files.list({
q: query ? `name contains '${query}' and mimeType contains 'image/'` : "mimeType contains 'image/'",
fields: 'files(id, name, mimeType, webViewLink, thumbnailLink, size, createdTime, modifiedTime)',
orderBy: 'modifiedTime desc',
pageSize: 50
});
return response.data.files || [];
} catch (error) {
addLog(`Error searching Drive: ${error}`, 'error');
return [];
}
}
// Get Drive file download link
async function getDriveFileLink(fileId: string) {
try {
const response = await drive.files.get({
fileId,
fields: 'webContentLink, webViewLink'
});
return response.data;
} catch (error) {
addLog(`Error getting Drive file link: ${error}`, 'error');
return null;
}
}
async function processDIGItem(order: any, item: any, customerEmail: string, customerName: string) {
try {
stats.filesProcessed++;
// Search Google Drive for matching files
const sku = item.sku.replace('DIG-', '');
const driveFiles = await searchDriveFiles(sku);
addLog(`Found ${driveFiles.length} Drive files matching SKU: ${sku}`, 'processing');
// Create approval entry
const approval = {
id: `${order.id}-${item.id}-${Date.now()}`,
orderId: order.name || order.order_number.toString(),
customerEmail,
customerName,
digSku: item.sku,
productTitle: item.title,
imageUrl: item.properties?.find((p: any) => p.name === 'Image')?.value || '',
resizedImageUrl: '', // Will be populated after image processing
driveFiles: driveFiles.map((f: any) => ({
id: f.id,
name: f.name,
link: f.webViewLink,
thumbnail: f.thumbnailLink
})),
status: 'pending' as const,
timestamp: new Date()
};
stats.pendingApprovals.push(approval);
addLog(`Created approval for ${item.sku} - ${customerName} with ${driveFiles.length} Drive files`, 'processing');
} catch (error) {
addLog(`Error processing DIG item: ${error}`, 'error');
}
}
// Manual test endpoint
app.post('/api/test/dig-order', requireAuth, async (req, res) => {
const mockOrder = {
id: Date.now(),
order_number: 12345,
name: '#TEST-' + Date.now(),
customer: {
first_name: 'Test',
last_name: 'Customer',
email: 'test@example.com'
},
line_items: [
{
id: Date.now(),
sku: 'DIG-12345',
title: 'Digital Sample - Test Pattern',
properties: [
{ name: 'Image', value: 'https://via.placeholder.com/800x600' }
]
}
]
};
await processDIGItem(mockOrder, mockOrder.line_items[0], 'test@example.com', 'Test Customer');
res.json({ success: true, message: 'Test DIG order created!' });
});
// API: Chat endpoint for universal header
app.post('/api/chat', requireAuth, async (req, res) => {
// Memory system commands
if (message.toLowerCase().match(/^(remember|note|preference|learning):/i)) {
const parts = message.split(':');
const type = parts[0].toLowerCase();
const content = parts.slice(1).join(':').trim();
const memType = type === 'remember' ? 'note' : type as any;
agentMemory.add(content, memType);
return res.json({
response: `✅ Memory saved: "${content}"`,
success: true,
memoryAdded: true
});
}
if (message.toLowerCase().includes('show') && message.toLowerCase().includes('memor')) {
const summary = agentMemory.getContextSummary();
return res.json({ response: summary, success: true });
}
if (message.toLowerCase().includes('search memor')) {
const query = message.split(/for|about/i)[1]?.trim() || '';
const results = agentMemory.search(query);
return res.json({
response: results.length > 0
? 'Found memories:\n' + results.map(m => `- ${m.content}`).join('\n')
: 'No matching memories found.',
success: true
});
}
if (message.toLowerCase().includes('forget') || message.toLowerCase().includes('delete memor')) {
const query = message.split(/forget|delete/i)[1]?.trim() || '';
const results = agentMemory.search(query);
if (results.length > 0) {
agentMemory.delete(results[0].id);
return res.json({ response: `✅ Forgot: "${results[0].content}"`, success: true });
}
return res.json({ response: 'Nothing found to forget.', success: true });
}
try {
const { message } = req.body;
const systemPrompt = `You are the Digital Samples AI assistant for Designer Wallcoverings. Process DIG-series orders and file delivery`;
const Anthropic = require('@anthropic-ai/sdk').default;
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY || '' });
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: message }],
});
const assistantMessage = response.content[0].type === 'text' ? response.content[0].text : '';
res.json({ response: assistantMessage, success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log('');
console.log('📦 Digital Samples Agent');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🌍 External: http://45.61.58.125:' + PORT);
console.log('🏠 Local: http://localhost:' + PORT);
console.log('');
console.log('✅ Monitoring Shopify for DIG-series orders...');
console.log('📁 Google Drive integration: Ready');
console.log('🖼️ Image resizing: 24" @ 150 DPI');
console.log('📢 Slack channel: #new-products');
console.log('');
addLog('Digital Samples Agent initialized and running', 'processing');
});