quanta-engine / server.js
VinOS Agent
feat: handle user attribution and fixed PDF-to-proposal flow
8b4b0fe
/**
* 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;