+
+
-
-
-
-
Henter dine samtaler...
+
+
+
+
+
+
+
Vælg en samtale for at se detaljer
+
Klik på en samtale i listen til venstre.
+
+
+function filterView(type) {
+ const items = document.querySelectorAll('.list-group-item');
+ items.forEach(item => {
+ if (type === 'all') item.classList.remove('d-none');
+ else if (type === 'private') {
+ item.dataset.type === 'private' ? item.classList.remove('d-none') : item.classList.add('d-none');
+ }
+ });
+}
+function filterConversations() {
+ const query = document.getElementById('conversationSearch').value.toLowerCase();
+ const items = document.querySelectorAll('.list-group-item');
+ items.forEach(item => {
+ const text = item.dataset.text;
+ text.includes(query) ? item.classList.remove('d-none') : item.classList.add('d-none');
+ });
+}
+
+
{% endblock %}
diff --git a/app/core/config.py b/app/core/config.py
index 54ac438..dff8cbc 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -154,7 +154,7 @@ class Settings(BaseSettings):
# Whisper Transcription
WHISPER_ENABLED: bool = True
WHISPER_API_URL: str = "http://172.16.31.115:5000/transcribe"
- WHISPER_TIMEOUT: int = 30
+ WHISPER_TIMEOUT: int = 300
WHISPER_SUPPORTED_FORMATS: List[str] = [".mp3", ".wav", ".m4a", ".ogg"]
@field_validator('*', mode='before')
diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index c11f8b1..24cf82e 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -354,7 +354,7 @@ async def delete_email(email_id: int):
@router.post("/emails/{email_id}/reprocess")
async def reprocess_email(email_id: int):
- """Reprocess email (re-classify and apply rules)"""
+ """Reprocess email (re-classify, run workflows, and apply rules)"""
try:
# Get email
query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL"
@@ -365,9 +365,9 @@ async def reprocess_email(email_id: int):
email = result[0]
- # Re-classify using processor service
+ # Re-classify and run full processing pipeline
processor = EmailProcessorService()
- await processor._classify_and_update(email)
+ processing_result = await processor.process_single_email(email)
# Re-fetch updated email
result = execute_query(query, (email_id,))
@@ -376,9 +376,10 @@ async def reprocess_email(email_id: int):
logger.info(f"🔄 Reprocessed email {email_id}: {email['classification']} ({email.get('confidence_score', 0):.2f})")
return {
"success": True,
- "message": "Email reprocessed",
+ "message": "Email reprocessed with workflows",
"classification": email['classification'],
- "confidence": email.get('confidence_score', 0)
+ "confidence": email.get('confidence_score', 0),
+ "workflows_executed": processing_result.get('workflows_executed', 0)
}
except HTTPException:
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html
index 4118497..282d975 100644
--- a/app/emails/frontend/emails.html
+++ b/app/emails/frontend/emails.html
@@ -1584,7 +1584,9 @@ async function loadEmailDetail(emailId) {
}
} catch (error) {
console.error('Failed to load email detail:', error);
- showError('Kunne ikke indlæse email detaljer: ' + error.message);
+ const errorMsg = error?.message || String(error) || 'Ukendt fejl';
+ alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
+ showEmptyState();
}
}
@@ -1746,6 +1748,11 @@ function renderEmailDetail(email) {
function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
+ if (!aiAnalysisTab) {
+ console.error('aiAnalysisTab element not found in DOM');
+ return;
+ }
+
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
@@ -1779,6 +1786,7 @@ function renderEmailAnalysis(email) {
🚚 Fragtnote
⏰ Tidsregistrering
📋 Sagsnotifikation
+
🎤 Optagelse
⚠️ Konkurs
🚫 Spam
📧 Generel
diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py
new file mode 100644
index 0000000..107f94b
--- /dev/null
+++ b/app/jobs/__init__.py
@@ -0,0 +1,3 @@
+"""
+Scheduled Jobs Module
+"""
diff --git a/app/jobs/sync_economic_accounts.py b/app/jobs/sync_economic_accounts.py
new file mode 100644
index 0000000..a85229e
--- /dev/null
+++ b/app/jobs/sync_economic_accounts.py
@@ -0,0 +1,115 @@
+"""
+Daily sync job for e-conomic chart of accounts (kontoplan)
+
+Scheduled to run every day at 06:00
+Updates the economic_accounts cache table with latest data from e-conomic API
+"""
+
+import logging
+from datetime import datetime
+from app.core.database import execute_query, execute_update, execute_insert
+from app.services.economic_service import get_economic_service
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+async def sync_economic_accounts():
+ """
+ Sync e-conomic chart of accounts to local cache
+
+ This job:
+ 1. Fetches all accounts from e-conomic API
+ 2. Updates economic_accounts table
+ 3. Logs sync statistics
+ """
+ try:
+ logger.info("🔄 Starting daily e-conomic accounts sync...")
+
+ # Check if e-conomic is configured
+ if not settings.ECONOMIC_APP_SECRET_TOKEN or not settings.ECONOMIC_AGREEMENT_GRANT_TOKEN:
+ logger.warning("⚠️ e-conomic credentials not configured - skipping sync")
+ return {
+ "success": False,
+ "reason": "e-conomic credentials not configured"
+ }
+
+ # Get economic service
+ economic_service = get_economic_service()
+
+ # Fetch accounts from e-conomic
+ logger.info("📥 Fetching accounts from e-conomic API...")
+ accounts = economic_service.get_accounts()
+
+ if not accounts:
+ logger.warning("⚠️ No accounts returned from e-conomic API")
+ return {
+ "success": False,
+ "reason": "No accounts returned from API"
+ }
+
+ logger.info(f"📦 Fetched {len(accounts)} accounts from e-conomic")
+
+ # Update database - upsert each account
+ updated_count = 0
+ inserted_count = 0
+
+ for account in accounts:
+ account_number = account.get('accountNumber')
+ name = account.get('name')
+ account_type = account.get('accountType')
+ vat_code = account.get('vatCode', {}).get('vatCode') if account.get('vatCode') else None
+
+ if not account_number:
+ continue
+
+ # Check if account exists
+ existing = execute_query(
+ "SELECT account_number FROM economic_accounts WHERE account_number = %s",
+ (account_number,)
+ )
+
+ if existing:
+ # Update existing
+ execute_update(
+ """UPDATE economic_accounts
+ SET name = %s, account_type = %s, vat_code = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE account_number = %s""",
+ (name, account_type, vat_code, account_number)
+ )
+ updated_count += 1
+ else:
+ # Insert new
+ execute_insert(
+ """INSERT INTO economic_accounts
+ (account_number, name, account_type, vat_code, updated_at)
+ VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
+ (account_number, name, account_type, vat_code)
+ )
+ inserted_count += 1
+
+ logger.info(f"✅ e-conomic accounts sync complete: {inserted_count} inserted, {updated_count} updated")
+
+ return {
+ "success": True,
+ "fetched": len(accounts),
+ "inserted": inserted_count,
+ "updated": updated_count,
+ "timestamp": datetime.now().isoformat()
+ }
+
+ except Exception as e:
+ logger.error(f"❌ e-conomic accounts sync failed: {e}", exc_info=True)
+ return {
+ "success": False,
+ "error": str(e),
+ "timestamp": datetime.now().isoformat()
+ }
+
+
+if __name__ == "__main__":
+ # Allow running this job manually for testing
+ import asyncio
+ result = asyncio.run(sync_economic_accounts())
+ print(f"Sync result: {result}")
diff --git a/app/modules/webshop/README.md b/app/modules/webshop/README.md
new file mode 100644
index 0000000..2b0ba56
--- /dev/null
+++ b/app/modules/webshop/README.md
@@ -0,0 +1,174 @@
+# Webshop Module
+
+Dette er template strukturen for nye BMC Hub moduler.
+
+## Struktur
+
+```
+webshop/
+├── module.json # Metadata og konfiguration
+├── README.md # Dokumentation
+├── backend/
+│ ├── __init__.py
+│ └── router.py # FastAPI routes (API endpoints)
+├── frontend/
+│ ├── __init__.py
+│ └── views.py # HTML view routes
+├── templates/
+│ └── index.html # Jinja2 templates
+└── migrations/
+ └── 001_init.sql # Database migrations
+```
+
+## Opret nyt modul
+
+```bash
+python scripts/create_module.py webshop "My Module Description"
+```
+
+## Database Tables
+
+Alle tabeller SKAL bruge `table_prefix` fra module.json:
+
+```sql
+-- Hvis table_prefix = "webshop_"
+CREATE TABLE webshop_items (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255)
+);
+```
+
+Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
+
+### Customer Linking (Hvis nødvendigt)
+
+Hvis dit modul skal have sin egen kunde-tabel (f.eks. ved sync fra eksternt system):
+
+**SKAL altid linke til core customers:**
+
+```sql
+CREATE TABLE webshop_customers (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ external_id VARCHAR(100), -- ID fra eksternt system
+ hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG!
+ active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Auto-link trigger (se migrations/001_init.sql for komplet eksempel)
+CREATE TRIGGER trigger_auto_link_webshop_customer
+ BEFORE INSERT OR UPDATE OF name
+ ON webshop_customers
+ FOR EACH ROW
+ EXECUTE FUNCTION auto_link_webshop_customer();
+```
+
+**Hvorfor?** Dette sikrer at:
+- ✅ E-conomic export virker automatisk
+- ✅ Billing integration fungerer
+- ✅ Ingen manuel linking nødvendig
+
+**Alternativ:** Hvis modulet kun har simple kunde-relationer, brug direkte FK:
+```sql
+CREATE TABLE webshop_orders (
+ id SERIAL PRIMARY KEY,
+ customer_id INTEGER REFERENCES customers(id) -- Direkte link
+);
+```
+
+## Konfiguration
+
+Modul-specifikke miljøvariable følger mønsteret:
+
+```bash
+MODULES__MY_MODULE__API_KEY=secret123
+MODULES__MY_MODULE__READ_ONLY=true
+```
+
+Tilgå i kode:
+
+```python
+from app.core.config import get_module_config
+
+api_key = get_module_config("webshop", "API_KEY")
+read_only = get_module_config("webshop", "READ_ONLY", default="true") == "true"
+```
+
+## Database Queries
+
+Brug ALTID helper functions fra `app.core.database`:
+
+```python
+from app.core.database import execute_query, execute_insert
+
+# Fetch
+customers = execute_query(
+ "SELECT * FROM webshop_customers WHERE active = %s",
+ (True,)
+)
+
+# Insert
+customer_id = execute_insert(
+ "INSERT INTO webshop_customers (name) VALUES (%s)",
+ ("Test Customer",)
+)
+```
+
+## Migrations
+
+Migrations ligger i `migrations/` og køres manuelt eller via migration tool:
+
+```python
+from app.core.database import execute_module_migration
+
+with open("migrations/001_init.sql") as f:
+ migration_sql = f.read()
+
+success = execute_module_migration("webshop", migration_sql)
+```
+
+## Enable/Disable
+
+```bash
+# Enable via API
+curl -X POST http://localhost:8000/api/v1/modules/webshop/enable
+
+# Eller rediger module.json
+{
+ "enabled": true
+}
+
+# Restart app
+docker-compose restart api
+```
+
+## Fejlhåndtering
+
+Moduler er isolerede - hvis dit modul crasher ved opstart:
+- Core systemet kører videre
+- Modulet bliver ikke loaded
+- Fejl logges til console og logs/app.log
+
+Runtime fejl i endpoints påvirker ikke andre moduler.
+
+## Testing
+
+```python
+import pytest
+from app.core.database import execute_query
+
+def test_webshop():
+ # Test bruger samme database helpers
+ result = execute_query("SELECT 1 as test")
+ assert result[0]["test"] == 1
+```
+
+## Best Practices
+
+1. **Database isolering**: Brug ALTID `table_prefix` fra module.json
+2. **Safety switches**: Tilføj `READ_ONLY` og `DRY_RUN` flags
+3. **Error handling**: Log fejl, raise HTTPException med status codes
+4. **Dependencies**: Deklarer i `module.json` hvis du bruger andre moduler
+5. **Migrations**: Nummer sekventielt (001, 002, 003...)
+6. **Documentation**: Opdater README.md med API endpoints og use cases
diff --git a/app/modules/webshop/backend/__init__.py b/app/modules/webshop/backend/__init__.py
new file mode 100644
index 0000000..2db4e36
--- /dev/null
+++ b/app/modules/webshop/backend/__init__.py
@@ -0,0 +1 @@
+# Backend package for template module
diff --git a/app/modules/webshop/backend/router.py b/app/modules/webshop/backend/router.py
new file mode 100644
index 0000000..8da946b
--- /dev/null
+++ b/app/modules/webshop/backend/router.py
@@ -0,0 +1,556 @@
+"""
+Webshop Module - API Router
+Backend endpoints for webshop administration og konfiguration
+"""
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Form
+from typing import List, Optional
+from pydantic import BaseModel
+from datetime import datetime
+import logging
+import os
+import shutil
+
+from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
+
+logger = logging.getLogger(__name__)
+
+# APIRouter instance (module_loader kigger efter denne)
+router = APIRouter()
+
+# Upload directory for logos
+LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
+os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
+
+
+# ============================================================================
+# PYDANTIC MODELS
+# ============================================================================
+
+class WebshopConfigCreate(BaseModel):
+ customer_id: int
+ name: str
+ allowed_email_domains: str # Comma-separated
+ header_text: Optional[str] = None
+ intro_text: Optional[str] = None
+ primary_color: str = "#0f4c75"
+ accent_color: str = "#3282b8"
+ default_margin_percent: float = 10.0
+ min_order_amount: float = 0.0
+ shipping_cost: float = 0.0
+ enabled: bool = True
+
+
+class WebshopConfigUpdate(BaseModel):
+ name: Optional[str] = None
+ allowed_email_domains: Optional[str] = None
+ header_text: Optional[str] = None
+ intro_text: Optional[str] = None
+ primary_color: Optional[str] = None
+ accent_color: Optional[str] = None
+ default_margin_percent: Optional[float] = None
+ min_order_amount: Optional[float] = None
+ shipping_cost: Optional[float] = None
+ enabled: Optional[bool] = None
+
+
+class WebshopProductCreate(BaseModel):
+ webshop_config_id: int
+ product_number: str
+ ean: Optional[str] = None
+ name: str
+ description: Optional[str] = None
+ unit: str = "stk"
+ base_price: float
+ category: Optional[str] = None
+ custom_margin_percent: Optional[float] = None
+ visible: bool = True
+ sort_order: int = 0
+
+
+# ============================================================================
+# WEBSHOP CONFIG ENDPOINTS
+# ============================================================================
+
+@router.get("/webshop/configs")
+async def get_all_webshop_configs():
+ """
+ Hent alle webshop konfigurationer med kunde info
+ """
+ try:
+ query = """
+ SELECT
+ wc.*,
+ c.name as customer_name,
+ c.cvr_number as customer_cvr,
+ (SELECT COUNT(*) FROM webshop_products WHERE webshop_config_id = wc.id) as product_count
+ FROM webshop_configs wc
+ LEFT JOIN customers c ON c.id = wc.customer_id
+ ORDER BY wc.created_at DESC
+ """
+ configs = execute_query(query)
+
+ return {
+ "success": True,
+ "configs": configs
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching webshop configs: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/webshop/configs/{config_id}")
+async def get_webshop_config(config_id: int):
+ """
+ Hent enkelt webshop konfiguration
+ """
+ try:
+ query = """
+ SELECT
+ wc.*,
+ c.name as customer_name,
+ c.cvr_number as customer_cvr,
+ c.email as customer_email
+ FROM webshop_configs wc
+ LEFT JOIN customers c ON c.id = wc.customer_id
+ WHERE wc.id = %s
+ """
+ config = execute_query_single(query, (config_id,))
+
+ if not config:
+ raise HTTPException(status_code=404, detail="Webshop config not found")
+
+ # Hent produkter
+ products_query = """
+ SELECT * FROM webshop_products
+ WHERE webshop_config_id = %s
+ ORDER BY sort_order, name
+ """
+ products = execute_query(products_query, (config_id,))
+
+ return {
+ "success": True,
+ "config": config,
+ "products": products
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching webshop config {config_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/webshop/configs")
+async def create_webshop_config(config: WebshopConfigCreate):
+ """
+ Opret ny webshop konfiguration
+ """
+ try:
+ # Check if customer already has a webshop
+ existing = execute_query_single(
+ "SELECT id FROM webshop_configs WHERE customer_id = %s",
+ (config.customer_id,)
+ )
+ if existing:
+ raise HTTPException(
+ status_code=400,
+ detail="Customer already has a webshop configuration"
+ )
+
+ query = """
+ INSERT INTO webshop_configs (
+ customer_id, name, allowed_email_domains, header_text, intro_text,
+ primary_color, accent_color, default_margin_percent,
+ min_order_amount, shipping_cost, enabled
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """
+
+ result = execute_query_single(
+ query,
+ (
+ config.customer_id, config.name, config.allowed_email_domains,
+ config.header_text, config.intro_text, config.primary_color,
+ config.accent_color, config.default_margin_percent,
+ config.min_order_amount, config.shipping_cost, config.enabled
+ )
+ )
+
+ logger.info(f"✅ Created webshop config {result['id']} for customer {config.customer_id}")
+
+ return {
+ "success": True,
+ "config": result,
+ "message": "Webshop configuration created successfully"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating webshop config: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/webshop/configs/{config_id}")
+async def update_webshop_config(config_id: int, config: WebshopConfigUpdate):
+ """
+ Opdater webshop konfiguration
+ """
+ try:
+ # Build dynamic update query
+ update_fields = []
+ params = []
+
+ if config.name is not None:
+ update_fields.append("name = %s")
+ params.append(config.name)
+ if config.allowed_email_domains is not None:
+ update_fields.append("allowed_email_domains = %s")
+ params.append(config.allowed_email_domains)
+ if config.header_text is not None:
+ update_fields.append("header_text = %s")
+ params.append(config.header_text)
+ if config.intro_text is not None:
+ update_fields.append("intro_text = %s")
+ params.append(config.intro_text)
+ if config.primary_color is not None:
+ update_fields.append("primary_color = %s")
+ params.append(config.primary_color)
+ if config.accent_color is not None:
+ update_fields.append("accent_color = %s")
+ params.append(config.accent_color)
+ if config.default_margin_percent is not None:
+ update_fields.append("default_margin_percent = %s")
+ params.append(config.default_margin_percent)
+ if config.min_order_amount is not None:
+ update_fields.append("min_order_amount = %s")
+ params.append(config.min_order_amount)
+ if config.shipping_cost is not None:
+ update_fields.append("shipping_cost = %s")
+ params.append(config.shipping_cost)
+ if config.enabled is not None:
+ update_fields.append("enabled = %s")
+ params.append(config.enabled)
+
+ if not update_fields:
+ raise HTTPException(status_code=400, detail="No fields to update")
+
+ params.append(config_id)
+ query = f"""
+ UPDATE webshop_configs
+ SET {', '.join(update_fields)}
+ WHERE id = %s
+ RETURNING *
+ """
+
+ result = execute_query_single(query, tuple(params))
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Webshop config not found")
+
+ logger.info(f"✅ Updated webshop config {config_id}")
+
+ return {
+ "success": True,
+ "config": result,
+ "message": "Webshop configuration updated successfully"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating webshop config {config_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/webshop/configs/{config_id}/upload-logo")
+async def upload_webshop_logo(config_id: int, logo: UploadFile = File(...)):
+ """
+ Upload logo til webshop
+ """
+ try:
+ # Validate file type
+ if not logo.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="File must be an image")
+
+ # Generate filename
+ ext = logo.filename.split(".")[-1]
+ filename = f"webshop_{config_id}.{ext}"
+ filepath = os.path.join(LOGO_UPLOAD_DIR, filename)
+
+ # Save file
+ with open(filepath, 'wb') as f:
+ shutil.copyfileobj(logo.file, f)
+
+ # Update database
+ query = """
+ UPDATE webshop_configs
+ SET logo_filename = %s
+ WHERE id = %s
+ RETURNING *
+ """
+ result = execute_query_single(query, (filename, config_id))
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Webshop config not found")
+
+ logger.info(f"✅ Uploaded logo for webshop {config_id}: {filename}")
+
+ return {
+ "success": True,
+ "filename": filename,
+ "config": result,
+ "message": "Logo uploaded successfully"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error uploading logo for webshop {config_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/webshop/configs/{config_id}")
+async def delete_webshop_config(config_id: int):
+ """
+ Slet webshop konfiguration (soft delete - disable)
+ """
+ try:
+ query = """
+ UPDATE webshop_configs
+ SET enabled = FALSE
+ WHERE id = %s
+ RETURNING *
+ """
+ result = execute_query_single(query, (config_id,))
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Webshop config not found")
+
+ logger.info(f"✅ Disabled webshop config {config_id}")
+
+ return {
+ "success": True,
+ "message": "Webshop configuration disabled"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error deleting webshop config {config_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# WEBSHOP PRODUCT ENDPOINTS
+# ============================================================================
+
+@router.post("/webshop/products")
+async def add_webshop_product(product: WebshopProductCreate):
+ """
+ Tilføj produkt til webshop whitelist
+ """
+ try:
+ query = """
+ INSERT INTO webshop_products (
+ webshop_config_id, product_number, ean, name, description,
+ unit, base_price, category, custom_margin_percent,
+ visible, sort_order
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """
+
+ result = execute_query_single(
+ query,
+ (
+ product.webshop_config_id, product.product_number, product.ean,
+ product.name, product.description, product.unit, product.base_price,
+ product.category, product.custom_margin_percent, product.visible,
+ product.sort_order
+ )
+ )
+
+ logger.info(f"✅ Added product {product.product_number} to webshop {product.webshop_config_id}")
+
+ return {
+ "success": True,
+ "product": result,
+ "message": "Product added to webshop"
+ }
+
+ except Exception as e:
+ if "unique" in str(e).lower():
+ raise HTTPException(status_code=400, detail="Product already exists in this webshop")
+ logger.error(f"❌ Error adding product to webshop: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/webshop/products/{product_id}")
+async def remove_webshop_product(product_id: int):
+ """
+ Fjern produkt fra webshop whitelist
+ """
+ try:
+ query = "DELETE FROM webshop_products WHERE id = %s RETURNING *"
+ result = execute_query_single(query, (product_id,))
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ logger.info(f"✅ Removed product {product_id} from webshop")
+
+ return {
+ "success": True,
+ "message": "Product removed from webshop"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error removing product {product_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# PUBLISH TO GATEWAY
+# ============================================================================
+
+@router.post("/webshop/configs/{config_id}/publish")
+async def publish_webshop_to_gateway(config_id: int):
+ """
+ Push webshop konfiguration til API Gateway
+ """
+ try:
+ from app.core.config import settings
+ import aiohttp
+
+ # Hent config og produkter
+ config_data = await get_webshop_config(config_id)
+
+ if not config_data["config"]["enabled"]:
+ raise HTTPException(status_code=400, detail="Cannot publish disabled webshop")
+
+ # Byg payload
+ payload = {
+ "config_id": config_id,
+ "customer_id": config_data["config"]["customer_id"],
+ "company_name": config_data["config"]["customer_name"],
+ "config_version": config_data["config"]["config_version"].isoformat(),
+ "branding": {
+ "logo_url": f"{settings.BASE_URL}/uploads/webshop_logos/{config_data['config']['logo_filename']}" if config_data['config'].get('logo_filename') else None,
+ "header_text": config_data["config"]["header_text"],
+ "intro_text": config_data["config"]["intro_text"],
+ "primary_color": config_data["config"]["primary_color"],
+ "accent_color": config_data["config"]["accent_color"]
+ },
+ "allowed_email_domains": config_data["config"]["allowed_email_domains"].split(","),
+ "products": [
+ {
+ "id": p["id"],
+ "product_number": p["product_number"],
+ "ean": p["ean"],
+ "name": p["name"],
+ "description": p["description"],
+ "unit": p["unit"],
+ "base_price": float(p["base_price"]),
+ "calculated_price": float(p["base_price"]) * (1 + (float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]) / 100)),
+ "margin_percent": float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]),
+ "category": p["category"],
+ "visible": p["visible"]
+ }
+ for p in config_data["products"]
+ ],
+ "settings": {
+ "min_order_amount": float(config_data["config"]["min_order_amount"]),
+ "shipping_cost": float(config_data["config"]["shipping_cost"]),
+ "default_margin_percent": float(config_data["config"]["default_margin_percent"])
+ }
+ }
+
+ # Send til Gateway
+ gateway_url = "https://apigateway.bmcnetworks.dk/webshop/ingest"
+
+ # TODO: Uncomment når Gateway er klar
+ # async with aiohttp.ClientSession() as session:
+ # async with session.post(gateway_url, json=payload) as response:
+ # if response.status != 200:
+ # error_text = await response.text()
+ # raise HTTPException(status_code=500, detail=f"Gateway error: {error_text}")
+ #
+ # gateway_response = await response.json()
+
+ # Mock response for now
+ logger.warning("⚠️ Gateway push disabled - mock response returned")
+ gateway_response = {"success": True, "message": "Config received (MOCK)"}
+
+ # Update last_published timestamps
+ update_query = """
+ UPDATE webshop_configs
+ SET last_published_at = CURRENT_TIMESTAMP,
+ last_published_version = config_version
+ WHERE id = %s
+ RETURNING *
+ """
+ updated_config = execute_query_single(update_query, (config_id,))
+
+ logger.info(f"✅ Published webshop {config_id} to Gateway")
+
+ return {
+ "success": True,
+ "config": updated_config,
+ "payload": payload,
+ "gateway_response": gateway_response,
+ "message": "Webshop configuration published to Gateway"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error publishing webshop {config_id} to Gateway: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# ORDERS FROM GATEWAY
+# ============================================================================
+
+@router.get("/webshop/orders")
+async def get_webshop_orders(config_id: Optional[int] = None, status: Optional[str] = None):
+ """
+ Hent importerede ordrer fra Gateway
+ """
+ try:
+ query = """
+ SELECT
+ wo.*,
+ wc.name as webshop_name,
+ c.name as customer_name
+ FROM webshop_orders wo
+ LEFT JOIN webshop_configs wc ON wc.id = wo.webshop_config_id
+ LEFT JOIN customers c ON c.id = wo.customer_id
+ WHERE 1=1
+ """
+ params = []
+
+ if config_id:
+ query += " AND wo.webshop_config_id = %s"
+ params.append(config_id)
+
+ if status:
+ query += " AND wo.status = %s"
+ params.append(status)
+
+ query += " ORDER BY wo.created_at DESC"
+
+ orders = execute_query(query, tuple(params) if params else None)
+
+ return {
+ "success": True,
+ "orders": orders
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching webshop orders: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/modules/webshop/frontend/__init__.py b/app/modules/webshop/frontend/__init__.py
new file mode 100644
index 0000000..abc5ac0
--- /dev/null
+++ b/app/modules/webshop/frontend/__init__.py
@@ -0,0 +1 @@
+# Frontend package for template module
diff --git a/app/modules/webshop/frontend/index.html b/app/modules/webshop/frontend/index.html
new file mode 100644
index 0000000..78e9c9e
--- /dev/null
+++ b/app/modules/webshop/frontend/index.html
@@ -0,0 +1,634 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Webshop Administration - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
Webshop Administration
+
Administrer kunde-webshops og konfigurationer
+
+
+
+ Opret Webshop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Administrer tilladte produkter for denne webshop
+
+
+ Tilføj Produkt
+
+
+
+
+
+
+
+
+
+ Varenr
+ Navn
+ EAN
+ Basispris
+ Avance %
+ Salgspris
+ Synlig
+
+
+
+
+
+ Ingen produkter endnu
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/modules/webshop/frontend/views.py b/app/modules/webshop/frontend/views.py
new file mode 100644
index 0000000..59def3c
--- /dev/null
+++ b/app/modules/webshop/frontend/views.py
@@ -0,0 +1,23 @@
+"""
+Webshop Module - Views Router
+HTML page serving for webshop admin interface
+"""
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+import os
+
+# Router for HTML views
+router = APIRouter()
+
+# Template directory - must be root "app" to allow extending shared/frontend/base.html
+templates = Jinja2Templates(directory="app")
+
+
+@router.get("/webshop", response_class=HTMLResponse, include_in_schema=False)
+async def webshop_admin(request: Request):
+ """
+ Webshop administration interface
+ """
+ return templates.TemplateResponse("modules/webshop/frontend/index.html", {"request": request})
diff --git a/app/modules/webshop/migrations/001_init.sql b/app/modules/webshop/migrations/001_init.sql
new file mode 100644
index 0000000..0131dfb
--- /dev/null
+++ b/app/modules/webshop/migrations/001_init.sql
@@ -0,0 +1,161 @@
+-- Webshop Module - Initial Migration
+-- Opret basis tabeller for webshop konfiguration og administration
+
+-- Webshop konfigurationer (én per kunde/domæne)
+CREATE TABLE IF NOT EXISTS webshop_configs (
+ id SERIAL PRIMARY KEY,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL, -- Webshop navn (fx "Advokatfirmaet A/S Webshop")
+
+ -- Email domæner der må logge ind (komma-separeret, fx "firma.dk,firma.com")
+ allowed_email_domains TEXT NOT NULL,
+
+ -- Branding
+ logo_filename VARCHAR(255), -- Filnavn i uploads/webshop_logos/
+ header_text TEXT,
+ intro_text TEXT,
+ primary_color VARCHAR(7) DEFAULT '#0f4c75', -- Hex color
+ accent_color VARCHAR(7) DEFAULT '#3282b8',
+
+ -- Pricing regler
+ default_margin_percent DECIMAL(5, 2) DEFAULT 10.00, -- Standard avance %
+ min_order_amount DECIMAL(10, 2) DEFAULT 0.00,
+ shipping_cost DECIMAL(10, 2) DEFAULT 0.00,
+
+ -- Status og versioning
+ enabled BOOLEAN DEFAULT TRUE,
+ config_version TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp for seneste ændring
+ last_published_at TIMESTAMP, -- Hvornår blev config sendt til Gateway
+ last_published_version TIMESTAMP, -- Hvilken version blev sendt
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(customer_id) -- Én webshop per kunde
+);
+
+-- Tilladte produkter per webshop (whitelist)
+CREATE TABLE IF NOT EXISTS webshop_products (
+ id SERIAL PRIMARY KEY,
+ webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE CASCADE,
+
+ -- Produkt identifikation (fra e-conomic)
+ product_number VARCHAR(100) NOT NULL,
+ ean VARCHAR(50),
+
+ -- Produkt info (synced fra e-conomic)
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ unit VARCHAR(50) DEFAULT 'stk',
+ base_price DECIMAL(10, 2), -- Pris fra e-conomic
+ category VARCHAR(100),
+
+ -- Webshop-specifik konfiguration
+ custom_margin_percent DECIMAL(5, 2), -- Override standard avance for dette produkt
+ visible BOOLEAN DEFAULT TRUE,
+ sort_order INTEGER DEFAULT 0,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(webshop_config_id, product_number)
+);
+
+-- Ordre importeret fra Gateway (når Hub poller Gateway)
+CREATE TABLE IF NOT EXISTS webshop_orders (
+ id SERIAL PRIMARY KEY,
+ webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE SET NULL,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
+
+ -- Order data fra Gateway
+ gateway_order_id VARCHAR(100) NOT NULL UNIQUE, -- ORD-2026-00123
+ order_email VARCHAR(255), -- Hvem bestilte
+ total_amount DECIMAL(10, 2),
+ status VARCHAR(50), -- pending, processing, completed, cancelled
+
+ -- Shipping info
+ shipping_company_name VARCHAR(255),
+ shipping_street TEXT,
+ shipping_postal_code VARCHAR(20),
+ shipping_city VARCHAR(100),
+ shipping_country VARCHAR(2) DEFAULT 'DK',
+ delivery_note TEXT,
+
+ -- Payload fra Gateway (JSON)
+ gateway_payload JSONB, -- Komplet order data fra Gateway
+
+ -- Import tracking
+ imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ processed_in_economic BOOLEAN DEFAULT FALSE,
+ economic_order_number INTEGER, -- e-conomic ordre nummer når oprettet
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Ordre linjer (items på ordre)
+CREATE TABLE IF NOT EXISTS webshop_order_items (
+ id SERIAL PRIMARY KEY,
+ webshop_order_id INTEGER REFERENCES webshop_orders(id) ON DELETE CASCADE,
+
+ product_number VARCHAR(100),
+ product_name VARCHAR(255),
+ quantity INTEGER NOT NULL,
+ unit_price DECIMAL(10, 2),
+ total_price DECIMAL(10, 2),
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_webshop_configs_customer ON webshop_configs(customer_id);
+CREATE INDEX IF NOT EXISTS idx_webshop_configs_enabled ON webshop_configs(enabled);
+CREATE INDEX IF NOT EXISTS idx_webshop_products_config ON webshop_products(webshop_config_id);
+CREATE INDEX IF NOT EXISTS idx_webshop_products_visible ON webshop_products(visible);
+CREATE INDEX IF NOT EXISTS idx_webshop_orders_gateway_id ON webshop_orders(gateway_order_id);
+CREATE INDEX IF NOT EXISTS idx_webshop_orders_customer ON webshop_orders(customer_id);
+CREATE INDEX IF NOT EXISTS idx_webshop_orders_status ON webshop_orders(status);
+CREATE INDEX IF NOT EXISTS idx_webshop_order_items_order ON webshop_order_items(webshop_order_id);
+
+-- Trigger for updated_at på configs
+CREATE OR REPLACE FUNCTION update_webshop_configs_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ NEW.config_version = CURRENT_TIMESTAMP; -- Bump version ved hver ændring
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_webshop_configs_updated_at
+BEFORE UPDATE ON webshop_configs
+FOR EACH ROW
+EXECUTE FUNCTION update_webshop_configs_updated_at();
+
+-- Trigger for updated_at på products
+CREATE OR REPLACE FUNCTION update_webshop_products_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_webshop_products_updated_at
+BEFORE UPDATE ON webshop_products
+FOR EACH ROW
+EXECUTE FUNCTION update_webshop_products_updated_at();
+
+-- Trigger for updated_at på orders
+CREATE OR REPLACE FUNCTION update_webshop_orders_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_webshop_orders_updated_at
+BEFORE UPDATE ON webshop_orders
+FOR EACH ROW
+EXECUTE FUNCTION update_webshop_orders_updated_at();
diff --git a/app/modules/webshop/module.json b/app/modules/webshop/module.json
new file mode 100644
index 0000000..79516ef
--- /dev/null
+++ b/app/modules/webshop/module.json
@@ -0,0 +1,19 @@
+{
+ "name": "webshop",
+ "version": "1.0.0",
+ "description": "Webshop administration og konfiguration",
+ "author": "BMC Networks",
+ "enabled": true,
+ "dependencies": [],
+ "table_prefix": "webshop_",
+ "api_prefix": "/api/v1/webshop",
+ "tags": [
+ "Webshop"
+ ],
+ "config": {
+ "safety_switches": {
+ "read_only": false,
+ "dry_run": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/modules/webshop/templates/index.html b/app/modules/webshop/templates/index.html
new file mode 100644
index 0000000..80ecb1b
--- /dev/null
+++ b/app/modules/webshop/templates/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
{{ page_title }} - BMC Hub
+
+
+
+
+
{{ page_title }}
+
+ {% if error %}
+
+ Error: {{ error }}
+
+ {% endif %}
+
+
+
+
+ {% if items %}
+
+
+
+ ID
+ Name
+ Description
+ Created
+
+
+
+ {% for item in items %}
+
+ {{ item.id }}
+ {{ item.name }}
+ {{ item.description or '-' }}
+ {{ item.created_at }}
+
+ {% endfor %}
+
+
+ {% else %}
+
No items found. This is a template module.
+ {% endif %}
+
+
+
+
+
+
+
+
+
diff --git a/app/services/email_processor_service.py b/app/services/email_processor_service.py
index c93cedb..3225b08 100644
--- a/app/services/email_processor_service.py
+++ b/app/services/email_processor_service.py
@@ -5,6 +5,7 @@ Based on OmniSync architecture adapted for BMC Hub
"""
import logging
+import re
from typing import List, Dict, Optional
from datetime import datetime
@@ -546,45 +547,68 @@ class EmailProcessorService:
if transcript:
transcripts.append(f"--- TRANSKRIBERET LYDFIL ({filename}) ---\n{transcript}\n----------------------------------")
- # Create conversation record
+ # Create conversation record (ALWAYS for supported audio, even if transcription fails)
+ try:
+ # Reconstruct path - mirroring EmailService._save_attachments logic
+ md5_hash = hashlib.md5(content).hexdigest()
+ # Default path in EmailService is "uploads/email_attachments"
+ file_path = f"uploads/email_attachments/{md5_hash}_{filename}"
+
+ # Determine Title from Subject if possible
+ title = f"Email Attachment: {filename}"
+ subject = email_data.get('subject', '')
+
+ # Pattern: "Optagelse af samtale(n) mellem 204 og 209"
+ # Handles both "samtale" and "samtalen", case insensitive
+ match = re.search(r'Optagelse af samtalen?\s+mellem\s+(\S+)\s+og\s+(\S+)', subject, re.IGNORECASE)
+ if match:
+ num1 = match.group(1)
+ num2 = match.group(2)
+ title = f"Samtale: {num1} ↔ {num2}"
+
+ # Generate Summary
+ summary = None
try:
- # Reconstruct path - mirroring EmailService._save_attachments logic
- md5_hash = hashlib.md5(content).hexdigest()
- # Default path in EmailService is "uploads/email_attachments"
- file_path = f"uploads/email_attachments/{md5_hash}_{filename}"
-
- # Determine user_id (optional, maybe from sender if internal?)
- # For now, create as system/unassigned
-
- query = """
- INSERT INTO conversations
- (title, transcript, audio_file_path, source, email_message_id, created_at)
- VALUES (%s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
- RETURNING id
- """
- conversation_id = execute_insert(query, (
- f"Email Attachment: {filename}",
- transcript,
- file_path,
- email_data.get('id')
- ))
-
- # Try to link to customer if we already know them?
- # ACTUALLY: We are BEFORE classification/domain matching.
- # Ideally, we should link later.
- # BUT, we can store the 'email_id' if we had a column.
- # I didn't add 'email_id' to conversations table.
- # I added customer_id and ticket_id.
- # Since this runs BEFORE those links are established, the conversation will be orphaned initially.
- # We could improve this by updating the conversation AFTER Step 5 (customer linking).
- # Or, simplified: The transcribed text is in the email body.
- # When the email is converted to a Ticket, the text follows.
- # But the 'Conversation' record is separate.
-
- logger.info(f"✅ Created conversation record {conversation_id} for {filename}")
-
+ from app.services.ollama_service import ollama_service
+ if transcript:
+ logger.info("🧠 Generating conversation summary...")
+ summary = await ollama_service.generate_summary(transcript)
except Exception as e:
- logger.error(f"❌ Failed to create conversation record: {e}")
+ logger.error(f"⚠️ Failed to generate summary: {e}")
+
+ # Determine user_id (optional, maybe from sender if internal?)
+ # For now, create as system/unassigned
+
+ query = """
+ INSERT INTO conversations
+ (title, transcript, summary, audio_file_path, source, email_message_id, created_at)
+ VALUES (%s, %s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
+ RETURNING id
+ """
+ conversation_id = execute_insert(query, (
+ title,
+ transcript,
+ summary,
+ file_path,
+ email_data.get('id')
+ ))
+
+ # Try to link to customer if we already know them?
+ # ACTUALLY: We are BEFORE classification/domain matching.
+ # Ideally, we should link later.
+ # BUT, we can store the 'email_id' if we had a column.
+ # I didn't add 'email_id' to conversations table.
+ # I added customer_id and ticket_id.
+ # Since this runs BEFORE those links are established, the conversation will be orphaned initially.
+ # We could improve this by updating the conversation AFTER Step 5 (customer linking).
+ # Or, simplified: The transcribed text is in the email body.
+ # When the email is converted to a Ticket, the text follows.
+ # But the 'Conversation' record is separate.
+
+ logger.info(f"✅ Created conversation record {conversation_id} for {filename}")
+
+ except Exception as e:
+ logger.error(f"❌ Failed to create conversation record: {e}")
if transcripts:
# Append to body
@@ -612,6 +636,33 @@ class EmailProcessorService:
email_data = result[0]
+ # Fetch attachments from DB to allow transcription on reprocess
+ query_att = "SELECT * FROM email_attachments WHERE email_id = %s"
+ atts = execute_query(query_att, (email_id,))
+
+ loaded_atts = []
+ if atts:
+ from pathlib import Path
+ for att in atts:
+ # 'file_path' is in DB
+ fpath = att.get('file_path')
+ if fpath:
+ try:
+ # If path is relative to cwd
+ path_obj = Path(fpath)
+ if path_obj.exists():
+ att['content'] = path_obj.read_bytes()
+ loaded_atts.append(att)
+ logger.info(f"📎 Loaded attachment content for reprocess: {att['filename']}")
+ except Exception as e:
+ logger.error(f"❌ Could not verify/load attachment {fpath}: {e}")
+
+ email_data['attachments'] = loaded_atts
+
+ # Run Transcription (Step 2.5 equivalent)
+ if settings.WHISPER_ENABLED and loaded_atts:
+ await self._process_attachments_for_transcription(email_data)
+
# Reclassify (either AI or keyword-based)
if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
diff --git a/app/services/email_service.py b/app/services/email_service.py
index a38bf3a..fb269c7 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -297,11 +297,22 @@ class EmailService:
continue
# Skip text parts (body content)
- if part.get_content_type() in ['text/plain', 'text/html']:
+ content_type = part.get_content_type()
+ if content_type in ['text/plain', 'text/html']:
continue
# Check if part has a filename (indicates attachment)
filename = part.get_filename()
+
+ # FALLBACK: If no filename but content-type is audio, generate one
+ if not filename and content_type.startswith('audio/'):
+ ext = '.mp3'
+ if 'wav' in content_type: ext = '.wav'
+ elif 'ogg' in content_type: ext = '.ogg'
+ elif 'm4a' in content_type: ext = '.m4a'
+ filename = f"audio_attachment{ext}"
+ logger.info(f"⚠️ Found audio attachment without filename. Generated: {filename}")
+
if filename:
# Decode filename if needed
filename = self._decode_header(filename)
@@ -412,14 +423,26 @@ class EmailService:
else:
content = b''
+ # Handle missing filenames for audio (FALLBACK)
+ filename = att.get('name')
+ content_type = att.get('contentType', 'application/octet-stream')
+
+ if not filename and content_type.startswith('audio/'):
+ ext = '.mp3'
+ if 'wav' in content_type: ext = '.wav'
+ elif 'ogg' in content_type: ext = '.ogg'
+ elif 'm4a' in content_type: ext = '.m4a'
+ filename = f"audio_attachment{ext}"
+ logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
+
attachments.append({
- 'filename': att.get('name', 'unknown'),
+ 'filename': filename or 'unknown',
'content': content,
- 'content_type': att.get('contentType', 'application/octet-stream'),
+ 'content_type': content_type,
'size': att.get('size', len(content))
})
- logger.info(f"📎 Fetched attachment: {att.get('name')} ({att.get('size', 0)} bytes)")
+ logger.info(f"📎 Fetched attachment: {filename} ({att.get('size', 0)} bytes)")
except Exception as e:
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
diff --git a/app/services/ollama_service.py b/app/services/ollama_service.py
index c3dc6b1..df84467 100644
--- a/app/services/ollama_service.py
+++ b/app/services/ollama_service.py
@@ -6,6 +6,7 @@ Handles supplier invoice extraction using Ollama LLM with CVR matching
import json
import hashlib
import logging
+import os
from pathlib import Path
from typing import Optional, Dict, List, Tuple
from datetime import datetime
@@ -593,6 +594,42 @@ Output: {
logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}")
return None
+ async def generate_summary(self, text: str) -> str:
+ """
+ Generate a short summary of the text using Ollama
+ """
+ if not text:
+ return ""
+
+ system_prompt = "Du er en hjælpsom assistent, der laver korte, præcise resuméer på dansk."
+ user_prompt = f"Lav et kort resumé (max 50 ord) af følgende tekst:\n\n{text}"
+
+ try:
+ import aiohttp
+
+ logger.info(f"🧠 Generating summary with Ollama ({self.model})...")
+
+ async with aiohttp.ClientSession() as session:
+ payload = {
+ "model": self.model,
+ "prompt": system_prompt + "\n\n" + user_prompt,
+ "stream": False,
+ "options": {"temperature": 0.3}
+ }
+ async with session.post(f"{self.endpoint}/api/generate", json=payload, timeout=60.0) as response:
+ if response.status == 200:
+ data = await response.json()
+ summary = data.get("response", "").strip()
+ logger.info("✅ Summary generated")
+ return summary
+ else:
+ error_text = await response.text()
+ logger.error(f"❌ Ollama error: {error_text}")
+ return "Kunne ikke generere resumé (API fejl)."
+
+ except Exception as e:
+ logger.error(f"❌ Ollama summary failed: {e}")
+ return f"Ingen resumé (Fejl: {str(e)})"
# Global instance
ollama_service = OllamaService()
diff --git a/app/services/simple_classifier.py b/app/services/simple_classifier.py
index 39e76f2..6aba10e 100644
--- a/app/services/simple_classifier.py
+++ b/app/services/simple_classifier.py
@@ -35,6 +35,10 @@ class SimpleEmailClassifier:
'case_notification': [
'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support'
],
+ 'recording': [
+ 'lydbesked', 'optagelse', 'voice note', 'voicemail',
+ 'telefonsvarer', 'samtale', 'recording', 'audio note'
+ ],
'bankruptcy': [
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
'betalingsstandsning', 'administration'
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 9c19bfb..c3f8b98 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -251,6 +251,8 @@
Ordre
Produkter
+
Webshop Administration
+
Pipeline
diff --git a/docs/WEBSHOP_FRONTEND_PROMPT.md b/docs/WEBSHOP_FRONTEND_PROMPT.md
new file mode 100644
index 0000000..13415a3
--- /dev/null
+++ b/docs/WEBSHOP_FRONTEND_PROMPT.md
@@ -0,0 +1,241 @@
+# GitHub Copilot Instructions - BMC Webshop (Frontend)
+
+## Project Overview
+
+BMC Webshop er en kunde-styret webshop løsning, hvor **BMC Hub** ejer indholdet, **API Gateway** (`apigateway.bmcnetworks.dk`) styrer logikken, og **Webshoppen** (dette projekt) kun viser og indsamler input.
+
+**Tech Stack**: React/Next.js/Vue.js (vælg én), TypeScript, Tailwind CSS eller Bootstrap 5
+
+---
+
+## 3-Lags Arkitektur
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ TIER 1: BMC HUB (Admin System) │
+│ - Administrerer webshop-opsætning │
+│ - Pusher data til Gateway │
+│ - Poller Gateway for nye ordrer │
+│ https://hub.bmcnetworks.dk │
+└─────────────────────────────────────────────────────────┘
+ ▼ (Push config)
+┌─────────────────────────────────────────────────────────┐
+│ TIER 2: API GATEWAY (Forretningslogik + Database) │
+│ - Modtager og gemmer webshop-config fra Hub │
+│ - Ejer PostgreSQL database med produkter, priser, ordrer│
+│ - Håndterer email/OTP login │
+│ - Beregner priser og filtrerer varer │
+│ - Leverer sikre API'er til Webshoppen │
+│ https://apigateway.bmcnetworks.dk │
+└─────────────────────────────────────────────────────────┘
+ ▲ (API calls)
+┌─────────────────────────────────────────────────────────┐
+│ TIER 3: WEBSHOP (Dette projekt - Kun Frontend) │
+│ - Viser logo, tekster, produkter, priser │
+│ - Shopping cart (kun i frontend state) │
+│ - Sender ordre som payload til Gateway │
+│ - INGEN forretningslogik eller datapersistering │
+└─────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Webshoppens Ansvar
+
+### ✅ Hvad Webshoppen GØR
+- Viser kundens logo, header-tekst, intro-tekst (fra Gateway)
+- Viser produktkatalog med navn, beskrivelse, pris (fra Gateway)
+- Samler kurv i browser state (localStorage/React state)
+- Sender ordre til Gateway ved checkout
+- Email/OTP login flow (kalder Gateway's auth-endpoint)
+
+### ❌ Hvad Webshoppen IKKE GØR
+- Gemmer INGEN data (hverken kurv, produkter, eller ordrer)
+- Beregner INGEN priser eller avance
+- Håndterer INGEN produkt-filtrering (Gateway leverer klar liste)
+- Snakker IKKE direkte med Hub eller e-conomic
+- Håndterer IKKE betalingsgateway (Gateway's ansvar)
+
+---
+
+## API Gateway Kontrakt
+
+Base URL: `https://apigateway.bmcnetworks.dk`
+
+### 1. Login med Email + Engangskode
+
+**Step 1: Anmod om engangskode**
+```http
+POST /webshop/auth/request-code
+Content-Type: application/json
+
+{
+ "email": "kunde@firma.dk"
+}
+
+Response 200:
+{
+ "success": true,
+ "message": "Engangskode sendt til kunde@firma.dk"
+}
+```
+
+**Step 2: Verificer kode og få JWT token**
+```http
+POST /webshop/auth/verify-code
+Content-Type: application/json
+
+{
+ "email": "kunde@firma.dk",
+ "code": "123456"
+}
+
+Response 200:
+{
+ "success": true,
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "customer_id": 42,
+ "expires_at": "2026-01-13T15:00:00Z"
+}
+```
+
+### 2. Hent Webshop Context (Komplet Webshop-Data)
+
+```http
+GET /webshop/{customer_id}/context
+Authorization: Bearer {jwt_token}
+
+Response 200:
+{
+ "customer_id": 42,
+ "company_name": "Advokatfirma A/S",
+ "config_version": "2026-01-13T12:00:00Z",
+ "branding": {
+ "logo_url": "https://apigateway.bmcnetworks.dk/assets/logos/42.png",
+ "header_text": "Velkommen til vores webshop",
+ "intro_text": "Bestil nemt og hurtigt direkte her.",
+ "primary_color": "#0f4c75",
+ "accent_color": "#3282b8"
+ },
+ "products": [
+ {
+ "id": 101,
+ "ean": "5711045071324",
+ "product_number": "FIRE-001",
+ "name": "Cisco Firewall ASA 5506-X",
+ "description": "Next-generation firewall med 8 porte",
+ "unit": "stk",
+ "base_price": 8500.00,
+ "calculated_price": 9350.00,
+ "margin_percent": 10.0,
+ "currency": "DKK",
+ "stock_available": true,
+ "category": "Network Security"
+ }
+ ],
+ "allowed_payment_methods": ["invoice", "card"],
+ "min_order_amount": 500.00,
+ "shipping_cost": 0.00
+}
+```
+
+### 3. Opret Ordre
+
+```http
+POST /webshop/orders
+Authorization: Bearer {jwt_token}
+Content-Type: application/json
+
+{
+ "customer_id": 42,
+ "order_items": [
+ {
+ "product_id": 101,
+ "quantity": 2,
+ "unit_price": 9350.00
+ }
+ ],
+ "shipping_address": {
+ "company_name": "Advokatfirma A/S",
+ "street": "Hovedgaden 1",
+ "postal_code": "1000",
+ "city": "København K",
+ "country": "DK"
+ },
+ "delivery_note": "Levering til bagsiden, ring på døren",
+ "total_amount": 18700.00
+}
+
+Response 201:
+{
+ "success": true,
+ "order_id": "ORD-2026-00123",
+ "status": "pending",
+ "total_amount": 18700.00,
+ "created_at": "2026-01-13T14:30:00Z",
+ "message": "Ordre modtaget. Du vil modtage en bekræftelse på email."
+}
+```
+
+---
+
+## Frontend Krav
+
+### Mandatory Features
+
+1. **Responsive Design**
+ - Mobile-first approach
+ - Breakpoints: 576px (mobile), 768px (tablet), 992px (desktop)
+
+2. **Dark Mode Support**
+ - Toggle mellem light/dark theme
+ - Gem præference i localStorage
+
+3. **Shopping Cart**
+ - Gem kurv i localStorage (persist ved page reload)
+ - Vis antal varer i header badge
+ - Real-time opdatering af total pris
+
+4. **Login Flow**
+ - Email input → Send kode
+ - Vis countdown timer (5 minutter)
+ - Verificer kode → Få JWT token
+ - Auto-logout ved token expiry
+
+5. **Product Catalog**
+ - Vis produkter i grid layout
+ - Søgning i produktnavn/beskrivelse
+ - "Tilføj til kurv" knap
+
+6. **Checkout Flow**
+ - Vis kurv-oversigt
+ - Leveringsadresse
+ - "Bekræft ordre" knap
+ - Success/error feedback
+
+### Design Guidelines
+
+**Stil**: Minimalistisk, clean, "Nordic" æstetik
+
+**Farver** (kan overskrives af Gateway's branding):
+- Primary: `#0f4c75` (Deep Blue)
+- Accent: `#3282b8` (Bright Blue)
+
+---
+
+## Security
+
+1. **HTTPS Only** - Al kommunikation med Gateway over HTTPS
+2. **JWT Token** - Gem i localStorage, send i Authorization header
+3. **Input Validation** - Validér email, antal, adresse
+4. **CORS** - Gateway skal have CORS headers
+
+---
+
+## Common Pitfalls to Avoid
+
+1. **Gem IKKE data i Webshoppen** - alt kommer fra Gateway
+2. **Beregn IKKE priser selv** - Gateway leverer `calculated_price`
+3. **Snakker IKKE direkte med Hub** - kun via Gateway
+4. **Gem IKKE kurv i database** - kun localStorage
+5. **Hardcode IKKE customer_id** - hent fra JWT token
diff --git a/main.py b/main.py
index 5650a28..27fce3b 100644
--- a/main.py
+++ b/main.py
@@ -54,6 +54,10 @@ from app.backups.backend.scheduler import backup_scheduler
from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views
+# Modules
+from app.modules.webshop.backend import router as webshop_api
+from app.modules.webshop.frontend import views as webshop_views
+
# Configure logging
logging.basicConfig(
level=logging.INFO,
@@ -131,6 +135,9 @@ app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
+# Module Routers
+app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
+
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
@@ -145,6 +152,7 @@ app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"])
+app.include_router(webshop_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/068_conversations_module.sql b/migrations/068_conversations_module.sql
index 11e3f44..e7b819c 100644
--- a/migrations/068_conversations_module.sql
+++ b/migrations/068_conversations_module.sql
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
- user_id INTEGER REFERENCES auth_users(id) ON DELETE SET NULL,
+ user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
email_message_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
diff --git a/scripts/generate_summary_for_conversation.py b/scripts/generate_summary_for_conversation.py
new file mode 100644
index 0000000..5ed57ce
--- /dev/null
+++ b/scripts/generate_summary_for_conversation.py
@@ -0,0 +1,38 @@
+
+import asyncio
+import os
+import sys
+import logging
+
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+from app.core.database import init_db, execute_query, execute_update
+from app.services.ollama_service import ollama_service
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+async def main(conv_id):
+ init_db()
+
+ # Get transcript
+ rows = execute_query("SELECT transcript FROM conversations WHERE id = %s", (conv_id,))
+ if not rows or not rows[0]['transcript']:
+ print("No transcript found")
+ return
+
+ transcript = rows[0]['transcript']
+ print(f"Transcript length: {len(transcript)}")
+
+ # Override endpoint for Docker Mac access
+ ollama_service.endpoint = os.environ.get("OLLAMA_ENDPOINT", "http://host.docker.internal:11434")
+ print(f"Using Ollama endpoint: {ollama_service.endpoint}")
+
+ summary = await ollama_service.generate_summary(transcript)
+ print(f"Summary generated: {summary}")
+
+ execute_update("UPDATE conversations SET summary = %s WHERE id = %s", (summary, conv_id))
+ print("Database updated")
+
+if __name__ == "__main__":
+ asyncio.run(main(1))
diff --git a/scripts/test_whisper_capabilities.py b/scripts/test_whisper_capabilities.py
new file mode 100644
index 0000000..c1fd15e
--- /dev/null
+++ b/scripts/test_whisper_capabilities.py
@@ -0,0 +1,61 @@
+
+import aiohttp
+import asyncio
+import os
+import json
+
+async def test_whisper_variant(session, url, file_path, params=None, form_fields=None, description=""):
+ print(f"\n--- Testing: {description} ---")
+ try:
+ data = aiohttp.FormData()
+ # Re-open file for each request to ensure pointer is at start
+ data.add_field('file', open(file_path, 'rb'), filename='rec.mp3')
+
+ if form_fields:
+ for k, v in form_fields.items():
+ data.add_field(k, str(v))
+
+ async with session.post(url, data=data, params=params) as response:
+ print(f"Status: {response.status}")
+ if response.status == 200:
+ try:
+ text_content = await response.text()
+ try:
+ result = json.loads(text_content)
+ # Print keys to see if we got something new
+ if isinstance(result, dict):
+ print("Keys:", result.keys())
+ if 'results' in result and len(result['results']) > 0:
+ print("Result[0] keys:", result['results'][0].keys())
+ if 'segments' in result:
+ print("FOUND SEGMENTS!")
+ print("Raw (truncated):", text_content[:300])
+ except:
+ print("Non-JSON Response:", text_content[:300])
+ except Exception as e:
+ print(f"Reading error: {e}")
+ else:
+ print("Error:", await response.text())
+ except Exception as e:
+ print(f"Exception: {e}")
+
+async def test_whisper():
+ url = "http://172.16.31.115:5000/transcribe"
+ file_path = "uploads/email_attachments/65d2ca781a6bf3cee9cee0a8ce80acac_rec.mp3"
+
+ if not os.path.exists(file_path):
+ print(f"File not found: {file_path}")
+ return
+
+ async with aiohttp.ClientSession() as session:
+ # Variant 1: 'timestamps': 'true' as form field
+ await test_whisper_variant(session, url, file_path, form_fields={'timestamps': 'true'}, description="Form: timestamps=true")
+
+ # Variant 2: 'response_format': 'verbose_json' (OpenAI style)
+ await test_whisper_variant(session, url, file_path, form_fields={'response_format': 'verbose_json'}, description="Form: verbose_json")
+
+ # Variant 3: 'verbose': 'true'
+ await test_whisper_variant(session, url, file_path, form_fields={'verbose': 'true'}, description="Form: verbose=true")
+
+if __name__ == "__main__":
+ asyncio.run(test_whisper())