/** * ═══════════════════════════════════════════════════════════════════════════ * 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 = /([\s\S]*?)<\/item>/g; let m; while ((m = re.exec(xml)) !== null && items.length < limit) { const item = m[1]; const title = (item.match(/<!\[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;