/** * QUANTA Engine Server * RFQ-to-Revenue Proposal Orchestrator for ideaxWeb * Express API + Telegram webhook + Web dashboard */ const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const multer = require('multer'); const axios = require('axios'); // Skills const webScraper = require('./skills/web_scraper'); const leadQualifier = require('./skills/lead_qualifier'); const proposalGenerator = require('./skills/proposal_generator'); const pdfBuilder = require('./skills/pdf_builder'); const projectManager = require('./skills/project_manager'); const vinosBridge = require('./skills/vinos_bridge'); const app = express(); const PORT = process.env.PORT || 7860; const ADMIN_KEY = process.env.QUANTA_ADMIN_KEY || ''; const API_KEY = process.env.QUANTA_API_KEY || ''; const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''; const CHAT_ID = process.env.TELEGRAM_CHAT_ID || ''; // Middleware app.use(cors()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); // File upload config const upload = multer({ dest: path.join(__dirname, 'storage/uploads'), limits: { fileSize: 20 * 1024 * 1024 }, // 20MB fileFilter: (req, file, cb) => { const allowed = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg', '.zip']; const ext = path.extname(file.originalname).toLowerCase(); cb(null, allowed.includes(ext)); }, }); // --- Auth Middleware --- function requireAdmin(req, res, next) { if (!ADMIN_KEY) return next(); // No key = open (dev mode) const auth = req.headers.authorization; if (auth === `Bearer ${ADMIN_KEY}`) return next(); res.status(401).json({ error: 'Unauthorized' }); } function requireAPIKey(req, res, next) { if (!API_KEY) return next(); const key = req.headers['x-api-key'] || req.query.api_key; if (key === API_KEY) return next(); res.status(401).json({ error: 'Invalid API key' }); } // ============================================ // WEB ROUTES // ============================================ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/proposal/:id', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'proposal.html')); }); app.get('/client/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'client.html')); }); // ============================================ // API ROUTES — Health // ============================================ app.get('/api/health', (req, res) => { res.json({ status: 'ok', engine: 'QUANTA', version: '1.0.0', uptime: process.uptime(), timestamp: new Date().toISOString(), }); }); // ============================================ // API ROUTES — RFQ & Proposals // ============================================ // Shared logic for Proposal Generation async function handleProposalGeneration(rfq_text, channel = 'web', competitor_domains = [], attribution = {}) { if (!rfq_text) throw new Error('rfq_text is required'); // Step 1: Qualify const qualification = await leadQualifier.qualify(rfq_text); // Step 2: Generate proposal const proposal = await proposalGenerator.generateProposal(qualification, { competitorDomains: competitor_domains, enrichWithVinOS: true, }); // Step 3: Create project const project = projectManager.createProject(qualification, proposal, attribution); // Step 4: Generate PDF try { const pdfPath = await pdfBuilder.generateProposalPDF(proposal); projectManager.updatePDFPath(project.id, pdfPath); } catch (e) { console.error('[Server] PDF generation failed:', e.message); } // Step 5: Notify if (CHAT_ID && BOT_TOKEN) { const summary = proposalGenerator.generateTelegramSummary(proposal); const clientUrl = projectManager.getClientURL(project.id); const notifyMsg = `${summary}\n\nšŸ”— Client link: ${clientUrl}\nšŸ”‘ PIN: ${project.client_pin}`; await _sendTelegram(CHAT_ID, notifyMsg); } // Response based on channel if (channel === 'telegram') { return { success: true, summary: proposalGenerator.generateTelegramSummary(proposal), project_id: project.id, client_url: projectManager.getClientURL(project.id), client_pin: project.client_pin, lqs: qualification.lqs, tier: qualification.recommended_tier, total: qualification.amounts?.total || 0, user_id: attribution.user_id, user_name: attribution.user_name }; } else { return { success: true, project, proposal: { sections: proposal.sections, generatedAt: proposal.generatedAt }, client_url: projectManager.getClientURL(project.id), client_pin: project.client_pin, }; } } app.post('/api/rfq', async (req, res) => { try { const { rfq_text, channel = 'web', competitor_domains = [], user_id, user_name } = req.body; const result = await handleProposalGeneration(rfq_text, channel, competitor_domains, { user_id, user_name }); res.json(result); } catch (e) { console.error('[Server] RFQ processing error:', e); res.status(500).json({ error: e.message }); } }); // ============================================ // API ROUTES — Projects // ============================================ app.get('/api/projects', requireAdmin, (req, res) => { const { status, tier, limit } = req.query; const projects = projectManager.listProjects({ status, tier, limit: limit ? parseInt(limit) : undefined, }); res.json({ success: true, projects }); }); app.get('/api/projects/:id', (req, res) => { const project = projectManager.getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Project not found' }); res.json({ success: true, project }); }); app.patch('/api/projects/:id', requireAdmin, (req, res) => { const { status, stage, milestone_id, milestone_status, note } = req.body; let result; if (status) result = projectManager.updateStatus(req.params.id, status); else if (stage) result = projectManager.updateStage(req.params.id, stage); else if (milestone_id && milestone_status) result = projectManager.updatePaymentMilestone(req.params.id, milestone_id, milestone_status); else if (note) result = projectManager.addNote(req.params.id, note); else return res.status(400).json({ error: 'Provide status, stage, milestone, or note' }); if (!result) return res.status(404).json({ error: 'Project not found' }); if (result.error) return res.status(400).json(result); res.json({ success: true, project: result }); }); app.get('/api/projects/:id/pdf', (req, res) => { const project = projectManager.getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Project not found' }); const latest = project.proposal_versions[project.proposal_versions.length - 1]; if (!latest || !latest.pdfPath || !fs.existsSync(latest.pdfPath)) { return res.status(404).json({ error: 'PDF not generated yet' }); } res.download(latest.pdfPath, `proposal_${project.client_name.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`); }); app.post('/api/projects/:id/upload', upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); projectManager.addNote(req.params.id, `File uploaded: ${req.file.originalname} (${req.file.size} bytes)`); res.json({ success: true, filename: req.file.originalname, path: req.file.path }); }); // ============================================ // API ROUTES — Client Portal // ============================================ app.post('/api/client/:token/verify', (req, res) => { const { pin } = req.body; if (!pin) return res.status(400).json({ error: 'PIN required' }); const result = projectManager.verifyClientPIN(req.params.token, pin); if (!result) return res.status(404).json({ error: 'Invalid access link' }); if (result.error) return res.status(403).json(result); // Return sanitized project data (no internal fields) res.json({ success: true, project: { id: result.id, client_name: result.client_name, project_name: result.project_name, status: result.status, onboarding_stage: result.onboarding_stage, recommended_tier: result.recommended_tier, amounts: result.amounts, payment_terms: result.payment_terms, timeline_weeks: result.timeline_weeks, key_dates: result.key_dates, checklist: result.checklist, proposal_versions: result.proposal_versions.map(v => ({ version: v.version, created: v.created, hasPDF: !!v.pdfPath, })), }, }); }); app.post('/api/client/:token/checklist', (req, res) => { const { checklist_id, done } = req.body; const project = projectManager.getProjectByToken(req.params.token); if (!project) return res.status(404).json({ error: 'Invalid access link' }); const result = projectManager.updateChecklist(project.id, checklist_id, done); if (!result) return res.status(404).json({ error: 'Failed' }); if (result.error) return res.status(400).json(result); res.json({ success: true }); }); // ============================================ // API ROUTES — Dashboard // ============================================ app.get('/api/dashboard', requireAdmin, (req, res) => { res.json({ success: true, data: projectManager.getDashboardData() }); }); // ============================================ // API ROUTES — Scraping // ============================================ app.post('/api/scrape/url', async (req, res) => { try { const { url, mode = 'auto' } = req.body; if (!url) return res.status(400).json({ error: 'url is required' }); const result = await webScraper.scrapeUrl(url, { mode }); res.json({ success: true, data: result }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/scrape/youtube', async (req, res) => { try { const { videoUrl } = req.body; if (!videoUrl) return res.status(400).json({ error: 'videoUrl is required' }); const result = await webScraper.scrapeYouTube(videoUrl); res.json({ success: true, data: result }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/scrape/social', async (req, res) => { try { const { platform, username } = req.body; if (!platform || !username) return res.status(400).json({ error: 'platform and username required' }); let result; switch (platform.toLowerCase()) { case 'instagram': case 'ig': result = await webScraper.scrapeInstagramProfile(username); break; case 'tiktok': case 'tt': result = await webScraper.scrapeTikTokProfile(username); break; case 'twitter': case 'x': result = await webScraper.scrapeTwitterProfile(username); break; default: return res.status(400).json({ error: `Unsupported platform: ${platform}` }); } res.json({ success: true, data: result }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/scrape/pdf', upload.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'PDF file required' }); const text = await webScraper.extractPDF(req.file.path); // If channel=telegram or generate=true, proceed to proposal const { channel, generate, user_id, user_name } = req.body; if (channel === 'telegram' || generate === 'true') { const result = await handleProposalGeneration(text, channel || 'web', [], { user_id, user_name }); // Cleanup temp file try { fs.unlinkSync(req.file.path); } catch {} return res.json(result); } // Default: just return text try { fs.unlinkSync(req.file.path); } catch {} res.json({ success: true, data: text }); } catch (e) { console.error('[Server] PDF processing error:', e); res.status(500).json({ error: e.message }); } }); app.post('/api/scrape/audit', async (req, res) => { try { const { domain } = req.body; if (!domain) return res.status(400).json({ error: 'domain is required' }); const result = await webScraper.auditCompetitor(domain); res.json({ success: true, data: result }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ============================================ // API ROUTES — Bridge (for VinOS calls) // ============================================ app.post('/api/bridge/rfq', requireAPIKey, async (req, res) => { try { const { rfq_text } = req.body; if (!rfq_text) return res.status(400).json({ error: 'rfq_text is required' }); const qualification = await leadQualifier.qualify(rfq_text); const proposal = await proposalGenerator.generateProposal(qualification); const project = projectManager.createProject(qualification, proposal); try { const pdfPath = await pdfBuilder.generateProposalPDF(proposal); projectManager.updatePDFPath(project.id, pdfPath); } catch {} res.json({ success: true, project_id: project.id, summary: proposalGenerator.generateTelegramSummary(proposal), client_url: projectManager.getClientURL(project.id), client_pin: project.client_pin, lqs: qualification.lqs, tier: qualification.recommended_tier, total: qualification.amounts.total, }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/bridge/project/:id', requireAPIKey, (req, res) => { const project = projectManager.getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Not found' }); res.json({ success: true, project }); }); // ============================================ // TELEGRAM HELPERS // ============================================ async function _sendTelegram(chatId, text) { if (!BOT_TOKEN) return; try { await axios.post(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, { chat_id: chatId, text: text.substring(0, 4000), parse_mode: 'HTML', }, { timeout: 10000 }); } catch (e) { console.error('[Telegram] Send failed:', e.message); } } // ============================================ // START SERVER // ============================================ app.listen(PORT, '0.0.0.0', () => { console.log(`[QUANTA] Engine running on port ${PORT}`); console.log(`[QUANTA] Dashboard: http://localhost:${PORT}`); console.log(`[QUANTA] Health: http://localhost:${PORT}/api/health`); }); module.exports = app;