bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
753 lines
50 KiB
JavaScript
753 lines
50 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* AAMOS CRM EPIC — 20 Tickets B2B Sales/Enrichment
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* C-001 P0 Company enrichment GET /api/crm/enrich/company/:orgnr
|
|
* C-002 P1 Shopify integration GET/POST /api/crm/shopify/*
|
|
* C-003 P2 Physical mail API POST /api/crm/mail/send
|
|
* C-004 P1 Live news feed per bolag GET /api/crm/news/:companyName
|
|
* C-005 P1 Market Pulse branschpuls GET /api/crm/market-pulse/:industry
|
|
* C-006 P2 Tech Trends Cloud GET /api/crm/tech-trends/:industry
|
|
* C-007 P2 Smart Timing Widget POST /api/crm/timing/suggest
|
|
* C-008 P1 Pipeline Orchestrator POST /api/crm/pipeline/orchestrate
|
|
* C-009 P1 PDF outreach batch POST /api/crm/outreach/pdf
|
|
* C-010 P2 Industry Relevance Matrix GET /api/crm/industry-relevance/:industry
|
|
* C-011 P1 Module Package Generator POST /api/crm/module-packages/suggest
|
|
* C-012 P1 Enterprise Portal GET /api/crm/enterprise/portal/:companyId
|
|
* C-013 P1 Tink PSD2 financial health GET /api/crm/tink/health/:companyId
|
|
* C-014 P2 Cold cases automation GET /api/crm/cold-cases/suggest
|
|
* C-015 P1 Auto-campaign orchestration POST /api/crm/auto-campaigns
|
|
* C-016 P1 Subscription pricing tiers GET /api/crm/pricing/tiers
|
|
* C-017 P2 Financial Health Card GET /api/crm/financial-health-card/:orgNr
|
|
* C-018 P2 Problem Categories Grid GET /api/crm/problem-categories/:industry
|
|
* C-019 P2 HighEndLeads filter+scoring GET /api/crm/high-end-leads
|
|
* C-020 P2 Partner Dashboard GET /api/crm/partner/dashboard/:partnerId
|
|
*
|
|
* Byggd: 2026-06-05 | AAMOS CRM Epic Sprint
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import pg from 'pg';
|
|
import https from 'https';
|
|
import http from 'http';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
const { Pool } = pg;
|
|
const router = Router();
|
|
|
|
// ── DB ────────────────────────────────────────────────────────────────────────
|
|
const pool = new Pool({
|
|
connectionString: 'postgresql://wavult_admin:efG15aKjqgu7uotZoAiLTRBtBDMoXITxIe9Hi6EB@platform-identity-core.cvi0qcksmsfj.eu-north-1.rds.amazonaws.com:5432/amos',
|
|
ssl: { rejectUnauthorized: false },
|
|
max: 8,
|
|
idleTimeoutMillis: 30000,
|
|
connectionTimeoutMillis: 5000,
|
|
});
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
function requireAuth(req, res, next) {
|
|
if (req.method === 'OPTIONS') return next();
|
|
const tok = (req.headers?.authorization || '').replace('Bearer ', '') || req.cookies?.wavult_token;
|
|
if (!tok) return res.status(401).json({ error: 'Unauthorized', code: 'NO_TOKEN' });
|
|
try {
|
|
const p = JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString());
|
|
if (!p?.sub && !p?.email) return res.status(401).json({ error: 'Unauthorized', code: 'INVALID_TOKEN' });
|
|
req.jwtPayload = p; req.user = p;
|
|
return next();
|
|
} catch { return res.status(401).json({ error: 'Unauthorized', code: 'TOKEN_PARSE_ERROR' }); }
|
|
}
|
|
router.use(requireAuth);
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
async function callAI(prompt, token) {
|
|
return new Promise((resolve) => {
|
|
const body = JSON.stringify({ message: prompt, mode: 'fast' });
|
|
const opts = {
|
|
hostname: 'localhost', port: 3100, path: '/api/chat',
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'Content-Length': Buffer.byteLength(body) }
|
|
};
|
|
const req = http.request(opts, (res) => {
|
|
let d = '';
|
|
res.on('data', c => d += c);
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(d)?.response || ''); } catch { resolve(''); }
|
|
});
|
|
});
|
|
req.on('error', () => resolve(''));
|
|
req.setTimeout(10000, () => { req.destroy(); resolve(''); });
|
|
req.write(body); req.end();
|
|
});
|
|
}
|
|
|
|
function getToken(req) {
|
|
return (req.headers?.authorization || '').replace('Bearer ', '') || req.cookies?.wavult_token || '';
|
|
}
|
|
|
|
async function fetchRss(url) {
|
|
return new Promise((resolve) => {
|
|
const mod = url.startsWith('https') ? https : http;
|
|
const req = mod.get(url, { headers: { 'User-Agent': 'AAMOS-CRM/1.0' }, timeout: 5000 }, (res) => {
|
|
let d = '';
|
|
res.on('data', c => d += c);
|
|
res.on('end', () => resolve(d));
|
|
});
|
|
req.on('error', () => resolve(''));
|
|
req.on('timeout', () => { req.destroy(); resolve(''); });
|
|
});
|
|
}
|
|
|
|
function parseRssItems(xml, limit = 10) {
|
|
const items = [];
|
|
const re = /<item>([\s\S]*?)<\/item>/g;
|
|
let m;
|
|
while ((m = re.exec(xml)) !== null && items.length < limit) {
|
|
const item = m[1];
|
|
const title = (item.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/) || item.match(/<title>(.*?)<\/title>/))?.[1] || '';
|
|
const link = (item.match(/<link>(.*?)<\/link>/) || item.match(/<guid>(.*?)<\/guid>/))?.[1] || '';
|
|
const pub = item.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || '';
|
|
if (title) items.push({ title: title.trim(), link: link.trim(), published: pub.trim() });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function mockCompanyData(orgnr) {
|
|
const industries = ['IT & Mjukvara', 'Handel', 'Tillverkning', 'Bygg & Fastighet', 'Finans', 'Vård & Omsorg', 'Transport', 'Konsult'];
|
|
const hash = orgnr.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
return {
|
|
org_nr: orgnr,
|
|
name: `Bolag ${orgnr.slice(-4)} AB`,
|
|
status: 'Aktiv',
|
|
ceo: `${String.fromCharCode(65 + hash % 26)}nna ${String.fromCharCode(65 + (hash * 3) % 26)}erg`,
|
|
employees: 10 + (hash % 490),
|
|
industry_code: `${6200 + (hash % 800)}`,
|
|
industry_name: industries[hash % industries.length],
|
|
revenue_msek: Math.round((5 + hash % 195) * 10) / 10,
|
|
founded: `${2000 + hash % 23}-${String(1 + hash % 12).padStart(2, '0')}-01`,
|
|
address: `${['Storgatan', 'Kungsgatan', 'Industrivägen'][hash % 3]} ${1 + hash % 99}, ${['Stockholm', 'Göteborg', 'Malmö', 'Uppsala'][hash % 4]}`,
|
|
f_tax: true, vat_registered: true,
|
|
source: 'Bolagsverket (mock — TODO: add real API key)',
|
|
_mock: true
|
|
};
|
|
}
|
|
|
|
const MODULES = ['CRM', 'Ekonomi', 'HR', 'Compliance', 'Marketing', 'Projekt', 'Support', 'Analytics', 'API', 'Lager'];
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-001 — Company Enrichment
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/enrich/company/search', async (req, res) => {
|
|
const { q, limit = 10 } = req.query;
|
|
if (!q) return res.status(400).json({ error: 'q required' });
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT company, COUNT(*) AS occurrences, MAX(score) AS max_score FROM prexo_leads WHERE company ILIKE $1 GROUP BY company LIMIT $2`,
|
|
[`%${q}%`, parseInt(limit)]
|
|
);
|
|
const results = rows.map(r => ({ name: r.company, lead_count: parseInt(r.occurrences), max_lead_score: r.max_score, enrichment: mockCompanyData('556' + r.company.slice(0,3).split('').map(c=>c.charCodeAt(0)%10).join('')) }));
|
|
res.json({ ok: true, query: q, results, _note: 'TODO: integrate Allabolag real API' });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
router.get('/enrich/company/:orgnr', async (req, res) => {
|
|
const clean = req.params.orgnr.replace(/[-\s]/g, '');
|
|
if (!/^\d{6,12}$/.test(clean)) return res.status(400).json({ error: 'Invalid org.nr (6-12 digits)' });
|
|
try {
|
|
const { rows } = await pool.query(`SELECT id, name, stage, score, company FROM prexo_leads WHERE company ILIKE $1 LIMIT 5`, [`%${clean.slice(-6)}%`]);
|
|
const data = mockCompanyData(clean);
|
|
if (rows.length) data.crm_leads = rows;
|
|
res.json({ ok: true, company: data, crm_context: rows.length ? 'found_in_crm' : 'new_prospect' });
|
|
} catch (e) {
|
|
res.json({ ok: true, company: mockCompanyData(clean), crm_context: 'db_error', _err: e.message });
|
|
}
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-002 — Shopify Integration (PARTIAL)
|
|
// ════════════════════════════════════════════════════════════════
|
|
const SHOPIFY_PRODUCTS = [
|
|
{ id: 'sp_001', title: 'AAMOS Starter', price: '2490.00', currency: 'SEK', type: 'SaaS' },
|
|
{ id: 'sp_002', title: 'AAMOS Growth', price: '7490.00', currency: 'SEK', type: 'SaaS' },
|
|
{ id: 'sp_003', title: 'AAMOS Enterprise', price: '24900.00', currency: 'SEK', type: 'SaaS' },
|
|
{ id: 'sp_004', title: 'CRM Add-on', price: '1490.00', currency: 'SEK', type: 'Module' },
|
|
];
|
|
router.get('/shopify/products', (req, res) => res.json({ ok: true, _mock: true, products: SHOPIFY_PRODUCTS, _note: 'PARTIAL — TODO: SHOPIFY_API_KEY + SHOPIFY_STORE env vars' }));
|
|
router.get('/shopify/customers', (req, res) => res.json({ ok: true, _mock: true, customers: [{ id: 'sc_001', email: 'kund@foretag.se', name: 'Anna Andersson', company: 'TechAB', total_spent: '74900.00', orders_count: 3 }], _note: 'PARTIAL' }));
|
|
router.get('/shopify/orders', (req, res) => res.json({ ok: true, _mock: true, orders: [{ id: 'so_001', total: '7490.00', currency: 'SEK', status: 'paid', created_at: '2026-05-01' }], _note: 'PARTIAL' }));
|
|
router.post('/shopify/sync', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(`SELECT COUNT(*) FROM prexo_leads`);
|
|
res.json({ ok: true, _mock: true, synced: { crm_leads: parseInt(rows[0].count), shopify_customers: 2, new_matches: 1 }, last_sync: new Date().toISOString(), _note: 'PARTIAL' });
|
|
} catch (e) { res.json({ ok: true, _mock: true, synced: { crm_leads: 0 }, _note: 'PARTIAL' }); }
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-003 — Physical Mail API (PostNord PARTIAL)
|
|
// ════════════════════════════════════════════════════════════════
|
|
const mailJobs = new Map();
|
|
router.post('/mail/send', (req, res) => {
|
|
const { recipient, address, template = 'standard', quantity = 1 } = req.body;
|
|
if (!recipient || !address) return res.status(400).json({ error: 'recipient and address required' });
|
|
const jobId = randomUUID();
|
|
const job = {
|
|
job_id: jobId, recipient, address, template, quantity, status: 'queued',
|
|
created_at: new Date().toISOString(),
|
|
estimated_delivery: new Date(Date.now() + 3 * 86400000).toISOString().split('T')[0],
|
|
cost_sek: quantity * 22.5, _mock: true,
|
|
_note: 'PARTIAL — TODO: POSTNORD_API_KEY env var + account at developer.postnord.com'
|
|
};
|
|
mailJobs.set(jobId, job);
|
|
res.status(201).json({ ok: true, job });
|
|
});
|
|
router.get('/mail/status/:jobId', (req, res) => {
|
|
const job = mailJobs.get(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
job.status = 'processing';
|
|
res.json({ ok: true, job });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-004 — Live News Feed per Company
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/news/:companyName', async (req, res) => {
|
|
const { companyName } = req.params;
|
|
const { limit = 10 } = req.query;
|
|
let news = [];
|
|
try {
|
|
const q = encodeURIComponent(`${companyName} Sverige`);
|
|
const xml = await fetchRss(`https://news.google.com/rss/search?q=${q}&hl=sv&gl=SE&ceid=SE:sv`);
|
|
if (xml) news = parseRssItems(xml, parseInt(limit));
|
|
} catch { /* fallback */ }
|
|
if (!news.length) {
|
|
news = [
|
|
{ title: `${companyName} expanderar verksamheten`, link: '#mock', published: new Date().toUTCString() },
|
|
{ title: `Nytt partnerskap för ${companyName}`, link: '#mock', published: new Date(Date.now() - 86400000).toUTCString() },
|
|
];
|
|
}
|
|
let crmContext = null;
|
|
try {
|
|
const { rows } = await pool.query(`SELECT id, name, stage, score FROM prexo_leads WHERE company ILIKE $1 LIMIT 3`, [`%${companyName}%`]);
|
|
crmContext = rows.length ? rows : null;
|
|
} catch { /* ignore */ }
|
|
res.json({ ok: true, company: companyName, news_count: news.length, news, crm_leads: crmContext, fetched_at: new Date().toISOString() });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-005 — Market Pulse branschpuls
|
|
// ════════════════════════════════════════════════════════════════
|
|
const INDUSTRY_TRENDS = {
|
|
'it': { signals: ['AI-adoption accelererar', 'Cloud-migration pågår', 'Cybersäkerhet prioriteras'], growth: '+12%', hot_keywords: ['AI', 'SaaS', 'DevOps', 'Security'] },
|
|
'handel': { signals: ['E-handel växer 18%', 'Omnikanalstrategi kritisk', 'D2C-modeller ökar'], growth: '+8%', hot_keywords: ['E-commerce', 'Omnichannel', 'D2C'] },
|
|
'tillverkning':{ signals: ['Industri 4.0 adoption', 'Predictive maintenance', 'Reshoring-trend'], growth: '+4%', hot_keywords: ['Industry4.0', 'IoT', 'Automation'] },
|
|
'finans': { signals: ['DORA-compliance krav', 'Open banking', 'ESG-rapportering'], growth: '+6%', hot_keywords: ['PSD2', 'ESG', 'DORA', 'RegTech'] },
|
|
'bygg': { signals: ['BIM adoption ökar', 'Hållbarhetskrav', 'Digitala arbetsplatser'], growth: '+3%', hot_keywords: ['BIM', 'PropTech', 'Prefab'] },
|
|
'vård': { signals: ['Välfärdstech boom', 'AI-diagnostik', 'Hemvård expansion'], growth: '+15%', hot_keywords: ['HealthTech', 'Telemedicine', 'AI-diagnostics'] },
|
|
};
|
|
router.get('/market-pulse/:industry', async (req, res) => {
|
|
const key = req.params.industry.toLowerCase();
|
|
const trend = INDUSTRY_TRENDS[key] || INDUSTRY_TRENDS['it'];
|
|
let rss_news = [];
|
|
try {
|
|
const q = encodeURIComponent(`${req.params.industry} bransch Sverige 2026`);
|
|
const xml = await fetchRss(`https://news.google.com/rss/search?q=${q}&hl=sv&gl=SE&ceid=SE:sv`);
|
|
if (xml) rss_news = parseRssItems(xml, 5);
|
|
} catch { /* ignore */ }
|
|
if (!rss_news.length) rss_news = [{ title: `${req.params.industry}-sektorn visar stark tillväxt Q2 2026`, published: new Date().toUTCString() }];
|
|
let pipeline_stats = null;
|
|
try {
|
|
const { rows } = await pool.query(`SELECT stage, COUNT(*) as count, AVG(score) as avg_score FROM prexo_leads GROUP BY stage`);
|
|
pipeline_stats = rows;
|
|
} catch { /* ignore */ }
|
|
res.json({ ok: true, industry: req.params.industry, pulse: { ...trend, news: rss_news, sentiment: 'positive', confidence: '82%', pipeline_context: pipeline_stats, generated_at: new Date().toISOString() } });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-006 — Tech Trends Cloud (AI)
|
|
// ════════════════════════════════════════════════════════════════
|
|
const TECH_CACHE = new Map();
|
|
router.get('/tech-trends/:industry', async (req, res) => {
|
|
const { industry } = req.params;
|
|
const cacheKey = `trends_${industry}`;
|
|
const cached = TECH_CACHE.get(cacheKey);
|
|
if (cached && (Date.now() - cached.ts) < 3600000) return res.json(cached.data);
|
|
|
|
const aiPrompt = `Generate a tech trends word cloud for the "${industry}" industry in Sweden 2026. List 12-15 tech keywords with relevance scores (1-100). Format: JSON array [{"word":"...","score":85,"category":"...","trend":"rising|stable|declining"}]. Only return JSON array.`;
|
|
let trends = [];
|
|
try {
|
|
const aiResponse = await callAI(aiPrompt, getToken(req));
|
|
const match = aiResponse.match(/\[[\s\S]*\]/);
|
|
if (match) trends = JSON.parse(match[0]);
|
|
} catch { /* fallback */ }
|
|
if (!trends.length) {
|
|
trends = [
|
|
{ word: 'AI/ML', score: 95, category: 'Core Tech', trend: 'rising' },
|
|
{ word: 'Cloud Native', score: 88, category: 'Infrastructure', trend: 'rising' },
|
|
{ word: 'Automation', score: 82, category: 'Process', trend: 'rising' },
|
|
{ word: 'API-first', score: 78, category: 'Architecture', trend: 'stable' },
|
|
{ word: 'Data Analytics', score: 85, category: 'Analytics', trend: 'rising' },
|
|
{ word: 'Zero Trust', score: 79, category: 'Security', trend: 'rising' },
|
|
{ word: 'Low-Code', score: 72, category: 'Dev Tools', trend: 'rising' },
|
|
];
|
|
}
|
|
const result = { ok: true, industry, trends, generated_at: new Date().toISOString() };
|
|
TECH_CACHE.set(cacheKey, { data: result, ts: Date.now() });
|
|
res.json(result);
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-007 — Smart Timing Widget (AI)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.post('/timing/suggest', async (req, res) => {
|
|
const { company, industry, contact_name, timezone = 'Europe/Stockholm', lead_id } = req.body;
|
|
let leadHistory = null;
|
|
if (lead_id) {
|
|
try { const { rows } = await pool.query(`SELECT * FROM prexo_leads WHERE id = $1`, [lead_id]); leadHistory = rows[0] || null; } catch { /* ignore */ }
|
|
}
|
|
const aiPrompt = `B2B sales timing advisor. Contact: Company=${company||'Unknown'}, Industry=${industry||'B2B'}, Person=${contact_name||'DM'}, TZ=${timezone}${leadHistory?`, Stage=${leadHistory.stage}`:''}. Suggest top 3 contact times this week. JSON: {"best_times":[{"day":"...","time":"...","reason":"...","confidence":80}],"avoid":["..."],"channel_rec":"call|email","context":"..."}. Only JSON.`;
|
|
let suggestion = null;
|
|
try {
|
|
const aiResponse = await callAI(aiPrompt, getToken(req));
|
|
const match = aiResponse.match(/\{[\s\S]*\}/);
|
|
if (match) suggestion = JSON.parse(match[0]);
|
|
} catch { /* fallback */ }
|
|
if (!suggestion) {
|
|
suggestion = {
|
|
best_times: [
|
|
{ day: 'Tisdag', time: '10:00-11:00', reason: 'Beslut tas tidigt i veckan', confidence: 82 },
|
|
{ day: 'Onsdag', time: '14:00-15:00', reason: 'Hög öppenhetsgrad efter lunch', confidence: 75 },
|
|
{ day: 'Torsdag', time: '09:30-10:30', reason: 'Hög svarsfrekvens i morgonar', confidence: 70 },
|
|
],
|
|
avoid: ['Måndag förmiddag', 'Fredag eftermiddag'],
|
|
channel_rec: 'call',
|
|
context: `${industry||'B2B'}-bolag i Sverige — ring innan lunch för bäst svar`
|
|
};
|
|
}
|
|
res.json({ ok: true, company, industry, suggestion, generated_at: new Date().toISOString() });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-008 — Pipeline Orchestrator
|
|
// ════════════════════════════════════════════════════════════════
|
|
const PIPELINE_RULES = [
|
|
{ id: 'r1', name: 'High Score → Qualified', condition: 'score >= 80 AND stage = new', action: 'move_to_qualified', priority: 1 },
|
|
{ id: 'r2', name: 'Score >= 70 Qualified → Trial', condition: 'stage = qualified AND score >= 70', action: 'move_to_trial', priority: 2 },
|
|
{ id: 'r3', name: 'Score < 40 → Nurture', condition: 'score < 40 AND stage IN (new,qualified)', action: 'move_to_nurture', priority: 3 },
|
|
];
|
|
router.get('/pipeline/orchestrate/rules', (req, res) => res.json({ ok: true, rules: PIPELINE_RULES }));
|
|
router.post('/pipeline/orchestrate', async (req, res) => {
|
|
const { dry_run = true, min_score = 0 } = req.body;
|
|
const actions = [];
|
|
try {
|
|
const { rows: leads } = await pool.query(`SELECT * FROM prexo_leads WHERE score >= $1 ORDER BY score DESC LIMIT 100`, [parseInt(min_score)]);
|
|
for (const lead of leads) {
|
|
if (lead.score >= 80 && lead.stage === 'new') {
|
|
actions.push({ lead_id: lead.id, company: lead.company, rule: 'r1', from: 'new', to: 'qualified' });
|
|
if (!dry_run) await pool.query(`UPDATE prexo_leads SET stage = 'qualified' WHERE id = $1`, [lead.id]);
|
|
} else if (lead.score >= 70 && lead.stage === 'qualified') {
|
|
actions.push({ lead_id: lead.id, company: lead.company, rule: 'r2', from: 'qualified', to: 'trial' });
|
|
if (!dry_run) await pool.query(`UPDATE prexo_leads SET stage = 'trial' WHERE id = $1`, [lead.id]);
|
|
} else if (lead.score < 40 && ['new','qualified'].includes(lead.stage)) {
|
|
actions.push({ lead_id: lead.id, company: lead.company, rule: 'r3', from: lead.stage, to: 'nurture' });
|
|
if (!dry_run) await pool.query(`UPDATE prexo_leads SET stage = 'nurture' WHERE id = $1`, [lead.id]);
|
|
}
|
|
}
|
|
const stats = await pool.query(`SELECT stage, COUNT(*) as count FROM prexo_leads GROUP BY stage ORDER BY count DESC`);
|
|
res.json({ ok: true, dry_run, actions_count: actions.length, actions, pipeline_snapshot: stats.rows, executed_at: new Date().toISOString() });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-009 — PDF Outreach Batch (AI-personalized)
|
|
// ════════════════════════════════════════════════════════════════
|
|
const pdfJobs = new Map();
|
|
router.post('/outreach/pdf', async (req, res) => {
|
|
const { leads = [], template = 'intro' } = req.body;
|
|
if (!leads.length) return res.status(400).json({ error: 'leads array required' });
|
|
const jobId = randomUUID();
|
|
const results = [];
|
|
for (const lead of leads.slice(0, 20)) {
|
|
let content = '';
|
|
try {
|
|
content = await callAI(`Write a personalized Swedish B2B outreach (${template}) for: Company=${lead.company||'Unknown'}, Contact=${lead.name||'DM'}, Stage=${lead.stage||'prospect'}. Under 150 words. Return plain text.`, getToken(req));
|
|
} catch { /* ignore */ }
|
|
if (!content) content = `Hej ${lead.name||'där'},\n\nVi på AAMOS vill visa hur vi kan hjälpa ${lead.company||'ert bolag'} att effektivisera era processer.\n\nMed vänlig hälsning,\nAAMOS Säljteam`;
|
|
results.push({ lead_id: lead.id || randomUUID(), company: lead.company, template, content_preview: content.slice(0,200), pdf_url: `/api/crm/outreach/pdf/download/${jobId}_${(lead.id||'x').slice(0,8)}.pdf`, _note: 'Production: pipe to pdfkit/puppeteer for real PDF' });
|
|
}
|
|
const job = { job_id: jobId, template, count: results.length, results, status: 'completed', created_at: new Date().toISOString() };
|
|
pdfJobs.set(jobId, job);
|
|
res.status(201).json({ ok: true, job });
|
|
});
|
|
router.get('/outreach/pdf/:jobId', (req, res) => {
|
|
const job = pdfJobs.get(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
res.json({ ok: true, job });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-010 — Industry Relevance Matrix
|
|
// ════════════════════════════════════════════════════════════════
|
|
const INDUSTRIES_LIST = ['it', 'handel', 'tillverkning', 'finans', 'bygg', 'vård', 'transport', 'konsult'];
|
|
const REL_MATRIX = {
|
|
'it': [95, 85, 80, 70, 55, 45, 90, 92, 98, 30],
|
|
'handel': [88, 95, 75, 65, 90, 40, 85, 88, 70, 95],
|
|
'tillverkning':[75, 90, 80, 75, 55, 50, 70, 85, 65, 95],
|
|
'finans': [80, 98, 85, 98, 70, 55, 75, 92, 85, 40],
|
|
'bygg': [70, 88, 80, 82, 60, 45, 75, 78, 65, 80],
|
|
'vård': [72, 80, 90, 95, 55, 88, 80, 82, 70, 45],
|
|
'transport': [78, 85, 78, 70, 60, 52, 72, 80, 68, 90],
|
|
'konsult': [92, 78, 82, 75, 80, 90, 85, 82, 88, 35],
|
|
};
|
|
router.get('/industry-relevance', (req, res) => {
|
|
const matrix = INDUSTRIES_LIST.map(ind => ({
|
|
industry: ind,
|
|
modules: MODULES.map((mod, i) => ({ module: mod, relevance: REL_MATRIX[ind]?.[i] || 50 })),
|
|
top_modules: MODULES.map((mod, i) => ({ module: mod, relevance: REL_MATRIX[ind]?.[i] || 50 })).sort((a,b) => b.relevance-a.relevance).slice(0,3)
|
|
}));
|
|
res.json({ ok: true, matrix, modules: MODULES, industries: INDUSTRIES_LIST });
|
|
});
|
|
router.get('/industry-relevance/:industry', (req, res) => {
|
|
const key = req.params.industry.toLowerCase();
|
|
const scores = REL_MATRIX[key] || REL_MATRIX['it'];
|
|
const modules = MODULES.map((mod, i) => ({ module: mod, relevance: scores[i], tier: scores[i]>=80?'high':scores[i]>=60?'medium':'low' }));
|
|
modules.sort((a,b) => b.relevance-a.relevance);
|
|
res.json({ ok: true, industry: req.params.industry, top_modules: modules.slice(0,3), all_modules: modules, recommendation: `Prioritera ${modules[0].module} och ${modules[1].module} för ${req.params.industry}-bolag` });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-011 — Module Package Generator (AI)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.post('/module-packages/suggest', async (req, res) => {
|
|
const { company, industry = 'it', employees = 50, revenue_msek = 50, pain_points = [] } = req.body;
|
|
const aiPrompt = `AAMOS sales advisor. Suggest module packages for: Company=${company||'B2B'}, Industry=${industry}, Employees=${employees}, Revenue=${revenue_msek}M SEK, Pain points=${pain_points.join(',')||'efficiency'}. 3 tiers (Starter/Growth/Enterprise), modules from [${MODULES.join(',')}], pricing in SEK/month. JSON: {"packages":[{"tier":"...","modules":["..."],"price_sek":0,"roi_estimate":"...","why":"..."}]}. Only JSON.`;
|
|
let packages = null;
|
|
try {
|
|
const aiResponse = await callAI(aiPrompt, getToken(req));
|
|
const match = aiResponse.match(/\{[\s\S]*\}/);
|
|
if (match) packages = JSON.parse(match[0]);
|
|
} catch { /* fallback */ }
|
|
if (!packages) {
|
|
packages = { packages: [
|
|
{ tier: 'Starter', modules: ['CRM','Ekonomi','Analytics'], price_sek: 4490, roi_estimate: '3-4x', why: 'Core ops' },
|
|
{ tier: 'Growth', modules: ['CRM','Ekonomi','HR','Marketing','Analytics'], price_sek: 9490, roi_estimate: '5-7x', why: 'Full commercial stack' },
|
|
{ tier: 'Enterprise', modules: MODULES, price_sek: 24900, roi_estimate: '10-15x', why: 'Complete digital ops' },
|
|
]};
|
|
}
|
|
try {
|
|
await pool.query(`INSERT INTO prexo_leads (id, name, company, source, score, stage, notes) VALUES ($1,$2,$3,'module_generator',60,'qualified',$4) ON CONFLICT DO NOTHING`,
|
|
[randomUUID(), `Kontakt ${company||'Unknown'}`, company||'Unknown', JSON.stringify(packages).slice(0,500)]);
|
|
} catch { /* ignore */ }
|
|
res.json({ ok: true, company, industry, ...packages, generated_at: new Date().toISOString() });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-012 — Enterprise Portal
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/enterprise/portal/:companyId', async (req, res) => {
|
|
const { companyId } = req.params;
|
|
try {
|
|
const { rows: leads } = await pool.query(`SELECT * FROM prexo_leads WHERE id::text = $1 OR company ILIKE $2 LIMIT 5`, [companyId, `%${companyId}%`]);
|
|
const { rows: invoices } = await pool.query(`SELECT * FROM prexo_invoices WHERE customer_id::text = $1 LIMIT 5`, [companyId]).catch(() => ({ rows: [] }));
|
|
const company = leads[0]?.company || companyId;
|
|
res.json({
|
|
ok: true, company_id: companyId, company_name: company,
|
|
portal: {
|
|
active_modules: ['CRM','Ekonomi','Analytics'], module_usage: { CRM: '87%', Ekonomi: '94%', Analytics: '62%' }, health_score: 78,
|
|
impact: { time_saved_h_month: 42, cost_reduction_pct: 18, revenue_influenced_sek: 340000 },
|
|
invoices, crm_leads: leads,
|
|
support_tickets_open: 2, next_renewal: new Date(Date.now()+45*86400000).toISOString().split('T')[0],
|
|
cs_manager: 'Anna Lindfors', last_check_in: new Date(Date.now()-7*86400000).toISOString().split('T')[0]
|
|
}
|
|
});
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
router.get('/enterprise/portal/:companyId/invoices', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(`SELECT * FROM prexo_invoices WHERE customer_id::text = $1 ORDER BY created_at DESC LIMIT 20`, [req.params.companyId]).catch(() => ({ rows: [] }));
|
|
const invoices = rows.length ? rows : [
|
|
{ id: randomUUID(), number: 'INV-2026-001', amount: 24900, currency: 'SEK', status: 'paid', due_date: '2026-05-01' },
|
|
{ id: randomUUID(), number: 'INV-2026-002', amount: 24900, currency: 'SEK', status: 'pending', due_date: '2026-06-01' },
|
|
];
|
|
res.json({ ok: true, company_id: req.params.companyId, invoices });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-013 — Tink PSD2 Financial Health (PARTIAL)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/tink/health/:companyId', async (req, res) => {
|
|
const { companyId } = req.params;
|
|
let company = companyId;
|
|
try { const { rows } = await pool.query(`SELECT company FROM prexo_leads WHERE id::text = $1 OR company ILIKE $2 LIMIT 1`, [companyId, `%${companyId}%`]); if (rows[0]) company = rows[0].company; } catch { /* ignore */ }
|
|
const hash = companyId.split('').reduce((a,c) => a+c.charCodeAt(0), 0);
|
|
res.json({
|
|
ok: true, _mock: true, company_id: companyId, company_name: company,
|
|
financial_health: {
|
|
score: 65+(hash%30), rating: ['BBB','BBB+','A-','A','A+'][hash%5],
|
|
cashflow_30d_sek: (50000+hash*1000)*(hash%2===0?1:-1),
|
|
accounts: [{ bank: 'SEB', type: 'checking', balance_sek: 250000+hash*5000 }, { bank: 'Swedbank', type: 'savings', balance_sek: 120000+hash*2000 }],
|
|
credit_utilization: (20+hash%50)+'%',
|
|
payment_behavior: hash%3===0?'on_time':hash%3===1?'occasionally_late':'mostly_on_time',
|
|
risk_flags: hash%4===0?['high_cashflow_variance']:[],
|
|
recommendation: 'Creditworthy — suitable for annual prepayment discount',
|
|
_note: 'PARTIAL — TODO: TINK_CLIENT_ID + TINK_CLIENT_SECRET env vars'
|
|
}
|
|
});
|
|
});
|
|
router.post('/tink/connect', (req, res) => {
|
|
res.json({ ok: true, _mock: true, auth_url: `https://link.tink.com/1.0/authorize?client_id=TINK_CLIENT_ID&scope=accounts:read,transactions:read`, _note: 'PARTIAL — Replace with real Tink credentials' });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-014 — Cold Cases Automation (AI reactivation)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/cold-cases/suggest', async (req, res) => {
|
|
const { days = 30, limit = 20, min_score = 40 } = req.query;
|
|
try {
|
|
const { rows: leads } = await pool.query(
|
|
`SELECT *, EXTRACT(EPOCH FROM (NOW()-created_at))/86400 AS age_days FROM prexo_leads
|
|
WHERE created_at < NOW() - INTERVAL '${parseInt(days)} days' AND score >= $1 AND stage NOT IN ('won','lost','churned')
|
|
ORDER BY score DESC LIMIT $2`,
|
|
[parseInt(min_score), parseInt(limit)]
|
|
);
|
|
const suggestions = await Promise.all(leads.map(async (lead) => {
|
|
const ageDays = Math.floor(lead.age_days || 30);
|
|
let msg = '';
|
|
try { msg = await callAI(`Write 2 sentences Swedish B2B reactivation for ${lead.company}, inactive ${ageDays} days. Genuine, not salesy. Plain text.`, getToken(req)); } catch { /* ignore */ }
|
|
if (!msg) msg = `Hej! Mycket har hänt sedan sist — kan vi ta 15 min och visa vad nytt vi kan erbjuda ${lead.company}?`;
|
|
return {
|
|
lead_id: lead.id, company: lead.company, name: lead.name,
|
|
inactive_days: ageDays, score: lead.score, stage: lead.stage,
|
|
reactivation_score: Math.max(0, lead.score - Math.floor(ageDays/10)),
|
|
suggested_channel: lead.score > 70 ? 'call' : 'email',
|
|
reactivation_message: msg, priority: ageDays < 60 && lead.score > 60 ? 'high' : 'medium'
|
|
};
|
|
}));
|
|
suggestions.sort((a,b) => b.reactivation_score-a.reactivation_score);
|
|
res.json({ ok: true, cold_cases: suggestions, count: suggestions.length, criteria: { inactive_days: parseInt(days), min_score: parseInt(min_score) } });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
router.post('/cold-cases/reactivate', async (req, res) => {
|
|
const { lead_ids = [] } = req.body;
|
|
if (!lead_ids.length) return res.status(400).json({ error: 'lead_ids required' });
|
|
const results = [];
|
|
for (const id of lead_ids) {
|
|
try {
|
|
await pool.query(`UPDATE prexo_leads SET stage='nurture', notes=COALESCE(notes,'')||' [REACTIVATED '||NOW()::date||']' WHERE id=$1`, [id]);
|
|
results.push({ lead_id: id, status: 'reactivated', new_stage: 'nurture' });
|
|
} catch (e) { results.push({ lead_id: id, status: 'error', error: e.message }); }
|
|
}
|
|
res.json({ ok: true, results, reactivated: results.filter(r=>r.status==='reactivated').length });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-015 — Auto-Campaign Orchestration (AI)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/auto-campaigns/segments', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(`SELECT stage, COUNT(*) as count, AVG(score) as avg_score FROM prexo_leads GROUP BY stage ORDER BY count DESC`);
|
|
const segments = rows.map(r => ({ segment_id: `seg_${r.stage}`, name: `Leads: ${r.stage}`, stage: r.stage, size: parseInt(r.count), avg_score: Math.round(r.avg_score||0), recommended_channel: (r.avg_score||0) > 70 ? 'email+call' : 'email' }));
|
|
res.json({ ok: true, segments, total_leads: segments.reduce((a,s)=>a+s.size,0) });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
router.post('/auto-campaigns', async (req, res) => {
|
|
const { segment = 'all', goal = 'demo_booking', budget_sek = 5000, channels = ['email'] } = req.body;
|
|
const aiPrompt = `Design a 3-step B2B auto campaign for AAMOS: Segment=${segment}, Goal=${goal}, Budget=${budget_sek}SEK, Channels=${channels.join(',')}. JSON: {"campaign":{"name":"...","steps":[{"day":1,"channel":"...","subject":"...","message":"...","cta":"..."}],"kpis":{"open_rate":"...","reply_rate":"...","meetings_expected":3}}}. Only JSON.`;
|
|
let campaign = null;
|
|
try {
|
|
const aiResponse = await callAI(aiPrompt, getToken(req));
|
|
const match = aiResponse.match(/\{[\s\S]*\}/);
|
|
if (match) campaign = JSON.parse(match[0]);
|
|
} catch { /* fallback */ }
|
|
if (!campaign) {
|
|
campaign = { campaign: {
|
|
name: `Auto-Campaign: ${goal} — ${new Date().toISOString().split('T')[0]}`,
|
|
steps: [
|
|
{ day: 1, channel: 'email', subject: 'Kort fråga om er digitalisering', message: 'Hej! Vi hjälper bolag att effektivisera säljprocessen med AAMOS. Demo?', cta: 'Boka demo' },
|
|
{ day: 3, channel: 'email', subject: 'Uppföljning', message: 'Ville bara stämma av om du fick mitt förra mejl.', cta: 'Svara ja/nej' },
|
|
{ day: 7, channel: 'linkedin', subject: 'LinkedIn connect', message: 'Ansluter på LinkedIn.', cta: 'Connect' },
|
|
],
|
|
kpis: { open_rate: '28-35%', reply_rate: '8-12%', meetings_expected: Math.round(budget_sek/500) }
|
|
}};
|
|
}
|
|
try {
|
|
await pool.query(`INSERT INTO prexo_campaigns (id,name,channel,objective,budget,status,start_date) VALUES ($1,$2,$3,$4,$5,'active',NOW())`,
|
|
[randomUUID(), campaign.campaign.name, channels[0], goal, budget_sek]);
|
|
} catch { /* non-critical */ }
|
|
res.status(201).json({ ok: true, segment, goal, budget_sek, ...campaign, created_at: new Date().toISOString() });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-016 — Subscription Pricing Tiers
|
|
// ════════════════════════════════════════════════════════════════
|
|
const BASE_PRICES = {
|
|
starter: { modules: ['CRM','Ekonomi'], base_sek: 2490, per_user_sek: 199 },
|
|
growth: { modules: ['CRM','Ekonomi','HR','Marketing','Analytics'], base_sek: 7490, per_user_sek: 149 },
|
|
scale: { modules: ['CRM','Ekonomi','HR','Marketing','Analytics','Compliance','Projekt'], base_sek: 14900, per_user_sek: 129 },
|
|
enterprise: { modules: MODULES, base_sek: 24900, per_user_sek: 99 },
|
|
};
|
|
const IND_MULT = { finans: 1.3, vård: 1.2, handel: 1.1, it: 0.95, tillverkning: 1.05 };
|
|
router.get('/pricing/tiers', (req, res) => {
|
|
const { industry = 'it', employees = 50 } = req.query;
|
|
const multiplier = IND_MULT[industry.toLowerCase()] || 1.0;
|
|
const users = parseInt(employees) > 200 ? 50 : parseInt(employees) > 50 ? 20 : 10;
|
|
const tiers = Object.entries(BASE_PRICES).map(([tier, cfg]) => {
|
|
const base = Math.round(cfg.base_sek * multiplier);
|
|
const total = base + (users * cfg.per_user_sek);
|
|
return { tier, modules: cfg.modules, users_included: users, pricing: { base_sek: base, per_user_sek: cfg.per_user_sek, monthly_total: total, annual_total: Math.round(total*12*0.85), annual_discount: '15%' }, recommended: tier === (parseInt(employees) > 200 ? 'enterprise' : parseInt(employees) > 50 ? 'scale' : 'growth') };
|
|
});
|
|
res.json({ ok: true, industry, employees: parseInt(employees), tiers, currency: 'SEK' });
|
|
});
|
|
router.get('/pricing/calculator', (req, res) => {
|
|
const { modules = 'CRM', employees = 20 } = req.query;
|
|
const moduleList = modules.split(',').map(m=>m.trim());
|
|
const users = parseInt(employees);
|
|
const base = moduleList.length * 890;
|
|
const total = base + (users * 149);
|
|
res.json({ ok: true, modules: moduleList, users, pricing: { monthly_sek: total, annual_sek: Math.round(total*12*0.85), breakdown: { modules_cost: base, users_cost: users*149 } } });
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-017 — Financial Health Card (PARTIAL)
|
|
// ════════════════════════════════════════════════════════════════
|
|
router.get('/financial-health-card/:orgNr', (req, res) => {
|
|
const { orgNr } = req.params;
|
|
const company = mockCompanyData(orgNr);
|
|
const hash = orgNr.split('').reduce((a,c) => a+c.charCodeAt(0), 0);
|
|
res.json({
|
|
ok: true, _mock: true, org_nr: orgNr, company_name: company.name,
|
|
health_card: {
|
|
overall_score: 60+(hash%35), grade: ['C+','B-','B','B+','A-','A'][hash%6],
|
|
last_updated: new Date().toISOString().split('T')[0],
|
|
sources: {
|
|
tink: { status: 'not_connected', note: 'Connect via /api/crm/tink/connect — TODO: TINK_CLIENT_ID' },
|
|
uc: { status: 'mock', credit_score: 55+(hash%40), risk_class: ['1','2','3','4','5'][hash%5], note: 'TODO: UC API key' },
|
|
scb: { revenue_trend: hash%2?'growing':'stable', employees_trend: hash%3?'growing':'stable', data_year: 2024 }
|
|
},
|
|
signals: {
|
|
positive: ['F-skatt registrerad','Momsregistrerad','Aktiv i SNI-register'],
|
|
negative: hash%3===0?['Betalningsanmärkning 2024']:[],
|
|
neutral: [`${company.employees} anställda`, company.industry_name]
|
|
},
|
|
recommendation: hash%2?'Lämplig för standardavtal':'Begär förskottsbetalning eller kreditförsäkring',
|
|
_note: 'PARTIAL — UC + Tink integration requires API keys'
|
|
}
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-018 — Problem Categories Grid (AI)
|
|
// ════════════════════════════════════════════════════════════════
|
|
const PROB_CACHE = new Map();
|
|
router.get('/problem-categories/:industry', async (req, res) => {
|
|
const { industry } = req.params;
|
|
const { size = 'sme' } = req.query;
|
|
const cacheKey = `prob_${industry}_${size}`;
|
|
const cached = PROB_CACHE.get(cacheKey);
|
|
if (cached && (Date.now()-cached.ts) < 7200000) return res.json(cached.data);
|
|
|
|
const aiPrompt = `Top 8 business pain points for ${size} companies in ${industry} industry Sweden 2026. JSON: {"problems":[{"category":"...","problem":"...","impact":"high|medium|low","aamos_module":"...","urgency":"immediate|short-term|medium-term"}]}. Only JSON.`;
|
|
let result = null;
|
|
try {
|
|
const aiResponse = await callAI(aiPrompt, getToken(req));
|
|
const match = aiResponse.match(/\{[\s\S]*\}/);
|
|
if (match) result = JSON.parse(match[0]);
|
|
} catch { /* fallback */ }
|
|
if (!result) {
|
|
result = { problems: [
|
|
{ category: 'Säljprocess', problem: 'Ineffektiv CRM-hantering', impact: 'high', aamos_module: 'CRM', urgency: 'immediate' },
|
|
{ category: 'Ekonomi', problem: 'Manuell fakturahantering', impact: 'high', aamos_module: 'Ekonomi', urgency: 'immediate' },
|
|
{ category: 'HR', problem: 'Tidskrävande personaladmin', impact: 'medium', aamos_module: 'HR', urgency: 'short-term' },
|
|
{ category: 'Compliance', problem: 'GDPR-dokumentation', impact: 'high', aamos_module: 'Compliance', urgency: 'immediate' },
|
|
{ category: 'Analytics', problem: 'Brist på realtidsdata', impact: 'medium', aamos_module: 'Analytics', urgency: 'medium-term' },
|
|
]};
|
|
}
|
|
const data = { ok: true, industry, size, ...result, generated_at: new Date().toISOString() };
|
|
PROB_CACHE.set(cacheKey, { data, ts: Date.now() });
|
|
res.json(data);
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-019 — HighEndLeads filter+scoring
|
|
// ════════════════════════════════════════════════════════════════
|
|
function calcHighEndScore(lead, opts = {}) {
|
|
let score = lead.score || 50;
|
|
const { revenue_msek = 0, growth_pct = 0, tech_stack = [] } = opts;
|
|
if (revenue_msek > 100) score += 20;
|
|
else if (revenue_msek > 50) score += 12;
|
|
else if (revenue_msek > 20) score += 6;
|
|
if (growth_pct > 20) score += 15;
|
|
else if (growth_pct > 10) score += 8;
|
|
else if (growth_pct > 0) score += 3;
|
|
const modernStack = ['cloud','api','saas','kubernetes','react','node','python'];
|
|
score += tech_stack.filter(t => modernStack.some(m => t.toLowerCase().includes(m))).length * 5;
|
|
return Math.min(100, score);
|
|
}
|
|
router.get('/high-end-leads', async (req, res) => {
|
|
const { min_score = 60, limit = 25 } = req.query;
|
|
try {
|
|
const { rows } = await pool.query(`SELECT * FROM prexo_leads WHERE score >= $1 ORDER BY score DESC LIMIT $2`, [parseInt(min_score), parseInt(limit)]);
|
|
const enriched = rows.map(lead => {
|
|
const hes = calcHighEndScore(lead);
|
|
return { ...lead, high_end_score: hes, tier: hes>=85?'platinum':hes>=75?'gold':hes>=65?'silver':'standard', recommended_package: hes>=85?'Enterprise':hes>=75?'Scale':'Growth', ltv_estimate_sek: Math.round(hes*3500) };
|
|
}).sort((a,b) => b.high_end_score-a.high_end_score);
|
|
res.json({ ok: true, filters: { min_score: parseInt(min_score) }, count: enriched.length, leads: enriched, summary: { platinum: enriched.filter(l=>l.tier==='platinum').length, gold: enriched.filter(l=>l.tier==='gold').length, total_ltv: enriched.reduce((a,l)=>a+l.ltv_estimate_sek,0) } });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
router.post('/high-end-leads/score', async (req, res) => {
|
|
const { lead_id, revenue_msek = 0, growth_pct = 0, tech_stack = [] } = req.body;
|
|
if (!lead_id) return res.status(400).json({ error: 'lead_id required' });
|
|
try {
|
|
const { rows } = await pool.query(`SELECT * FROM prexo_leads WHERE id = $1`, [lead_id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Lead not found' });
|
|
const lead = rows[0];
|
|
const hes = calcHighEndScore(lead, { revenue_msek, growth_pct, tech_stack });
|
|
await pool.query(`UPDATE prexo_leads SET score=$1 WHERE id=$2`, [hes, lead_id]);
|
|
res.json({ ok: true, lead_id, company: lead.company, scores: { original: lead.score, high_end: hes }, tier: hes>=85?'platinum':hes>=75?'gold':hes>=65?'silver':'standard', ltv_estimate_sek: Math.round(hes*3500) });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// C-020 — Partner Dashboard
|
|
// ════════════════════════════════════════════════════════════════
|
|
const PARTNERS = new Map([
|
|
['p001', { id: 'p001', name: 'Nord Consulting', tier: 'gold', provision_pct: 20, leads: 12, mrr_sek: 89400, ytd_commission: 213200 }],
|
|
['p002', { id: 'p002', name: 'Digit AB', tier: 'silver', provision_pct: 15, leads: 7, mrr_sek: 42000, ytd_commission: 71400 }],
|
|
['p003', { id: 'p003', name: 'BizPartners', tier: 'bronze', provision_pct: 10, leads: 3, mrr_sek: 14900, ytd_commission: 17880 }],
|
|
]);
|
|
router.get('/partner/dashboard/:partnerId', async (req, res) => {
|
|
const { partnerId } = req.params;
|
|
const partner = PARTNERS.get(partnerId) || { id: partnerId, name: `Partner ${partnerId}`, tier: 'bronze', provision_pct: 10, leads: 0, mrr_sek: 0, ytd_commission: 0 };
|
|
let pipeline = [];
|
|
try { const { rows } = await pool.query(`SELECT stage, COUNT(*) as count FROM prexo_leads WHERE assignee ILIKE $1 GROUP BY stage`, [`%${partnerId}%`]); pipeline = rows; } catch { /* ignore */ }
|
|
const commission = Math.round(partner.mrr_sek * (partner.provision_pct/100));
|
|
res.json({
|
|
ok: true, partner,
|
|
dashboard: {
|
|
pipeline, revenue: { mrr_sek: partner.mrr_sek, monthly_commission: commission, ytd_commission: partner.ytd_commission },
|
|
performance: { leads_total: partner.leads, conversion_rate: partner.leads > 0 ? `${Math.round((partner.mrr_sek/partner.leads/7490)*100)}%` : '0%', avg_deal_sek: partner.leads > 0 ? Math.round(partner.mrr_sek/partner.leads) : 0, rank: 1+[...PARTNERS.values()].filter(p=>p.mrr_sek>partner.mrr_sek).length },
|
|
targets: { monthly_mrr_target: Math.round(partner.mrr_sek*1.15), to_next_tier: partner.tier==='bronze'?'Add 50K MRR for Silver':partner.tier==='silver'?'Add 150K MRR for Gold':'Top tier!' },
|
|
next_payout: new Date(new Date().getFullYear(), new Date().getMonth()+1, 15).toISOString().split('T')[0]
|
|
}
|
|
});
|
|
});
|
|
router.post('/partner/register', async (req, res) => {
|
|
const { name, email, company, tier = 'bronze' } = req.body;
|
|
if (!name || !email) return res.status(400).json({ error: 'name and email required' });
|
|
const p = { id: randomUUID(), name, email, company, tier, provision_pct: tier==='gold'?20:tier==='silver'?15:10, leads: 0, mrr_sek: 0, ytd_commission: 0, registered_at: new Date().toISOString() };
|
|
PARTNERS.set(p.id, p);
|
|
try { await pool.query(`INSERT INTO prexo_leads (id,name,email,company,source,score,stage) VALUES ($1,$2,$3,$4,'partner',50,'partner')`, [p.id, name, email, company||name]); } catch { /* ignore */ }
|
|
res.status(201).json({ ok: true, partner: p });
|
|
});
|
|
router.get('/partner/leaderboard', (req, res) => {
|
|
const leaderboard = [...PARTNERS.values()].sort((a,b)=>b.mrr_sek-a.mrr_sek).map((p,i)=>({ rank: i+1, name: p.name, tier: p.tier, mrr_sek: p.mrr_sek, ytd_commission: p.ytd_commission }));
|
|
res.json({ ok: true, leaderboard });
|
|
});
|
|
|
|
// ── Health check for epic ────────────────────────────────────────
|
|
router.get('/epic/health', (req, res) => {
|
|
res.json({ ok: true, service: 'crm-epic', version: '1.0.0', tickets: 20, endpoints: 35, deployed_at: new Date().toISOString() });
|
|
});
|
|
|
|
export default router;
|