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
+76
View File
@@ -0,0 +1,76 @@
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const path = require('path');
require('dotenv').config();
const orderRoutes = require('./routes/orders');
const reportRoutes = require('./routes/reports');
const adminRoutes = require('./routes/admin');
const logger = require('./utils/logger');
const app = express();
// Middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
app.use(cors());
app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Static files
app.use(express.static(path.join(__dirname, 'public')));
// API Routes
app.use('/api/orders', orderRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/admin', adminRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve frontend
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
app.get('/status', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'status.html'));
});
app.get('/report-view', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'report-view.html'));
});
// Error handling
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
error: 'Ett oväntat fel inträffade',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// 404
app.use((req, res) => {
res.status(404).json({ error: 'Sidan hittades inte' });
});
module.exports = app;
+22
View File
@@ -0,0 +1,22 @@
const { Sequelize } = require('sequelize');
require('dotenv').config();
const sequelize = new Sequelize(
process.env.DB_NAME || 'landvex',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || 'password',
{
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? console.log : false,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000
}
}
);
module.exports = sequelize;
+113
View File
@@ -0,0 +1,113 @@
const { AdminUser } = require('../models');
const jwt = require('jsonwebtoken');
const logger = require('../utils/logger');
class AdminController {
async login(req, res) {
try {
const { username, password } = req.body;
const user = await AdminUser.findOne({ where: { username } });
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Ogiltiga inloggningsuppgifter' });
}
const isValid = await user.comparePassword(password);
if (!isValid) {
return res.status(401).json({ error: 'Ogiltiga inloggningsuppgifter' });
}
await user.update({ lastLogin: new Date() });
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
data: {
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
}
});
} catch (error) {
logger.error('Admin login failed:', error);
res.status(500).json({ error: 'Inloggning misslyckades' });
}
}
async createUser(req, res) {
try {
const { username, email, password, role } = req.body;
const user = await AdminUser.create({
username,
email,
password,
role: role || 'analyst'
});
res.status(201).json({
success: true,
data: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
logger.error('Create admin user failed:', error);
res.status(500).json({ error: 'Kunde inte skapa användare' });
}
}
async getStats(req, res) {
try {
const { Order, Report } = require('../models');
const totalOrders = await Order.count();
const completedOrders = await Order.count({ where: { status: 'completed' } });
const pendingOrders = await Order.count({ where: { status: 'pending' } });
const failedOrders = await Order.count({ where: { status: 'failed' } });
const totalReports = await Report.count();
// Recent activity
const recentOrders = await Order.findAll({
order: [['createdAt', 'DESC']],
limit: 5
});
res.json({
success: true,
data: {
orders: {
total: totalOrders,
completed: completedOrders,
pending: pendingOrders,
failed: failedOrders,
completionRate: totalOrders > 0 ? ((completedOrders / totalOrders) * 100).toFixed(1) : 0
},
reports: {
total: totalReports
},
recentOrders
}
});
} catch (error) {
logger.error('Get stats failed:', error);
res.status(500).json({ error: 'Kunde inte hämta statistik' });
}
}
}
module.exports = new AdminController();
+144
View File
@@ -0,0 +1,144 @@
const { Order } = require('../models');
const reportQueue = require('../queues/reportQueue');
const logger = require('../utils/logger');
class OrderController {
async create(req, res) {
try {
const order = await Order.create({
productName: req.body.productName,
productType: req.body.productType,
ingredients: req.body.ingredients,
usage: req.body.usage,
format: req.body.format,
priceLevel: req.body.priceLevel,
certifications: req.body.certifications,
targetGroups: req.body.targetGroups || [],
targetMarkets: req.body.targetMarkets || [],
currentMarkets: req.body.currentMarkets || [],
email: req.body.email,
priority: req.body.priority || 'normal',
estimatedCompletion: new Date(Date.now() + 72 * 60 * 60 * 1000) // 72 hours
});
// Add to queue for processing
await reportQueue.add({
orderId: order.id
}, {
delay: 1000, // Start after 1 second
priority: req.body.priority === 'urgent' ? 1 :
req.body.priority === 'high' ? 2 : 3
});
logger.info(`Order created: ${order.id}`);
res.status(201).json({
success: true,
data: {
id: order.id,
productName: order.productName,
status: order.status,
progress: order.progress,
estimatedCompletion: order.estimatedCompletion,
message: 'Din beställning har mottagits och bearbetas.'
}
});
} catch (error) {
logger.error('Order creation failed:', error);
res.status(500).json({
error: 'Kunde inte skapa beställning',
message: error.message
});
}
}
async getById(req, res) {
try {
const order = await Order.findByPk(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Beställning hittades inte' });
}
res.json({
success: true,
data: order
});
} catch (error) {
logger.error('Get order failed:', error);
res.status(500).json({ error: 'Kunde inte hämta beställning' });
}
}
async getByEmail(req, res) {
try {
const orders = await Order.findAll({
where: { email: req.query.email },
order: [['createdAt', 'DESC']]
});
res.json({
success: true,
data: orders
});
} catch (error) {
logger.error('Get orders by email failed:', error);
res.status(500).json({ error: 'Kunde inte hämta beställningar' });
}
}
async getAll(req, res) {
try {
const { status, page = 1, limit = 20 } = req.query;
const where = {};
if (status) where.status = status;
const orders = await Order.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
limit: parseInt(limit),
offset: (parseInt(page) - 1) * parseInt(limit)
});
res.json({
success: true,
data: orders.rows,
pagination: {
total: orders.count,
page: parseInt(page),
pages: Math.ceil(orders.count / parseInt(limit))
}
});
} catch (error) {
logger.error('Get all orders failed:', error);
res.status(500).json({ error: 'Kunde inte hämta beställningar' });
}
}
async updateStatus(req, res) {
try {
const order = await Order.findByPk(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Beställning hittades inte' });
}
await order.update({
status: req.body.status,
progress: req.body.progress,
progressMessage: req.body.progressMessage
});
res.json({
success: true,
data: order
});
} catch (error) {
logger.error('Update order status failed:', error);
res.status(500).json({ error: 'Kunde inte uppdatera status' });
}
}
}
module.exports = new OrderController();
+100
View File
@@ -0,0 +1,100 @@
const { Report, Order } = require('../models');
const logger = require('../utils/logger');
class ReportController {
async getById(req, res) {
try {
const report = await Report.findByPk(req.params.id, {
include: [{
model: Order,
as: 'order',
attributes: ['productName', 'productType', 'email', 'status']
}]
});
if (!report) {
return res.status(404).json({ error: 'Rapport hittades inte' });
}
res.json({
success: true,
data: report
});
} catch (error) {
logger.error('Get report failed:', error);
res.status(500).json({ error: 'Kunde inte hämta rapport' });
}
}
async getByOrderId(req, res) {
try {
const report = await Report.findOne({
where: { orderId: req.params.orderId },
include: [{
model: Order,
as: 'order'
}]
});
if (!report) {
return res.status(404).json({ error: 'Rapport hittades inte' });
}
res.json({
success: true,
data: report
});
} catch (error) {
logger.error('Get report by order failed:', error);
res.status(500).json({ error: 'Kunde inte hämta rapport' });
}
}
async getHtml(req, res) {
try {
const report = await Report.findByPk(req.params.id);
if (!report || !report.htmlContent) {
return res.status(404).json({ error: 'Rapport hittades inte' });
}
res.setHeader('Content-Type', 'text/html');
res.send(report.htmlContent);
} catch (error) {
logger.error('Get report HTML failed:', error);
res.status(500).json({ error: 'Kunde inte hämta rapport' });
}
}
async getAll(req, res) {
try {
const { page = 1, limit = 20 } = req.query;
const reports = await Report.findAndCountAll({
include: [{
model: Order,
as: 'order',
attributes: ['productName', 'email', 'status']
}],
order: [['createdAt', 'DESC']],
limit: parseInt(limit),
offset: (parseInt(page) - 1) * parseInt(limit)
});
res.json({
success: true,
data: reports.rows,
pagination: {
total: reports.count,
page: parseInt(page),
pages: Math.ceil(reports.count / parseInt(limit))
}
});
} catch (error) {
logger.error('Get all reports failed:', error);
res.status(500).json({ error: 'Kunde inte hämta rapporter' });
}
}
}
module.exports = new ReportController();
+78
View File
@@ -0,0 +1,78 @@
const reportQueue = require('../queues/reportQueue');
const { Order } = require('../models');
const reportService = require('../services/reportService');
const emailService = require('../services/emailService');
const logger = require('../utils/logger');
// Process report generation jobs
reportQueue.process(async (job) => {
const { orderId } = job.data;
logger.info(`Starting report job for order ${orderId}`);
try {
const order = await Order.findByPk(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
// Update status to data collection
await order.update({
status: 'data_collection',
progress: 10,
progressMessage: 'Samlar in marknadsdata...'
});
await emailService.sendStatusUpdate(order.email, order);
// Simulate progress updates
await job.progress(20);
await new Promise(resolve => setTimeout(resolve, 1000));
await order.update({
status: 'analysis',
progress: 40,
progressMessage: 'Analyserar marknadsdata med AI...'
});
await emailService.sendStatusUpdate(order.email, order);
await job.progress(50);
// Generate report
const report = await reportService.generateReport(order);
await order.update({
status: 'report_generation',
progress: 80,
progressMessage: 'Slutför rapportgenerering...'
});
await job.progress(80);
// Finalize
await order.update({
status: 'completed',
progress: 100,
progressMessage: 'Rapport klar!'
});
// Send completion email
await emailService.sendReportEmail(order.email, order, report);
logger.info(`Report job completed for order ${orderId}`);
return { reportId: report.id };
} catch (error) {
logger.error(`Report job failed for order ${orderId}:`, error);
const order = await Order.findByPk(orderId);
if (order) {
await order.update({
status: 'failed',
progress: 0,
progressMessage: `Fel: ${error.message}`
});
}
throw error;
}
});
module.exports = reportQueue;
+33
View File
@@ -0,0 +1,33 @@
const jwt = require('jsonwebtoken');
const { AdminUser } = require('../models');
const authMiddleware = async (req, res, next) => {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Ingen token tillhandahållen' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await AdminUser.findByPk(decoded.id);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Ogiltig token' });
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Ogiltig token' });
}
};
const adminOnly = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin-åtkomst krävs' });
}
next();
};
module.exports = { authMiddleware, adminOnly };
+40
View File
@@ -0,0 +1,40 @@
const { body, validationResult } = require('express-validator');
const orderValidation = [
body('productName')
.trim()
.isLength({ min: 2, max: 255 })
.withMessage('Produktnamn måste vara 2-255 tecken'),
body('productType')
.isIn(['kosttillskott', 'tech', 'fastighet', 'mode', 'livsmedel', 'skonhet', 'halsa', 'ovrigt'])
.withMessage('Ogiltig produkttyp'),
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Ogiltig e-postadress'),
body('targetGroups')
.optional()
.isArray()
.withMessage('Målgrupper måste vara en array'),
body('targetMarkets')
.optional()
.isArray()
.withMessage('Målmarknader måste vara en array'),
body('currentMarkets')
.optional()
.isArray()
.withMessage('Nuvarande marknader måste vara en array')
];
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Valideringsfel',
details: errors.array()
});
}
next();
};
module.exports = { orderValidation, validate };
+61
View File
@@ -0,0 +1,61 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const bcrypt = require('bcryptjs');
const AdminUser = sequelize.define('AdminUser', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
username: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.STRING(255),
allowNull: false
},
role: {
type: DataTypes.ENUM('admin', 'manager', 'analyst'),
defaultValue: 'analyst'
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
lastLogin: {
type: DataTypes.DATE,
allowNull: true
}
}, {
tableName: 'admin_users',
timestamps: true,
hooks: {
beforeCreate: async (user) => {
if (user.password) {
user.password = await bcrypt.hash(user.password, 12);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 12);
}
}
}
});
AdminUser.prototype.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = AdminUser;
+40
View File
@@ -0,0 +1,40 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const DataCache = sequelize.define('DataCache', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
source: {
type: DataTypes.ENUM('google_trends', 'statista', 'quixzoom', 'web_scraping', 'national_stats', 'ai_analysis'),
allowNull: false
},
query: {
type: DataTypes.STRING(500),
allowNull: false
},
data: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {}
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {}
}
}, {
tableName: 'data_caches',
timestamps: true,
indexes: [
{ fields: ['source', 'query'] },
{ fields: ['expiresAt'] }
]
});
module.exports = DataCache;
+98
View File
@@ -0,0 +1,98 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Order = sequelize.define('Order', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
productName: {
type: DataTypes.STRING(255),
allowNull: false
},
productType: {
type: DataTypes.ENUM('kosttillskott', 'tech', 'fastighet', 'mode', 'livsmedel', 'skonhet', 'halsa', 'ovrigt'),
allowNull: false
},
ingredients: {
type: DataTypes.TEXT,
allowNull: true
},
usage: {
type: DataTypes.TEXT,
allowNull: true
},
format: {
type: DataTypes.STRING(100),
allowNull: true
},
priceLevel: {
type: DataTypes.ENUM('budget', 'mellan', 'premium', 'lyx'),
allowNull: true
},
certifications: {
type: DataTypes.TEXT,
allowNull: true
},
targetMarkets: {
type: DataTypes.ARRAY(DataTypes.STRING),
allowNull: true,
defaultValue: []
},
currentMarkets: {
type: DataTypes.ARRAY(DataTypes.STRING),
allowNull: true,
defaultValue: []
},
targetGroups: {
type: DataTypes.ARRAY(DataTypes.STRING),
allowNull: true,
defaultValue: []
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
isEmail: true
}
},
status: {
type: DataTypes.ENUM('pending', 'data_collection', 'analysis', 'report_generation', 'completed', 'failed'),
defaultValue: 'pending'
},
progress: {
type: DataTypes.INTEGER,
defaultValue: 0,
validate: {
min: 0,
max: 100
}
},
progressMessage: {
type: DataTypes.STRING(500),
allowNull: true
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
defaultValue: 'normal'
},
estimatedCompletion: {
type: DataTypes.DATE,
allowNull: true
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {}
}
}, {
tableName: 'orders',
timestamps: true,
indexes: [
{ fields: ['status'] },
{ fields: ['email'] },
{ fields: ['createdAt'] }
]
});
module.exports = Order;
+75
View File
@@ -0,0 +1,75 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Report = sequelize.define('Report', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
orderId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'orders',
key: 'id'
}
},
title: {
type: DataTypes.STRING(500),
allowNull: false
},
content: {
type: DataTypes.TEXT,
allowNull: true
},
htmlContent: {
type: DataTypes.TEXT,
allowNull: true
},
markdownContent: {
type: DataTypes.TEXT,
allowNull: true
},
marketRankings: {
type: DataTypes.JSONB,
defaultValue: []
},
competitorAnalysis: {
type: DataTypes.JSONB,
defaultValue: {}
},
trendAnalysis: {
type: DataTypes.JSONB,
defaultValue: {}
},
riskAssessment: {
type: DataTypes.JSONB,
defaultValue: {}
},
dataSources: {
type: DataTypes.JSONB,
defaultValue: []
},
charts: {
type: DataTypes.JSONB,
defaultValue: []
},
executiveSummary: {
type: DataTypes.TEXT,
allowNull: true
},
fileUrl: {
type: DataTypes.STRING(1000),
allowNull: true
},
generatedAt: {
type: DataTypes.DATE,
allowNull: true
}
}, {
tableName: 'reports',
timestamps: true
});
module.exports = Report;
+19
View File
@@ -0,0 +1,19 @@
const sequelize = require('../config/database');
const Order = require('./Order');
const Report = require('./Report');
const DataCache = require('./DataCache');
const AdminUser = require('./AdminUser');
// Associations
Order.hasOne(Report, { foreignKey: 'orderId', as: 'report' });
Report.belongsTo(Order, { foreignKey: 'orderId', as: 'order' });
const models = {
sequelize,
Order,
Report,
DataCache,
AdminUser
};
module.exports = models;
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex Admin</h1>
</div>
</header>
<main class="container">
<section id="loginSection">
<h2>Admin-inloggning</h2>
<form id="loginForm">
<div class="form-group">
<label for="username">Användarnamn</label>
<input type="text" id="username" required>
</div>
<div class="form-group">
<label for="password">Lösenord</label>
<input type="password" id="password" required>
</div>
<button type="submit" class="btn btn-primary">Logga in</button>
</form>
</section>
<section id="dashboardSection" class="hidden">
<nav class="admin-nav">
<button onclick="showTab('stats')" class="btn btn-small">Statistik</button>
<button onclick="showTab('orders')" class="btn btn-small">Beställningar</button>
<button onclick="showTab('reports')" class="btn btn-small">Rapporter</button>
<button onclick="logout()" class="btn btn-small btn-danger">Logga ut</button>
</nav>
<div id="statsTab" class="tab-content">
<h3>Översikt</h3>
<div id="statsGrid" class="stats-grid"></div>
<h4>Senaste aktivitet</h4>
<div id="recentActivity"></div>
</div>
<div id="ordersTab" class="tab-content hidden">
<h3>Alla beställningar</h3>
<div id="ordersTable"></div>
</div>
<div id="reportsTab" class="tab-content hidden">
<h3>Alla rapporter</h3>
<div id="reportsTable"></div>
</div>
</section>
</main>
<script src="/js/admin.js"></script>
</body>
</html>
+389
View File
@@ -0,0 +1,389 @@
/* Landvex Styles */
:root {
--primary: #1a5276;
--primary-light: #2874a6;
--secondary: #1abc9c;
--danger: #e74c3c;
--warning: #f39c12;
--success: #27ae60;
--bg: #f8f9fa;
--card-bg: #ffffff;
--text: #2c3e50;
--text-light: #7f8c8d;
--border: #dee2e6;
--shadow: 0 2px 10px rgba(0,0,0,0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 40px 0;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
/* Hero */
.hero {
text-align: center;
padding: 40px 0;
}
.hero h2 {
color: var(--primary);
margin-bottom: 15px;
}
/* Forms */
.order-form {
background: var(--card-bg);
padding: 30px;
border-radius: 10px;
box-shadow: var(--shadow);
margin-bottom: 40px;
}
.form-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.form-section:last-child {
border-bottom: none;
}
.form-section h3 {
color: var(--primary);
margin-bottom: 20px;
font-size: 1.3em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 2px solid var(--border);
border-radius: 6px;
font-size: 1em;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--text-light);
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.checkbox-group label {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.checkbox-group input {
width: auto;
margin-right: 8px;
}
/* Buttons */
.btn {
display: inline-block;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-light);
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover {
background: #16a085;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-small {
padding: 8px 16px;
font-size: 0.9em;
}
.form-actions {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 30px;
}
/* Results */
.result {
background: var(--card-bg);
padding: 30px;
border-radius: 10px;
box-shadow: var(--shadow);
margin-bottom: 40px;
text-align: center;
}
.success-message {
color: var(--success);
}
.success-message h3 {
margin-bottom: 15px;
}
/* Progress */
.progress-bar {
width: 100%;
height: 20px;
background: var(--border);
border-radius: 10px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--secondary), var(--primary));
transition: width 0.5s ease;
border-radius: 10px;
}
/* Report */
.report-header {
text-align: center;
margin-bottom: 30px;
}
.executive-summary {
background: #eaf2f8;
padding: 20px;
border-left: 5px solid var(--primary);
margin: 20px 0;
border-radius: 0 8px 8px 0;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 30px 0;
}
.chart-box {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: var(--card-bg);
box-shadow: var(--shadow);
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--primary);
color: white;
}
tr:hover {
background: #f5f5f5;
}
/* Status badges */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 600;
}
.badge-pending { background: #fff3cd; color: #856404; }
.badge-processing { background: #cce5ff; color: #004085; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
/* Admin */
.admin-nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.stat-card {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
text-align: center;
}
.stat-card h4 {
color: var(--text-light);
font-size: 0.9em;
margin-bottom: 10px;
}
.stat-card .number {
font-size: 2em;
font-weight: bold;
color: var(--primary);
}
/* Utility */
.hidden {
display: none !important;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-light);
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
/* Footer */
.footer {
background: var(--text);
color: white;
text-align: center;
padding: 20px;
margin-top: 40px;
}
.footer a {
color: var(--secondary);
}
/* Print styles for reports */
@media print {
.header, .footer, .report-actions {
display: none;
}
.container {
max-width: 100%;
}
}
/* Responsive */
@media (max-width: 600px) {
.header h1 {
font-size: 1.8em;
}
.form-actions {
flex-direction: column;
}
.checkbox-group {
grid-template-columns: 1fr;
}
.charts-container {
grid-template-columns: 1fr;
}
}
+169
View File
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Landvex - Marknadsintelligens</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>AI-driven marknadsintelligens för din produkt</p>
</div>
</header>
<main class="container">
<section class="hero">
<h2>Få en professionell marknadsrapport inom 72 timmar</h2>
<p>Fyll i formuläret nedan för att få en komplett marknadsanalys med konkurrensanalys, trenddata och strategiska rekommendationer.</p>
</section>
<form id="orderForm" class="order-form">
<div class="form-section">
<h3>Produktinformation</h3>
<div class="form-group">
<label for="productName">Produktnamn *</label>
<input type="text" id="productName" name="productName" required placeholder="t.ex. Omega-3 Kapslar">
</div>
<div class="form-group">
<label for="productType">Produkttyp *</label>
<select id="productType" name="productType" required>
<option value="">Välj produkttyp</option>
<option value="kosttillskott">Kosttillskott</option>
<option value="tech">Teknik/Elektronik</option>
<option value="fastighet">Fastighet</option>
<option value="mode">Mode/Kläder</option>
<option value="livsmedel">Livsmedel</option>
<option value="skonhet">Skönhet</option>
<option value="halsa">Hälsa/Vård</option>
<option value="ovrigt">Övrigt</option>
</select>
</div>
<div class="form-group">
<label for="ingredients">Ingredienser/Teknik</label>
<textarea id="ingredients" name="ingredients" rows="3" placeholder="t.ex. Fiskolja, Vitamin D, Vitamin E"></textarea>
</div>
<div class="form-group">
<label for="usage">Användningsområde</label>
<textarea id="usage" name="usage" rows="3" placeholder="t.ex. Dagligt kosttillskott för hjärthälsa"></textarea>
</div>
<div class="form-group">
<label for="format">Format/Storlek</label>
<input type="text" id="format" name="format" placeholder="t.ex. 60 kapslar, 500ml">
</div>
<div class="form-group">
<label for="priceLevel">Prisnivå</label>
<select id="priceLevel" name="priceLevel">
<option value="">Välj prisnivå</option>
<option value="budget">Budget</option>
<option value="mellan">Mellan</option>
<option value="premium">Premium</option>
<option value="lyx">Lyx</option>
</select>
</div>
<div class="form-group">
<label for="certifications">Certifieringar</label>
<input type="text" id="certifications" name="certifications" placeholder="t.ex. KRAV, EU-ekologiskt, GMP">
</div>
</div>
<div class="form-section">
<h3>Målgrupp & Marknader</h3>
<div class="form-group">
<label>Målgrupp</label>
<div class="checkbox-group">
<label><input type="checkbox" name="targetGroups" value="ungdomar"> Ungdomar (13-25)</label>
<label><input type="checkbox" name="targetGroups" value="vuxna"> Vuxna (25-45)</label>
<label><input type="checkbox" name="targetGroups" value="medelalder"> Medelålder (45-65)</label>
<label><input type="checkbox" name="targetGroups" value="aldre"> Äldre (65+)</label>
<label><input type="checkbox" name="targetGroups" value="halsomedvetna"> Hälso-medvetna</label>
<label><input type="checkbox" name="targetGroups" value="aktiva"> Aktiva/Tränande</label>
<label><input type="checkbox" name="targetGroups" value="familjer"> Familjer</label>
<label><input type="checkbox" name="targetGroups" value="premium"> Premium-konsumenter</label>
</div>
</div>
<div class="form-group">
<label>Nuvarande marknader</label>
<div class="checkbox-group">
<label><input type="checkbox" name="currentMarkets" value="SE"> Sverige</label>
<label><input type="checkbox" name="currentMarkets" value="NO"> Norge</label>
<label><input type="checkbox" name="currentMarkets" value="DK"> Danmark</label>
<label><input type="checkbox" name="currentMarkets" value="FI"> Finland</label>
<label><input type="checkbox" name="currentMarkets" value="DE"> Tyskland</label>
<label><input type="checkbox" name="currentMarkets" value="UK"> Storbritannien</label>
<label><input type="checkbox" name="currentMarkets" value="US"> USA</label>
</div>
</div>
<div class="form-group">
<label>Målmarknader (välj de du vill analysera)</label>
<div class="checkbox-group">
<label><input type="checkbox" name="targetMarkets" value="SE" checked> Sverige</label>
<label><input type="checkbox" name="targetMarkets" value="NO"> Norge</label>
<label><input type="checkbox" name="targetMarkets" value="DK"> Danmark</label>
<label><input type="checkbox" name="targetMarkets" value="FI"> Finland</label>
<label><input type="checkbox" name="targetMarkets" value="DE"> Tyskland</label>
<label><input type="checkbox" name="targetMarkets" value="NL"> Nederländerna</label>
<label><input type="checkbox" name="targetMarkets" value="UK"> Storbritannien</label>
<label><input type="checkbox" name="targetMarkets" value="PL"> Polen</label>
</div>
</div>
</div>
<div class="form-section">
<h3>Leverans</h3>
<div class="form-group">
<label for="email">E-postadress *</label>
<input type="email" id="email" name="email" required placeholder="din@email.com">
<small>Rapporten skickas till denna adress när den är klar.</small>
</div>
<div class="form-group">
<label for="priority">Prioritet</label>
<select id="priority" name="priority">
<option value="normal">Normal (72h)</option>
<option value="high">Hög (48h)</option>
<option value="urgent">Brådskande (24h)</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Beställ rapport</button>
<button type="button" class="btn btn-secondary" onclick="trackOrder()">Spåra beställning</button>
</div>
</form>
<div id="result" class="result hidden">
<div class="success-message">
<h3>✅ Beställning mottagen!</h3>
<p>Din beställning har registrerats och bearbetas.</p>
<div id="orderDetails"></div>
<p><a href="#" id="trackLink" class="btn btn-secondary">Spåra din beställning</a></p>
</div>
</div>
<div id="error" class="error hidden"></div>
</main>
<footer class="footer">
<div class="container">
<p>Landvex AI - Data-driven marknadsintelligens</p>
</div>
</footer>
<script src="/js/main.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
let authToken = localStorage.getItem('landvex_admin_token');
if (authToken) {
showDashboard();
}
document.getElementById('loginForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (response.ok) {
authToken = result.data.token;
localStorage.setItem('landvex_admin_token', authToken);
showDashboard();
} else {
alert(result.error || 'Inloggning misslyckades');
}
} catch (error) {
alert('Kunde inte logga in');
}
});
function showDashboard() {
document.getElementById('loginSection').classList.add('hidden');
document.getElementById('dashboardSection').classList.remove('hidden');
loadStats();
}
function logout() {
localStorage.removeItem('landvex_admin_token');
authToken = null;
location.reload();
}
function showTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.getElementById(`${tab}Tab`).classList.remove('hidden');
if (tab === 'orders') loadOrders();
if (tab === 'reports') loadReports();
}
async function loadStats() {
try {
const response = await fetch('/api/admin/stats', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const stats = result.data;
document.getElementById('statsGrid').innerHTML = `
<div class="stat-card">
<h4>Totalt antal beställningar</h4>
<div class="number">${stats.orders.total}</div>
</div>
<div class="stat-card">
<h4>Genomförda</h4>
<div class="number">${stats.orders.completed}</div>
</div>
<div class="stat-card">
<h4>Väntande</h4>
<div class="number">${stats.orders.pending}</div>
</div>
<div class="stat-card">
<h4>Genomförandedgrad</h4>
<div class="number">${stats.orders.completionRate}%</div>
</div>
`;
document.getElementById('recentActivity').innerHTML = stats.recentOrders.map(order => `
<div class="order-card">
<p><strong>${order.productName}</strong> - ${translateStatus(order.status)}</p>
<small>${new Date(order.createdAt).toLocaleString('sv-SE')}</small>
</div>
`).join('');
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadOrders() {
try {
const response = await fetch('/api/orders', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const html = `
<table>
<thead>
<tr>
<th>Produkt</th>
<th>E-post</th>
<th>Status</th>
<th>Framsteg</th>
<th>Datum</th>
</tr>
</thead>
<tbody>
${result.data.map(order => `
<tr>
<td>${order.productName}</td>
<td>${order.email}</td>
<td><span class="badge badge-${order.status}">${translateStatus(order.status)}</span></td>
<td>${order.progress}%</td>
<td>${new Date(order.createdAt).toLocaleDateString('sv-SE')}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('ordersTable').innerHTML = html;
}
} catch (error) {
console.error('Error loading orders:', error);
}
}
async function loadReports() {
try {
const response = await fetch('/api/reports', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (response.ok) {
const html = `
<table>
<thead>
<tr>
<th>Titel</th>
<th>Produkt</th>
<th>Genererad</th>
<th>Åtgärder</th>
</tr>
</thead>
<tbody>
${result.data.map(report => `
<tr>
<td>${report.title}</td>
<td>${report.order?.productName || 'N/A'}</td>
<td>${new Date(report.generatedAt).toLocaleDateString('sv-SE')}</td>
<td>
<a href="/api/reports/${report.id}/html" target="_blank" class="btn btn-small">Visa</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('reportsTable').innerHTML = html;
}
} catch (error) {
console.error('Error loading reports:', error);
}
}
function translateStatus(status) {
const translations = {
pending: 'Väntar',
data_collection: 'Samlar data',
analysis: 'Analyserar',
report_generation: 'Genererar rapport',
completed: 'Klar',
failed: 'Misslyckades'
};
return translations[status] || status;
}
+69
View File
@@ -0,0 +1,69 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('orderForm');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = {
productName: formData.get('productName'),
productType: formData.get('productType'),
ingredients: formData.get('ingredients'),
usage: formData.get('usage'),
format: formData.get('format'),
priceLevel: formData.get('priceLevel'),
certifications: formData.get('certifications'),
targetGroups: Array.from(form.querySelectorAll('input[name="targetGroups"]:checked')).map(cb => cb.value),
targetMarkets: Array.from(form.querySelectorAll('input[name="targetMarkets"]:checked')).map(cb => cb.value),
currentMarkets: Array.from(form.querySelectorAll('input[name="currentMarkets"]:checked')).map(cb => cb.value),
email: formData.get('email'),
priority: formData.get('priority')
};
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showResult(result.data);
} else {
showError(result.error || 'Ett fel inträffade');
}
} catch (error) {
showError('Kunde inte skicka beställningen. Försök igen.');
}
});
});
function showResult(data) {
document.getElementById('orderForm').classList.add('hidden');
document.getElementById('result').classList.remove('hidden');
document.getElementById('orderDetails').innerHTML = `
<p><strong>Beställnings-ID:</strong> ${data.id}</p>
<p><strong>Produkt:</strong> ${data.productName}</p>
<p><strong>Beräknad klar:</strong> ${new Date(data.estimatedCompletion).toLocaleString('sv-SE')}</p>
`;
document.getElementById('trackLink').href = `/status?email=${encodeURIComponent(data.email)}`;
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
function trackOrder() {
const email = document.getElementById('email').value;
if (email) {
window.location.href = `/status?email=${encodeURIComponent(email)}`;
} else {
window.location.href = '/status';
}
}
+88
View File
@@ -0,0 +1,88 @@
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const orderId = urlParams.get('orderId');
const reportId = urlParams.get('id');
if (!orderId && !reportId) {
showError('Ingen rapport angiven');
return;
}
try {
let response;
if (orderId) {
response = await fetch(`/api/reports/order/${orderId}`);
} else {
response = await fetch(`/api/reports/${reportId}`);
}
const result = await response.json();
if (response.ok) {
displayReport(result.data);
} else {
showError(result.error || 'Rapport hittades inte');
}
} catch (error) {
showError('Kunde inte ladda rapporten');
}
});
function displayReport(report) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('reportContent').classList.remove('hidden');
document.getElementById('reportTitle').textContent = report.title;
document.getElementById('reportMeta').innerHTML = `
Genererad: ${new Date(report.generatedAt).toLocaleString('sv-SE')} |
Order: ${report.order?.productName || 'N/A'}
`;
if (report.executiveSummary) {
document.getElementById('executiveSummary').innerHTML = `
<h3>Exekutiv Sammanfattning</h3>
<p>${report.executiveSummary}</p>
`;
}
// Render charts
if (report.charts && report.charts.length > 0) {
const container = document.getElementById('chartsContainer');
container.innerHTML = '';
report.charts.forEach((chart, index) => {
const chartDiv = document.createElement('div');
chartDiv.className = 'chart-box';
chartDiv.innerHTML = `<canvas id="chart${index}"></canvas>`;
container.appendChild(chartDiv);
new Chart(document.getElementById(`chart${index}`), {
type: chart.type,
data: chart.data,
options: {
responsive: true,
plugins: {
title: {
display: true,
text: chart.title
}
}
}
});
});
}
// Render report content
if (report.htmlContent) {
document.getElementById('reportBody').innerHTML = report.htmlContent;
} else if (report.content) {
document.getElementById('reportBody').innerHTML = report.content;
}
}
function showError(message) {
document.getElementById('loading').classList.add('hidden');
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
+88
View File
@@ -0,0 +1,88 @@
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email');
if (email) {
document.getElementById('trackEmail').value = email;
loadOrders();
}
});
async function loadOrders() {
const email = document.getElementById('trackEmail').value;
if (!email) {
alert('Ange en e-postadress');
return;
}
try {
const response = await fetch(`/api/orders/track?email=${encodeURIComponent(email)}`);
const result = await response.json();
if (response.ok && result.data.length > 0) {
displayOrders(result.data);
} else {
document.getElementById('ordersList').innerHTML = '<p>Inga beställningar hittades.</p>';
}
} catch (error) {
document.getElementById('ordersList').innerHTML = '<p>Kunde inte ladda beställningar.</p>';
}
}
function displayOrders(orders) {
const html = orders.map(order => `
<div class="order-card" onclick="showOrderDetail('${order.id}')">
<h4>${order.productName}</h4>
<p>Status: <span class="badge badge-${order.status}">${translateStatus(order.status)}</span></p>
<p>Beställd: ${new Date(order.createdAt).toLocaleString('sv-SE')}</p>
<p>Framsteg: ${order.progress}%</p>
</div>
`).join('');
document.getElementById('ordersList').innerHTML = html;
}
async function showOrderDetail(orderId) {
try {
const response = await fetch(`/api/orders/${orderId}`);
const result = await response.json();
if (response.ok) {
const order = result.data;
document.getElementById('orderDetail').classList.remove('hidden');
document.getElementById('detailContent').innerHTML = `
<p><strong>Produkt:</strong> ${order.productName}</p>
<p><strong>Typ:</strong> ${order.productType}</p>
<p><strong>Status:</strong> ${translateStatus(order.status)}</p>
<p><strong>Beställd:</strong> ${new Date(order.createdAt).toLocaleString('sv-SE')}</p>
<p><strong>Beräknad klar:</strong> ${order.estimatedCompletion ? new Date(order.estimatedCompletion).toLocaleString('sv-SE') : 'Ej satt'}</p>
`;
document.getElementById('progressFill').style.width = `${order.progress}%`;
document.getElementById('progressText').textContent = order.progressMessage || translateStatus(order.status);
if (order.status === 'completed') {
document.getElementById('reportSection').classList.remove('hidden');
document.getElementById('viewReportBtn').href = `/report-view?orderId=${order.id}`;
} else {
document.getElementById('reportSection').classList.add('hidden');
}
}
} catch (error) {
console.error('Error loading order detail:', error);
}
}
function translateStatus(status) {
const translations = {
pending: 'Väntar',
data_collection: 'Samlar data',
analysis: 'Analyserar',
report_generation: 'Genererar rapport',
completed: 'Klar',
failed: 'Misslyckades'
};
return translations[status] || status;
}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rapport - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>Marknadsintelligensrapport</p>
</div>
</header>
<main class="container">
<div id="loading" class="loading">
<p>Laddar rapport...</p>
</div>
<div id="reportContent" class="hidden">
<div class="report-header">
<h2 id="reportTitle"></h2>
<p id="reportMeta"></p>
</div>
<div id="executiveSummary" class="executive-summary"></div>
<div id="chartsContainer" class="charts-container"></div>
<div id="reportBody"></div>
<div class="report-actions">
<button onclick="window.print()" class="btn btn-secondary">Skriv ut / Spara som PDF</button>
<a href="/" class="btn btn-secondary">Ny beställning</a>
</div>
</div>
<div id="error" class="error hidden"></div>
</main>
<script src="/js/report.js"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spåra beställning - Landvex</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1>🏢 Landvex</h1>
<p>Spåra din beställning</p>
</div>
</header>
<main class="container">
<section class="track-section">
<h2>Spåra din beställning</h2>
<div class="form-group">
<label for="trackEmail">E-postadress</label>
<input type="email" id="trackEmail" placeholder="din@email.com">
<button onclick="loadOrders()" class="btn btn-primary">Sök</button>
</div>
<div id="ordersList"></div>
</section>
<section id="orderDetail" class="hidden">
<h3>Beställningsdetaljer</h3>
<div id="detailContent"></div>
<div id="progressSection">
<h4>Framsteg</h4>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<p id="progressText"></p>
</div>
<div id="reportSection" class="hidden">
<h4>Din rapport</h4>
<a id="viewReportBtn" href="#" class="btn btn-primary">Visa rapport</a>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p><a href="/">← Tillbaka till startsidan</a></p>
</div>
</footer>
<script src="/js/status.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
const Queue = require('bull');
const logger = require('../utils/logger');
const reportQueue = new Queue('report generation', process.env.REDIS_URL || 'redis://127.0.0.1:6379', {
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
},
removeOnComplete: 100,
removeOnFail: 50
}
});
reportQueue.on('completed', (job, result) => {
logger.info(`Job ${job.id} completed for order ${job.data.orderId}`);
});
reportQueue.on('failed', (job, err) => {
logger.error(`Job ${job.id} failed:`, err);
});
reportQueue.on('progress', (job, progress) => {
logger.info(`Job ${job.id} progress: ${progress}%`);
});
module.exports = reportQueue;
+13
View File
@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const adminController = require('../controllers/adminController');
const { authMiddleware, adminOnly } = require('../middleware/auth');
// Public
router.post('/login', adminController.login);
// Protected
router.get('/stats', authMiddleware, adminController.getStats);
router.post('/users', authMiddleware, adminOnly, adminController.createUser);
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const orderController = require('../controllers/orderController');
const { orderValidation, validate } = require('../middleware/validation');
const { authMiddleware } = require('../middleware/auth');
// Public routes
router.post('/', orderValidation, validate, orderController.create);
router.get('/track', orderController.getByEmail);
router.get('/:id', orderController.getById);
// Admin routes
router.get('/', authMiddleware, orderController.getAll);
router.patch('/:id/status', authMiddleware, orderController.updateStatus);
module.exports = router;
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const reportController = require('../controllers/reportController');
const { authMiddleware } = require('../middleware/auth');
// Public routes
router.get('/:id/html', reportController.getHtml);
router.get('/order/:orderId', reportController.getByOrderId);
router.get('/:id', reportController.getById);
// Admin routes
router.get('/', authMiddleware, reportController.getAll);
module.exports = router;
+41
View File
@@ -0,0 +1,41 @@
const app = require('./app');
const { sequelize } = require('./models');
const logger = require('./utils/logger');
require('./jobs/reportJob'); // Initialize queue processors
const PORT = process.env.PORT || 3000;
// Database connection and server start
async function startServer() {
try {
await sequelize.authenticate();
logger.info('Database connection established successfully.');
// Sync models (in production, use migrations instead)
await sequelize.sync({ alter: process.env.NODE_ENV === 'development' });
logger.info('Database models synchronized.');
app.listen(PORT, () => {
logger.info(`Landvex server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
} catch (error) {
logger.error('Unable to start server:', error);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully');
await sequelize.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT received, shutting down gracefully');
await sequelize.close();
process.exit(0);
});
startServer();
+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();
+27
View File
@@ -0,0 +1,27 @@
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'landvex-api' },
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' })
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;