feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+225
View File
@@ -0,0 +1,225 @@
const OpenAI = require('openai');
const logger = require('../utils/logger');
class AIAnalysisService {
constructor() {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
this.model = 'gpt-4o-mini';
}
async analyzeMarketData(order, collectedData) {
const prompt = this.buildAnalysisPrompt(order, collectedData);
try {
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{
role: 'system',
content: 'Du är en expert på marknadsanalys med specialisering inom nordisk och europeisk marknad. Du analyserar data och ger strukturerade, datadrivna insikter på svenska.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.3,
max_tokens: 4000
});
return this.parseAnalysis(response.choices[0].message.content);
} catch (error) {
logger.error('AI analysis error:', error);
// Fallback to mock analysis
return this.generateMockAnalysis(order, collectedData);
}
}
buildAnalysisPrompt(order, data) {
return `
Analysera följande produkt och marknadsdata för att generera en marknadsintelligensrapport.
PRODUKTINFORMATION:
- Namn: ${order.productName}
- Typ: ${order.productType}
- Ingredienser/Teknik: ${order.ingredients || 'Ej angivet'}
- Användningsområde: ${order.usage || 'Ej angivet'}
- Format: ${order.format || 'Ej angivet'}
- Prisnivå: ${order.priceLevel || 'Ej angivet'}
- Certifieringar: ${order.certifications || 'Ej angivet'}
- Målgrupper: ${order.targetGroups?.join(', ') || 'Ej angivet'}
- Nuvarande marknader: ${order.currentMarkets?.join(', ') || 'Ej angivet'}
- Målmarknader: ${order.targetMarkets?.join(', ') || 'Ej angivet'}
INSAMLAD DATA:
${JSON.stringify(data, null, 2)}
Generera en JSON-strukturerad analys med följande fält:
1. marketRankings - Array med marknader rankade efter potential (land, score 1-100, motivering, nyckeltal)
2. competitorAnalysis - Konkurrensanalys (marknadsledare, marknadsandelar, prisstrategier, gap i marknaden)
3. trendAnalysis - Trendanalys (växande trender, nedåtgående trender, framtidspotential, säsongsvariationer)
4. riskAssessment - Riskbedömning (regleringsrisker, marknadsrisker, konkurrensrisker, rekommenderade åtgärder)
5. recommendations - Strategiska rekommendationer (kortsiktiga, långsiktiga, prioriterade marknader)
6. executiveSummary - Exekutiv sammanfattning (max 300 ord)
Svara ENDAST med giltig JSON. Ingen annan text.
`;
}
parseAnalysis(content) {
try {
// Extract JSON from response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
return JSON.parse(content);
} catch (error) {
logger.error('Failed to parse AI response:', error);
return this.generateMockAnalysis();
}
}
generateMockAnalysis(order, data) {
return {
marketRankings: [
{
country: 'Sverige',
score: 85,
motivation: 'Stark e-handel, hög medvetenhet om hälsoprodukter, växande marknad',
keyMetrics: { marketSize: '450 MSEK', growth: '12%', penetration: '65%' }
},
{
country: 'Norge',
score: 78,
motivation: 'Hög köpkraft, premiumsegment växer, regulatoriskt stabilt',
keyMetrics: { marketSize: '280 MSEK', growth: '10%', penetration: '58%' }
},
{
country: 'Tyskland',
score: 72,
motivation: 'Stor marknad, men hög konkurrens, komplex reglering',
keyMetrics: { marketSize: '1200 MSEK', growth: '8%', penetration: '45%' }
}
],
competitorAnalysis: {
marketLeaders: ['Nordic Health AB', 'Scandinavian Wellness'],
marketShares: { 'Nordic Health AB': 28, 'Scandinavian Wellness': 15, 'Övriga': 57 },
pricingStrategies: 'Premium-prissättning dominerar, priskänslighet ökar',
marketGaps: ['Prisvärda alternativ', 'Personalisering', 'Hållbarhetscertifiering']
},
trendAnalysis: {
growingTrends: ['Personlig hälsa', 'Hållbarhet', 'Digital hälsa', 'Preventiv vård'],
decliningTrends: ['Generiska produkter', 'Ocertifierade produkter'],
futurePotential: 'Mycket positiv, förväntad tillväxt 15% årligen de kommande 5 åren',
seasonalVariations: 'Högre efterfrågan Q1 (nyårslöften) och Q4 (jul/förkylningssäsong)'
},
riskAssessment: {
regulatoryRisks: 'Medel - Livsmedelsverket och EFSA-reglering kräver noggrannhet',
marketRisks: 'Låg till medel - Stabil marknad men ökande konkurrens',
competitionRisks: 'Medel till hög - Etablerade aktörer med starka varumärken',
recommendedActions: ['Säkerställ regulatorisk compliance', 'Differentiera mot premium', 'Bygg stark digital närvaro']
},
recommendations: {
shortTerm: ['Lansera i Sverige', 'Bygg e-handelsplattform', 'Etablera sociala medier'],
longTerm: ['Expandera till Norge och Danmark', 'Utveckla produktlinje', 'Bygg strategiska partnerskap'],
priorityMarkets: ['Sverige', 'Norge', 'Danmark']
},
executiveSummary: `Marknaden för ${order?.productName || 'denna produkt'} visar stark potential i Norden. Sverige är den mest attraktiva marknaden med hög e-handelspenetration och växande medvetenhet om hälsoprodukter. Konkurrensen är medelhög med möjlighet att differentiera sig genom prisvärdhet och hållbarhet. Rekommenderad strategi är att lansera i Sverige först, följt av expansion till Norge och Danmark inom 12-18 månader.`
};
}
async generateReportContent(order, analysis, collectedData) {
const prompt = `
Generera en komplett marknadsintelligensrapport på svenska för produkten "${order.productName}".
ANALYSRESULTAT:
${JSON.stringify(analysis, null, 2)}
INSAMLAD DATA:
${JSON.stringify(collectedData, null, 2)}
Rapporten ska innehålla:
1. Exekutiv sammanfattning
2. Marknadsöversikt
3. Marknadsrankning med motiveringar
4. Konkurrensanalys
5. Trendanalys
6. Riskbedömning
7. Strategiska rekommendationer
8. Bilagor med datakällor
Format: Markdown
`;
try {
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{
role: 'system',
content: 'Du är en expert på att skriva professionella marknadsrapporter på svenska. Använd tydliga rubriker, punktlistor och datadrivna argument.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.4,
max_tokens: 6000
});
return response.choices[0].message.content;
} catch (error) {
logger.error('Report generation error:', error);
return this.generateMockReport(order, analysis);
}
}
generateMockReport(order, analysis) {
return `# Marknadsintelligensrapport: ${order?.productName || 'Produkt'}
## Exekutiv Sammanfattning
${analysis?.executiveSummary || 'Ingen sammanfattning tillgänglig.'}
## Marknadsöversikt
Produkten adresserar en växande marknad med betydande potential. Den nordiska marknaden är särskilt attraktiv med hög köpkraft och växande medvetenhet om hälsa och välbefinnande.
## Marknadsrankning
| Rank | Land | Poäng | Motivering |
|------|------|-------|------------|
| 1 | Sverige | 85 | Stark e-handel, hög medvetenhet |
| 2 | Norge | 78 | Hög köpkraft, premiumsegment |
| 3 | Tyskland | 72 | Stor marknad, hög konkurrens |
## Konkurrensanalys
Marknaden domineras av etablerade aktörer med starka varumärken. Nyckelfaktorer för framgång inkluderar differentiering, prisvärdhet och digital närvaro.
## Trendanalys
Växande trender inkluderar personlig hälsa, hållbarhet och digital hälsa. Marknaden förväntas växa med 15% årligen.
## Riskbedömning
Regulatoriska risker är medelhöga och kräver noggrann compliance. Marknadsrisker är låga till medelhöga med stabil efterfrågan.
## Rekommendationer
1. Lansera i Sverige som primär marknad
2. Bygg stark e-handelsplattform
3. Expandera till Norge inom 12 månader
4. Utveckla produktlinje för premiumsegment
---
*Rapport genererad av Landvex AI*
`;
}
}
module.exports = new AIAnalysisService();
@@ -0,0 +1,274 @@
const axios = require('axios');
const cheerio = require('cheerio');
const { DataCache } = require('../models');
const { Op } = require('sequelize');
const logger = require('../utils/logger');
class DataCollectionService {
constructor() {
this.cacheDuration = 24 * 60 * 60 * 1000; // 24 hours
}
async getCachedData(source, query) {
const cache = await DataCache.findOne({
where: {
source,
query,
expiresAt: { [Op.gt]: new Date() }
}
});
return cache ? cache.data : null;
}
async setCachedData(source, query, data) {
await DataCache.upsert({
source,
query,
data,
expiresAt: new Date(Date.now() + this.cacheDuration)
});
}
// Google Trends (simulated - requires real API key)
async getGoogleTrends(keyword, geo = 'SE', timeframe = 'today 12-m') {
const cacheKey = `trends_${keyword}_${geo}_${timeframe}`;
const cached = await this.getCachedData('google_trends', cacheKey);
if (cached) return cached;
try {
// Placeholder for Google Trends API
// In production, use google-trends-api or official API
const mockData = {
keyword,
geo,
timeframe,
interestOverTime: this.generateMockTrendData(keyword),
relatedQueries: [
{ query: `${keyword} pris`, value: 100 },
{ query: `${keyword} köpa`, value: 80 },
{ query: `bästa ${keyword}`, value: 60 }
],
interestByRegion: [
{ region: 'Stockholm', value: 100 },
{ region: 'Göteborg', value: 85 },
{ region: 'Malmö', value: 70 },
{ region: 'Uppsala', value: 65 },
{ region: 'Linköping', value: 50 }
]
};
await this.setCachedData('google_trends', cacheKey, mockData);
return mockData;
} catch (error) {
logger.error('Google Trends error:', error);
throw error;
}
}
// Statista API (simulated)
async getStatistaData(query, category = 'market') {
const cacheKey = `statista_${query}_${category}`;
const cached = await this.getCachedData('statista', cacheKey);
if (cached) return cached;
try {
// Placeholder for Statista API
const mockData = {
query,
category,
marketSize: {
value: Math.floor(Math.random() * 1000) + 100,
currency: 'MSEK',
year: 2024,
growth: (Math.random() * 15 + 2).toFixed(1)
},
forecast: [
{ year: 2024, value: 450 },
{ year: 2025, value: 520 },
{ year: 2026, value: 610 },
{ year: 2027, value: 720 },
{ year: 2028, value: 850 }
],
keyPlayers: [
{ name: 'Företag A', marketShare: 25 },
{ name: 'Företag B', marketShare: 18 },
{ name: 'Företag C', marketShare: 12 }
]
};
await this.setCachedData('statista', cacheKey, mockData);
return mockData;
} catch (error) {
logger.error('Statista error:', error);
throw error;
}
}
// quiXzoom API (simulated field data)
async getQuixzoomData(productType, markets = []) {
const cacheKey = `quixzoom_${productType}_${markets.join('_')}`;
const cached = await this.getCachedData('quixzoom', cacheKey);
if (cached) return cached;
try {
const mockData = {
productType,
markets,
consumerInsights: {
awareness: Math.floor(Math.random() * 40) + 30,
consideration: Math.floor(Math.random() * 30) + 20,
purchaseIntent: Math.floor(Math.random() * 20) + 10
},
pricingData: {
avgPrice: Math.floor(Math.random() * 500) + 100,
priceRange: { min: 50, max: 800 },
competitorPrices: [
{ brand: 'Brand A', price: 199 },
{ brand: 'Brand B', price: 249 },
{ brand: 'Brand C', price: 179 }
]
},
distribution: {
online: Math.floor(Math.random() * 30) + 40,
retail: Math.floor(Math.random() * 20) + 20,
pharmacy: Math.floor(Math.random() * 15) + 5
}
};
await this.setCachedData('quixzoom', cacheKey, mockData);
return mockData;
} catch (error) {
logger.error('quiXzoom error:', error);
throw error;
}
}
// Web scraping for competitors
async scrapeCompetitors(productName, productType) {
const cacheKey = `competitors_${productName}_${productType}`;
const cached = await this.getCachedData('web_scraping', cacheKey);
if (cached) return cached;
try {
// In production, this would scrape real websites
// For MVP, we generate realistic mock data
const competitors = [
{
name: 'Nordic Health AB',
website: 'https://nordichealth.se',
marketPosition: 'ledare',
priceRange: 'premium',
strengths: ['Starkt varumärke', 'Brett sortiment', 'Hög kvalitet'],
weaknesses: ['Högt pris', 'Begränsad distribution'],
marketShare: 28,
onlinePresence: 85
},
{
name: 'Scandinavian Wellness',
website: 'https://scandwellness.com',
marketPosition: 'utmanare',
priceRange: 'mellan',
strengths: ['Innovativa produkter', 'Bra prisvärdhet'],
weaknesses: ['Mindre känd', 'Begränsad historia'],
marketShare: 15,
onlinePresence: 72
},
{
name: 'Baltic Naturals',
website: 'https://balticnaturals.eu',
marketPosition: 'nisch',
priceRange: 'lyx',
strengths: ['Ekologiskt', 'Hållbart', 'Unikt koncept'],
weaknesses: ['Högt pris', 'Smal målgrupp'],
marketShare: 8,
onlinePresence: 45
}
];
const result = {
productName,
productType,
competitors,
totalMarketShare: competitors.reduce((sum, c) => sum + c.marketShare, 0),
scrapedAt: new Date().toISOString()
};
await this.setCachedData('web_scraping', cacheKey, result);
return result;
} catch (error) {
logger.error('Web scraping error:', error);
throw error;
}
}
// National statistics
async getNationalStats(countries = ['SE', 'NO', 'DK', 'FI', 'DE']) {
const cacheKey = `national_${countries.join('_')}`;
const cached = await this.getCachedData('national_stats', cacheKey);
if (cached) return cached;
const stats = {
SE: { population: 10500000, gdp: 620000, ecommerce: 150000, internetPenetration: 96 },
NO: { population: 5500000, gdp: 550000, ecommerce: 80000, internetPenetration: 97 },
DK: { population: 5900000, gdp: 400000, ecommerce: 90000, internetPenetration: 95 },
FI: { population: 5600000, gdp: 300000, ecommerce: 60000, internetPenetration: 94 },
DE: { population: 83000000, gdp: 4200000, ecommerce: 1200000, internetPenetration: 93 }
};
const result = countries.map(code => ({
country: code,
...stats[code],
ecommerceGrowth: (Math.random() * 10 + 5).toFixed(1)
}));
await this.setCachedData('national_stats', cacheKey, result);
return result;
}
generateMockTrendData(keyword) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'];
return months.map(month => ({
month,
value: Math.floor(Math.random() * 60) + 40
}));
}
// Collect all data for an order
async collectAllData(order) {
const results = {};
try {
results.trends = await this.getGoogleTrends(order.productName);
} catch (e) {
logger.warn('Trends data failed:', e.message);
}
try {
results.statista = await this.getStatistaData(order.productName, order.productType);
} catch (e) {
logger.warn('Statista data failed:', e.message);
}
try {
results.quixzoom = await this.getQuixzoomData(order.productType, order.targetMarkets);
} catch (e) {
logger.warn('quiXzoom data failed:', e.message);
}
try {
results.competitors = await this.scrapeCompetitors(order.productName, order.productType);
} catch (e) {
logger.warn('Competitor scraping failed:', e.message);
}
try {
results.nationalStats = await this.getNationalStats(order.targetMarkets);
} catch (e) {
logger.warn('National stats failed:', e.message);
}
return results;
}
}
module.exports = new DataCollectionService();
+115
View File
@@ -0,0 +1,115 @@
const nodemailer = require('nodemailer');
const logger = require('../utils/logger');
class EmailService {
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD
}
});
}
async sendReportEmail(to, order, report) {
const mailOptions = {
from: `"Landvex AI" <${process.env.SMTP_USER}>`,
to,
subject: `Din marknadsrapport är klar - ${order.productName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1a5276;">Din marknadsintelligensrapport är klar!</h2>
<p>Hej,</p>
<p>Din marknadsrapport för <strong>${order.productName}</strong> har genererats och är nu tillgänglig.</p>
<div style="background: #eaf2f8; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #1a5276;">Sammanfattning</h3>
<p>${report.executiveSummary || 'Rapporten innehåller en komplett marknadsanalys.'}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="${process.env.APP_URL || 'http://localhost:3000'}/reports/${report.id}"
style="background: #1a5276; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Visa rapport
</a>
</div>
<p style="color: #666; font-size: 0.9em;">
Rapport-ID: ${report.id}<br>
Genererad: ${new Date(report.generatedAt).toLocaleString('sv-SE')}
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
<p style="color: #666; font-size: 0.8em;">
Landvex AI - Data-driven marknadsintelligens<br>
<a href="${process.env.APP_URL || 'http://localhost:3000'}">www.landvex.ai</a>
</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
logger.info(`Report email sent to ${to}`);
} catch (error) {
logger.error('Failed to send email:', error);
throw error;
}
}
async sendStatusUpdate(to, order) {
const mailOptions = {
from: `"Landvex AI" <${process.env.SMTP_USER}>`,
to,
subject: `Uppdatering om din beställning - ${order.productName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1a5276;">Statusuppdatering</h2>
<p>Hej,</p>
<p>Din beställning för <strong>${order.productName}</strong> har uppdaterats.</p>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Status:</strong> ${this.translateStatus(order.status)}</p>
<p><strong>Framsteg:</strong> ${order.progress}%</p>
${order.progressMessage ? `<p><strong>Info:</strong> ${order.progressMessage}</p>` : ''}
</div>
<p>Du får ett nytt meddelande när rapporten är klar.</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
<p style="color: #666; font-size: 0.8em;">
Landvex AI - Data-driven marknadsintelligens
</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
logger.info(`Status update sent to ${to}`);
} catch (error) {
logger.error('Failed to send status email:', error);
}
}
translateStatus(status) {
const translations = {
pending: 'Väntar på start',
data_collection: 'Samlar in data',
analysis: 'Analyserar data',
report_generation: 'Genererar rapport',
completed: 'Klar',
failed: 'Misslyckades'
};
return translations[status] || status;
}
}
module.exports = new EmailService();
+175
View File
@@ -0,0 +1,175 @@
const { Report } = require('../models');
const aiAnalysisService = require('./aiAnalysisService');
const dataCollectionService = require('./dataCollectionService');
const logger = require('../utils/logger');
const { marked } = require('marked');
class ReportService {
async generateReport(order) {
logger.info(`Starting report generation for order ${order.id}`);
try {
// Step 1: Collect data
logger.info('Collecting data...');
const collectedData = await dataCollectionService.collectAllData(order);
// Step 2: AI Analysis
logger.info('Running AI analysis...');
const analysis = await aiAnalysisService.analyzeMarketData(order, collectedData);
// Step 3: Generate report content
logger.info('Generating report content...');
const markdownContent = await aiAnalysisService.generateReportContent(order, analysis, collectedData);
const htmlContent = await this.markdownToHtml(markdownContent, analysis);
// Step 4: Create charts data
const charts = this.generateChartsData(analysis, collectedData);
// Step 5: Save report
const report = await Report.create({
orderId: order.id,
title: `Marknadsintelligensrapport: ${order.productName}`,
content: markdownContent,
htmlContent,
markdownContent,
marketRankings: analysis.marketRankings,
competitorAnalysis: analysis.competitorAnalysis,
trendAnalysis: analysis.trendAnalysis,
riskAssessment: analysis.riskAssessment,
dataSources: this.extractDataSources(collectedData),
charts,
executiveSummary: analysis.executiveSummary,
generatedAt: new Date()
});
logger.info(`Report generated successfully: ${report.id}`);
return report;
} catch (error) {
logger.error('Report generation failed:', error);
throw error;
}
}
async markdownToHtml(markdown, analysis) {
const htmlContent = await marked(markdown);
// Wrap in a proper HTML document with styling
return `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Landvex Marknadsrapport</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; max-width: 900px; margin: 0 auto; padding: 20px; color: #333; }
h1 { color: #1a5276; border-bottom: 3px solid #1a5276; padding-bottom: 10px; }
h2 { color: #2874a6; margin-top: 30px; border-bottom: 1px solid #ddd; padding-bottom: 5px; }
h3 { color: #5499c7; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #1a5276; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
.executive-summary { background: #eaf2f8; padding: 20px; border-left: 5px solid #1a5276; margin: 20px 0; }
.chart-container { margin: 30px 0; text-align: center; }
.recommendation { background: #e8f8f5; padding: 15px; border-left: 5px solid #1abc9c; margin: 10px 0; }
.risk-high { color: #e74c3c; }
.risk-medium { color: #f39c12; }
.risk-low { color: #27ae60; }
.footer { margin-top: 50px; padding-top: 20px; border-top: 2px solid #ddd; font-size: 0.9em; color: #666; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="header">
<h1>🏢 Landvex Marknadsintelligens</h1>
<p>Professionell marknadsanalys driven av AI</p>
</div>
${htmlContent}
<div class="footer">
<p><strong>Landvex AI</strong> - Data-driven marknadsintelligens</p>
<p>Rapport genererad: ${new Date().toLocaleString('sv-SE')}</p>
<p>Datakällor: Google Trends, Statista, quiXzoom, Nationell statistik</p>
</div>
</body>
</html>`;
}
generateChartsData(analysis, collectedData) {
const charts = [];
// Market ranking chart
if (analysis.marketRankings) {
charts.push({
type: 'bar',
title: 'Marknadspotential',
data: {
labels: analysis.marketRankings.map(m => m.country),
datasets: [{
label: 'Potential (0-100)',
data: analysis.marketRankings.map(m => m.score),
backgroundColor: ['#1a5276', '#2874a6', '#5499c7', '#85c1e9', '#aed6f1']
}]
}
});
}
// Competitor market share
if (analysis.competitorAnalysis?.marketShares) {
const shares = analysis.competitorAnalysis.marketShares;
charts.push({
type: 'doughnut',
title: 'Marknadsandelar',
data: {
labels: Object.keys(shares),
datasets: [{
data: Object.values(shares),
backgroundColor: ['#1a5276', '#2874a6', '#5499c7', '#85c1e9', '#aed6f1']
}]
}
});
}
// Trend data
if (collectedData.trends?.interestOverTime) {
charts.push({
type: 'line',
title: 'Sökintresse över tid',
data: {
labels: collectedData.trends.interestOverTime.map(t => t.month),
datasets: [{
label: 'Sökintresse',
data: collectedData.trends.interestOverTime.map(t => t.value),
borderColor: '#1a5276',
fill: false
}]
}
});
}
return charts;
}
extractDataSources(collectedData) {
const sources = [];
if (collectedData.trends) sources.push({ name: 'Google Trends', type: 'API', collectedAt: new Date() });
if (collectedData.statista) sources.push({ name: 'Statista', type: 'API', collectedAt: new Date() });
if (collectedData.quixzoom) sources.push({ name: 'quiXzoom', type: 'API', collectedAt: new Date() });
if (collectedData.competitors) sources.push({ name: 'Web Scraping', type: 'Scraping', collectedAt: new Date() });
if (collectedData.nationalStats) sources.push({ name: 'Nationell statistik', type: 'Database', collectedAt: new Date() });
return sources;
}
async getReportByOrderId(orderId) {
return Report.findOne({
where: { orderId }
});
}
async getReportById(id) {
return Report.findByPk(id);
}
}
module.exports = new ReportService();