diff --git a/.env.example b/.env.example index a808ac3..8d73e6c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ API_HOST=0.0.0.0 API_PORT=8001 # Changed from 8000 to avoid conflicts with other services ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker) +# FirmaAPI (CVR company lookup) +FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 +FIRMAAPI_API_KEY= +FIRMAAPI_TIMEOUT_SECONDS=12 + # ===================================================== # SECURITY # ===================================================== @@ -69,6 +74,20 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300 # Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" NEXTCLOUD_ENCRYPTION_KEY= +# ===================================================== +# Links / Endpoints Module (Optional) +# ===================================================== +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 +LINKS_CHECK_TIMEOUT_SECONDS=5 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= + # ===================================================== # vTiger Cloud Integration (Required for Subscriptions) # ===================================================== diff --git a/.env.prod.example b/.env.prod.example index 5f8a61f..ebbad72 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -44,6 +44,11 @@ API_HOST=0.0.0.0 API_PORT=8000 API_RELOAD=false +# FirmaAPI (CVR company lookup) +FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 +FIRMAAPI_API_KEY= +FIRMAAPI_TIMEOUT_SECONDS=12 + # ===================================================== # SECURITY - Production # ===================================================== @@ -76,3 +81,18 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here # VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer pΓ₯ samme linje ECONOMIC_READ_ONLY=true ECONOMIC_DRY_RUN=true + +# ===================================================== +# Links / Endpoints Module - Production (Optional) +# ===================================================== +# Start disabled; enable after migration + validation +LINKS_MODULE_ENABLED=false +LINKS_READ_ONLY=true +LINKS_DRY_RUN=true +LINKS_DEAD_LINK_CHECK_ENABLED=true +LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 +LINKS_CHECK_TIMEOUT_SECONDS=5 + +# Vaultwarden (Bitwarden-compatible) +VAULTWARDEN_BASE_URL= +VAULTWARDEN_API_TOKEN= diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7f060f8..73899a4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -107,6 +107,23 @@ if settings.ECONOMIC_READ_ONLY: logger.warning("Read-only mode") ``` +### Migration Validation +```bash +# Validate root migrations against current PostgreSQL schema +python scripts/validate_migrations.py + +# Include module-specific migration directory in validation +python scripts/validate_migrations.py --module app/modules/sag/migrations + +# Machine-readable report and strict index validation +python scripts/validate_migrations.py --json --strict-indexes +``` + +Exit codes: +- `0`: Schema is aligned, or only index differences were found without strict mode. +- `1`: Schema mismatches were found (missing tables/columns, or missing indexes with strict mode). +- `2`: Runtime error (for example connection/configuration issues). + ## 🐳 Docker Commands ```bash diff --git a/Dockerfile b/Dockerfile index 42d78ca..907e764 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \ curl \ git \ libpq-dev \ + libzbar0 \ gcc \ g++ \ python3-dev \ diff --git a/add_css.py b/add_css.py new file mode 100644 index 0000000..650d2d8 --- /dev/null +++ b/add_css.py @@ -0,0 +1,142 @@ +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + text = f.read() + +css_start = text.find(' +{% endblock %} + +{% block content %} +
+ + + + + +
+
Sessions i alt
–
+
Uden sag/kontakt
–
+
Total tid (min)
–
+
Unikke enheder
–
+
+ + +
+ + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + +
TidspunktVarighedRemote IDTeknikkerHardwareKundeKontaktSagStatus
IndlΓ¦ser…
+
+ +
+ + +
+ + +
+ + +
+
+
+ Session + +
+
+ +
+ + +
+
+ Tidspunkt + – +
+
+ Varighed + – +
+
+ Maskine-ID + – +
+
+ Teknikker + +
+
+ + +
+ + + + + + + + +
+ + + + +
+ + +
+ + + + +
+ + +
+ + + + +
+ + +
+ +
+ +
+
+ +
+ + +
+ + +
+
+ + + +
+ + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index 109cb63..5071f7a 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -7,6 +7,7 @@ from typing import Optional from app.core.auth_service import AuthService from app.core.config import settings from app.core.auth_dependencies import get_current_user +from app.core.database import execute_query import logging logger = logging.getLogger(__name__) @@ -207,3 +208,101 @@ async def disable_2fa( ) return {"message": "2FA disabled"} + + +# ─── User Profile ───────────────────────────────────────────────────────────── + +class UserProfileUpdate(BaseModel): + full_name: Optional[str] = None + phone: Optional[str] = None + title: Optional[str] = None + anydesk_id: Optional[str] = None + + +@router.get("/me/profile") +async def get_my_profile(current_user: dict = Depends(get_current_user)): + """Get current user's extended profile fields""" + rows = execute_query( + "SELECT full_name, phone, title, anydesk_id FROM users WHERE user_id = %s", + (current_user["id"],) + ) + if not rows: + raise HTTPException(status_code=404, detail="User not found") + return dict(rows[0]) + + +@router.patch("/me/profile") +async def update_my_profile( + payload: UserProfileUpdate, + current_user: dict = Depends(get_current_user) +): + """Update current user's profile fields""" + fields = [] + values = [] + + if payload.full_name is not None: + fields.append("full_name = %s") + values.append(payload.full_name.strip() or None) + if payload.phone is not None: + fields.append("phone = %s") + values.append(payload.phone.strip() or None) + if payload.title is not None: + fields.append("title = %s") + values.append(payload.title.strip() or None) + if payload.anydesk_id is not None: + fields.append("anydesk_id = %s") + values.append(payload.anydesk_id.strip() or None) + + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + + fields.append("updated_at = NOW()") + values.append(current_user["id"]) + + execute_query( + f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", + tuple(values) + ) + return {"message": "Profil opdateret"} + + +# ─── User AnyDesk IDs (multiple per technician) ─────────────────────────────── + +class AnyDeskIdAdd(BaseModel): + anydesk_id: str + label: Optional[str] = None + + +@router.get("/me/anydesk-ids") +async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)): + rows = execute_query( + "SELECT id, anydesk_id, label, created_at FROM user_anydesk_ids WHERE user_id = %s ORDER BY created_at", + (current_user["id"],) + ) + return {"ids": [dict(r) for r in (rows or [])]} + + +@router.post("/me/anydesk-ids", status_code=201) +async def add_my_anydesk_id(payload: AnyDeskIdAdd, current_user: dict = Depends(get_current_user)): + ad_id = payload.anydesk_id.strip() + if not ad_id: + raise HTTPException(status_code=400, detail="anydesk_id cannot be empty") + try: + execute_query( + "INSERT INTO user_anydesk_ids (user_id, anydesk_id, label) VALUES (%s, %s, %s)", + (current_user["id"], ad_id, payload.label or None) + ) + except Exception: + raise HTTPException(status_code=409, detail="AnyDesk ID allerede tilfΓΈjet") + return {"message": "TilfΓΈjet"} + + +@router.delete("/me/anydesk-ids/{entry_id}") +async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_current_user)): + rows = execute_query( + "DELETE FROM user_anydesk_ids WHERE id = %s AND user_id = %s RETURNING id", + (entry_id, current_user["id"]) + ) + if not rows: + raise HTTPException(status_code=404, detail="Ikke fundet") + return {"message": "Slettet"} diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html index e0c4331..726422a 100644 --- a/app/contacts/frontend/contacts.html +++ b/app/contacts/frontend/contacts.html @@ -4,6 +4,53 @@ {% block extra_css %} {% endblock %} {% block content %} -
+

Kontakter

Administrer kontaktpersoner

-
- - +
+
+ + +
+
@@ -81,9 +377,9 @@
-
-
- +
+
+
@@ -123,7 +419,7 @@ - '; + + if (currentRequestController) { + currentRequestController.abort(); + } + currentRequestController = new AbortController(); try { // Build query parameters @@ -343,8 +703,14 @@ async function loadContacts() { } else if (currentFilter === 'inactive') { params.append('is_active', 'false'); } + + const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`; + if (queryKey === lastLoadedQueryKey) { + return; + } + lastLoadedQueryKey = queryKey; - const response = await fetch(`/api/v1/contacts?${params}`); + const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal }); const data = await response.json(); totalContacts = data.total; @@ -352,11 +718,20 @@ async function loadContacts() { updatePagination(data.total); } catch (error) { + if (error.name === 'AbortError') { + return; + } console.error('Failed to load contacts:', error); tbody.innerHTML = ''; + } finally { + currentRequestController = null; } } +function toggleClearButton(value) { + document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value); +} + function displayContacts(contacts) { const tbody = document.getElementById('contactsTableBody'); @@ -367,9 +742,9 @@ function displayContacts(contacts) { tbody.innerHTML = contacts.map(contact => { const initials = getInitials(contact.first_name, contact.last_name); - const statusBadge = contact.is_active - ? 'Aktiv' - : 'Inaktiv'; + const statusBadge = contact.is_active + ? 'Aktiv' + : 'Inaktiv'; const companyCount = contact.company_count || 0; const companyNames = contact.company_names || []; @@ -389,36 +764,41 @@ function displayContacts(contacts) { ` : ''; const smsLine = mobileLine || phoneLine; + const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-'); + const safeDepartment = escapeHtml(contact.department || '-'); + const safeEmail = escapeHtml(contact.email || '-'); + const safeTitle = escapeHtml(contact.title || '-'); + const companiesTitle = escapeHtml(companyNames.join(', ')); return ` - + - + + + + + + + + + `; + }).join(''); + + container.innerHTML = ` +
Navn
Kunne ikke indlæse kontakter
${initials}
-
${escapeHtml(contact.first_name + ' ' + contact.last_name)}
-
${contact.department || '-'}
+
${safeName}
+
${safeDepartment}
-
${contact.email || '-'}
- ${smsLine} +
${safeEmail}
+
${smsLine}
${contact.title || '-'}${safeTitle} - - ${companyCount} + + ${companyCount} - ${companyDisplay !== '-' ? '
' + companyDisplay + '
' : ''} + ${companyDisplay !== '-' ? '
' + escapeHtml(companyDisplay) + '
' : ''}
${statusBadge}
- -
@@ -580,20 +960,88 @@ async function loadCompaniesForSelect() { try { const response = await fetch('/api/v1/customers?limit=1000'); const data = await response.json(); - - const select = document.getElementById('companySelect'); - select.innerHTML = data.customers.map(c => - `` - ).join(''); + + availableCompanies = Array.isArray(data.customers) + ? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() })) + : []; + renderCompanyResults(document.getElementById('companySearchInput')?.value || ''); + renderSelectedCompanies(); } catch (error) { console.error('Failed to load companies:', error); } } +function renderCompanyResults(query) { + const host = document.getElementById('companyResults'); + if (!host) return; + + const needle = String(query || '').trim().toLowerCase(); + let list = availableCompanies; + if (needle) { + list = availableCompanies.filter((c) => c.name.toLowerCase().includes(needle)); + } + + list = list.slice(0, 80); + + if (!list.length) { + host.innerHTML = '
Ingen firmaer fundet
'; + return; + } + + host.innerHTML = list.map((c) => { + const selected = selectedCompanyIds.has(c.id); + return ` + + `; + }).join(''); +} + +function toggleCompanySelection(companyId) { + const id = Number(companyId); + if (!Number.isFinite(id)) return; + + if (selectedCompanyIds.has(id)) { + selectedCompanyIds.delete(id); + } else { + selectedCompanyIds.add(id); + } + + renderSelectedCompanies(); + renderCompanyResults(document.getElementById('companySearchInput')?.value || ''); +} + +function renderSelectedCompanies() { + const host = document.getElementById('selectedCompanies'); + if (!host) return; + + const selected = availableCompanies.filter((c) => selectedCompanyIds.has(c.id)); + if (!selected.length) { + host.innerHTML = 'Ingen firmaer valgt'; + return; + } + + host.innerHTML = selected.map((c) => ` + + ${escapeHtml(c.name)} + + + `).join(''); +} + function showCreateContactModal() { // Reset form document.getElementById('createContactForm').reset(); document.getElementById('isActiveInput').checked = true; + selectedCompanyIds = new Set(); + const companySearchInput = document.getElementById('companySearchInput'); + if (companySearchInput) { + companySearchInput.value = ''; + } + renderCompanyResults(''); + renderSelectedCompanies(); // Show modal const modal = new bootstrap.Modal(document.getElementById('createContactModal')); @@ -610,8 +1058,7 @@ async function createContact() { } // Get selected company IDs - const companySelect = document.getElementById('companySelect'); - const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value)); + const companyIds = Array.from(selectedCompanyIds); const contactData = { first_name: firstName, diff --git a/app/core/config.py b/app/core/config.py index c624aa4..3b4206b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -30,6 +30,11 @@ class Settings(BaseSettings): APIGATEWAY_URL: str = "" APIGW_TOKEN: str = "" APIGW_TIMEOUT_SECONDS: int = 12 + + # FirmaAPI (CVR company data) + FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1" + FIRMAAPI_API_KEY: str = "" + FIRMAAPI_TIMEOUT_SECONDS: int = 12 # Security SECRET_KEY: str = "dev-secret-key-change-in-production" @@ -70,6 +75,18 @@ class Settings(BaseSettings): NEXTCLOUD_CACHE_TTL_SECONDS: int = 300 NEXTCLOUD_ENCRYPTION_KEY: str = "" + # Links / Endpoints Module + LINKS_MODULE_ENABLED: bool = False + LINKS_READ_ONLY: bool = True + LINKS_DRY_RUN: bool = True + LINKS_DEAD_LINK_CHECK_ENABLED: bool = True + LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60 + LINKS_CHECK_TIMEOUT_SECONDS: int = 5 + + # Vaultwarden (Bitwarden-compatible) + VAULTWARDEN_BASE_URL: str = "" + VAULTWARDEN_API_TOKEN: str = "" + # Wiki.js Integration WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk" WIKI_API_TOKEN: str = "" @@ -227,9 +244,10 @@ class Settings(BaseSettings): REMINDERS_QUEUE_BATCH_SIZE: int = 10 # AnyDesk Remote Support Integration + ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL ANYDESK_LICENSE_ID: str = "" - ANYDESK_API_TOKEN: str = "" - ANYDESK_PASSWORD: str = "" + ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com + ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls ANYDESK_TIMEOUT_SECONDS: int = 30 diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 4514570..58be2c4 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -12,7 +12,7 @@ import asyncio import aiohttp from urllib.parse import quote -from app.core.database import execute_query, execute_query_single, execute_update +from app.core.database import execute_query, execute_query_single, execute_update, execute_insert from app.core.config import settings from app.services.cvr_service import get_cvr_service from app.services.customer_activity_logger import CustomerActivityLogger @@ -81,7 +81,8 @@ async def list_customers( offset: int = Query(default=0, ge=0), search: Optional[str] = Query(default=None), source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None - is_active: Optional[bool] = Query(default=None) + is_active: Optional[bool] = Query(default=None), + vip: Optional[bool] = Query(default=None) ): """ List customers with pagination and filtering @@ -137,6 +138,19 @@ async def list_customers( if is_active is not None: query += " AND c.is_active = %s" params.append(is_active) + + # Add VIP filter (customer tagged with "vip") + if vip is True: + query += """ + AND EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'customer' + AND et.entity_id = c.id + AND LOWER(t.name) = 'vip' + ) + """ query += """ GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile @@ -169,6 +183,18 @@ async def list_customers( if is_active is not None: count_query += " AND is_active = %s" count_params.append(is_active) + + if vip is True: + count_query += """ + AND EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'customer' + AND et.entity_id = customers.id + AND LOWER(t.name) = 'vip' + ) + """ count_result = execute_query_single(count_query, tuple(count_params)) total = count_result['total'] if count_result else 0 diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 31d8b91..f51c304 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -245,6 +245,9 @@ + +
+ +
+ + + + + + + + + + + + + + + + +
SagsIDTitelStatusPrioritetOprettetHandling
+
+
+
+
+ Ingen sager fundet for denne kunde +
+
+
@@ -748,6 +803,42 @@
+ + +
{% include "modules/nextcloud/templates/tab.html" %} @@ -1210,6 +1301,11 @@ let customerKontaktFilter = 'all'; let eventListenersAdded = false; +function getAuthHeaders() { + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + document.addEventListener('DOMContentLoaded', () => { if (eventListenersAdded) { console.log('Event listeners already added, skipping...'); @@ -1226,6 +1322,13 @@ document.addEventListener('DOMContentLoaded', () => { }, { once: false }); } + const casesTab = document.querySelector('a[href="#cases"]'); + if (casesTab) { + casesTab.addEventListener('shown.bs.tab', () => { + loadCustomerCases(); + }, { once: false }); + } + const kontaktTab = document.querySelector('a[href="#kontakt"]'); if (kontaktTab) { kontaktTab.addEventListener('shown.bs.tab', () => { @@ -1265,6 +1368,13 @@ document.addEventListener('DOMContentLoaded', () => { loadCustomerHardware(); }, { once: false }); } + + const linksTab = document.querySelector('a[href="#links"]'); + if (linksTab) { + linksTab.addEventListener('shown.bs.tab', () => { + loadCustomerLinks(); + }, { once: false }); + } // Load activity when tab is shown const activityTab = document.querySelector('a[href="#activity"]'); @@ -2315,6 +2425,107 @@ async function loadContacts() { } } +async function loadCustomerCases() { + const container = document.getElementById('customerCasesContainer'); + const empty = document.getElementById('customerCasesEmpty'); + + if (!container || !empty) { + return; + } + + container.classList.remove('d-none'); + empty.classList.add('d-none'); + + container.innerHTML = ` + + + + + + + + + + + + + + + + +
SagsIDTitelStatusPrioritetOprettetHandling
+
+
+ `; + + try { + const response = await fetch(`/api/v1/sag?customer_id=${customerId}`); + const cases = await response.json(); + + if (!response.ok) { + throw new Error(cases?.detail || 'Kunne ikke hente kundens sager'); + } + + const list = Array.isArray(cases) ? cases : []; + + if (!list.length) { + container.classList.add('d-none'); + empty.classList.remove('d-none'); + return; + } + + const rows = list.map((item) => { + const id = Number(item.id) || 0; + const title = escapeHtml(item.titel || '-'); + const statusRaw = String(item.status || 'ukendt'); + const statusLabel = escapeHtml(statusRaw); + const priority = escapeHtml(item.priority || 'normal'); + const created = item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '-'; + + const statusClass = + statusRaw.toLowerCase() === 'lukket' ? 'bg-success-subtle text-success-emphasis' : + statusRaw.toLowerCase() === 'afventer' ? 'bg-warning-subtle text-warning-emphasis' : + 'bg-primary-subtle text-primary-emphasis'; + + return ` +
#${id}${title}${statusLabel}${priority}${created} + + + +
+ + + + + + + + + + + + ${rows} + +
SagsIDTitelStatusPrioritetOprettetHandling
+ `; + } catch (error) { + console.error('Failed to load customer cases:', error); + container.innerHTML = `
${escapeHtml(error.message || 'Fejl ved hentning af sager')}
`; + } +} + let subscriptionsLoaded = false; async function loadSubscriptions() { @@ -2376,6 +2587,7 @@ async function loadCustomerPipeline() { let customerHardware = []; let hardwareLocationsById = {}; +let customerLinks = []; function getHardwareGroupLabel(item, groupBy) { if (groupBy === 'location') { @@ -2548,6 +2760,109 @@ document.addEventListener('change', (event) => { } }); +function renderCustomerLinksTable() { + const container = document.getElementById('customerLinksContainer'); + const empty = document.getElementById('customerLinksEmpty'); + if (!container || !empty) return; + + if (!customerLinks.length) { + container.classList.add('d-none'); + empty.classList.remove('d-none'); + return; + } + + container.classList.remove('d-none'); + empty.classList.add('d-none'); + + container.innerHTML = ` + + + + + + + + + + + + ${customerLinks.map((link) => { + const type = (link.type || 'http').toUpperCase(); + const target = link.url || link.host || '-'; + const environment = link.environment || 'prod'; + return ` + + + + + + + + `; + }).join('')} + +
NavnTypeMΓ₯lMiljΓΈHandling
${escapeHtml(link.name || 'Uden navn')}${escapeHtml(type)}${escapeHtml(target)}${escapeHtml(environment)} + + + +
+ `; +} + +async function loadCustomerLinks() { + const container = document.getElementById('customerLinksContainer'); + const empty = document.getElementById('customerLinksEmpty'); + if (!container || !empty) return; + + container.classList.remove('d-none'); + empty.classList.add('d-none'); + container.innerHTML = ` + + + + + + + + + + + + + + + +
NavnTypeMΓ₯lMiljΓΈHandling
+ `; + + try { + const response = await fetch(`/api/v1/links?customer_id=${customerId}`, { + headers: { + ...getAuthHeaders() + }, + credentials: 'include' + }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('Ingen adgang til links. Log ind igen eller tjek links.read permission.'); + } + if (response.status === 404) { + throw new Error('Links-endpoint ikke fundet (modul ikke aktivt eller API ikke genstartet).'); + } + throw new Error('Kunne ikke hente links'); + } + + const links = await response.json(); + customerLinks = Array.isArray(links) ? links : []; + renderCustomerLinksTable(); + } catch (error) { + console.error('Failed to load customer links:', error); + container.classList.add('d-none'); + empty.classList.remove('d-none'); + empty.textContent = error.message || 'Kunne ikke hente links for kunden'; + } +} + function renderCustomerPipeline(opportunities) { const tbody = document.getElementById('customerOpportunitiesTable'); if (!opportunities || opportunities.length === 0) { diff --git a/app/customers/frontend/customers.html b/app/customers/frontend/customers.html index d6ae2ea..bfc1161 100644 --- a/app/customers/frontend/customers.html +++ b/app/customers/frontend/customers.html @@ -4,6 +4,53 @@ {% block extra_css %} {% endblock %} {% block content %} -
+

Kunder

Administrer dine kunder

-
- - +
+
+ + +
+
- - - - + + + +
@@ -73,55 +150,391 @@
+ + +{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 5186405..1ac3997 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -4,20 +4,23 @@ import shutil import json import re import hashlib +import base64 +import html from pathlib import Path -from datetime import datetime -from typing import List, Optional +from datetime import datetime, timedelta +from typing import List, Optional, Dict from uuid import uuid4 -from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request -from fastapi.responses import FileResponse +from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response +from fastapi.responses import FileResponse, HTMLResponse from pydantic import BaseModel, Field -from app.core.database import execute_query, execute_query_single +from app.core.database import execute_query, execute_query_single, table_has_column from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.core.config import settings from app.services.email_service import EmailService from app.services.case_analysis_service import CaseAnalysisService from app.services.ollama_service import ollama_service +from app.services.brother_label_print_service import BrotherLabelPrintService, LabelJob try: import extract_msg @@ -191,6 +194,18 @@ class SagSendEmailRequest(BaseModel): thread_key: Optional[str] = None +class SignatureCanvasRequest(BaseModel): + data_url: str = Field(..., min_length=32) + + +class DirectPrintOverrideRequest(BaseModel): + printer_host: Optional[str] = None + printer_port: Optional[int] = None + printer_model: Optional[str] = None + label_size: Optional[str] = None + hardware_id: Optional[int] = None + + def _normalize_email_list(values: List[str], field_name: str) -> List[str]: cleaned: List[str] = [] for value in values or []: @@ -234,6 +249,130 @@ def _derive_thread_key_for_outbound( return _normalize_message_id_token(generated_message_id) +def _generate_local_thread_key_for_new_outbound(sag_id: int) -> str: + """Generate a stable local thread key for brand-new case emails. + + This prevents fallback BMCid tags like sXXt001 that cannot be mapped back + to a concrete thread later. + """ + nonce = uuid4().hex[:10] + return f"sag{sag_id}-{int(datetime.now().timestamp())}-{nonce}@bmchub.local" + + +def _build_scan_token(sag_id: int, token_type: str) -> str: + if token_type == "work_order": + return f"BMCSCAN-WO-S{sag_id}-{uuid4().hex[:10].upper()}" + + # Keep hardware label tokens shorter so Code39 labels stay physically compact. + return f"BMCSCAN-HW-{sag_id}-{uuid4().hex[:6].upper()}" + + +def _create_document_token( + sag_id: int, + token_type: str, + user_id: Optional[int] = None, + hardware_id: Optional[int] = None, +) -> str: + token = _build_scan_token(sag_id, token_type) + expires_at = datetime.now() + timedelta(days=30) + + execute_query( + """ + INSERT INTO sag_document_tokens ( + sag_id, + token, + token_type, + hardware_id, + created_by_user_id, + expires_at + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (sag_id, token, token_type, hardware_id, user_id, expires_at), + ) + return token + + +def _get_setting_value(key: str, fallback: Optional[str] = None) -> Optional[str]: + row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,)) + if not row: + return fallback + value = row.get("value") + if value is None: + return fallback + return str(value) + + +_CODE39_PATTERNS: Dict[str, str] = { + "0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn", + "4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw", + "8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw", + "C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn", + "G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn", + "K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww", + "O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn", + "S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw", + "W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn", + "-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn", + "/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn", +} + + +def _render_code39_svg( + value: str, + height: int = 48, + narrow: int = 2, + wide: int = 5, + gap: int = 2, + font_size: int = 11, + include_text: bool = True, +) -> str: + safe_value = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*") + if not safe_value: + safe_value = "EMPTY" + + sequence = f"*{safe_value}*" + total_width = 12 + for ch in sequence: + pattern = _CODE39_PATTERNS[ch] + for idx, code in enumerate(pattern): + stroke = wide if code == "w" else narrow + total_width += stroke + if idx < len(pattern) - 1: + total_width += gap + total_width += gap + + x = 6 + bars = [] + for ch in sequence: + pattern = _CODE39_PATTERNS[ch] + for idx, code in enumerate(pattern): + stroke = wide if code == "w" else narrow + if idx % 2 == 0: + bars.append(f'') + x += stroke + if idx < len(pattern) - 1: + x += gap + x += gap + + text_block = "" + svg_height = height + if include_text: + svg_height = height + 20 + text_block = ( + f'{html.escape(safe_value)}' + ) + + return ( + f'' + f"{''.join(bars)}" + f"{text_block}" + f"" + ) + + def _get_signature_template() -> str: default_template = ( "{full_name}\n" @@ -325,7 +464,14 @@ def _append_signature_to_html(body_html: Optional[str], signature: str) -> Optio return None signature_html = clean_signature.replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", "
") - return f"{body_html}

--
{signature_html}" + signature_block = ( + "
" + f"{signature_html}" + "
" + ) + return f"{body_html}{signature_block}" @router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis) @@ -2152,6 +2298,816 @@ async def add_kommentar(sag_id: int, data: dict, request: Request): raise HTTPException(status_code=500, detail="Failed to add comment") +# ============================================================================ +# WORK ORDERS / LABELS +# ============================================================================ + +def _resolve_case_row_for_documents(sag_id: int): + row = execute_query_single( + """ + SELECT s.id, s.titel, s.status, s.created_at, s.beskrivelse, s.customer_id, c.name AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.id = %s AND s.deleted_at IS NULL + """, + (sag_id,), + ) + if not row: + raise HTTPException(status_code=404, detail="Case not found") + return row + + +def _store_generated_case_file( + sag_id: int, + filename: str, + content_bytes: bytes, + content_type: str, + source_type: str, + source_token: Optional[str] = None, +) -> Dict: + stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR) + destination = _resolve_attachment_path(stored_name) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content_bytes) + + query = """ + INSERT INTO sag_files ( + sag_id, + filename, + content_type, + size_bytes, + stored_name, + source_type, + source_token + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, filename, created_at + """ + rows = execute_query( + query, + (sag_id, filename, content_type, len(content_bytes), stored_name, source_type, source_token), + ) + return rows[0] + + +@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse) +async def print_case_work_order(sag_id: int, request: Request): + """Render a printable work order with scan token + barcode.""" + case = _resolve_case_row_for_documents(sag_id) + + user_id = None + try: + user_id = _get_user_id_from_request(request) + except HTTPException: + user_id = None + + token = _create_document_token(sag_id, "work_order", user_id=user_id) + barcode_svg = _render_code39_svg(token) + + todo_steps = execute_query( + """ + SELECT title, description, due_date, is_done + FROM sag_todo_steps + WHERE sag_id = %s + AND deleted_at IS NULL + ORDER BY is_done ASC, due_date ASC NULLS LAST, id ASC + """, + (sag_id,), + ) or [] + + todo_items_html = "".join( + ( + "" + "β–‘" + f"{html.escape(step.get('title') or '')}" + f"
{html.escape(step.get('description') or '')}
" + "" + ) + for step in todo_steps + ) + if not todo_items_html: + todo_items_html = ( + "β–‘" + "Ingen todo-opgaver registreret" + ) + + def _nl2br(value: Optional[str]) -> str: + text = str(value or "").strip() + if not text: + return "-" + return "
".join(html.escape(part) for part in text.splitlines()) + + def _clip(value: Optional[str], limit: int = 600) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[:limit].rstrip()}..." + + def _strip_quoted_email_text(value: Optional[str]) -> str: + text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip() + if not text: + return "" + + lines = text.split("\n") + kept = [] + header_re = re.compile(r"^(fra|from|til|to|sendt|sent|dato|date|emne|subject)\s*:\s*", re.IGNORECASE) + original_msg_re = re.compile(r"^(-----\s*original message\s*-----|begin forwarded message)", re.IGNORECASE) + wrote_re = re.compile(r"\b(wrote|skrev)\s*:\s*$", re.IGNORECASE) + + for idx, line in enumerate(lines): + trimmed = line.strip() + + if trimmed.startswith(">"): + break + if original_msg_re.match(trimmed): + break + if wrote_re.search(trimmed): + break + + if re.match(r"^[-_]{3,}$", trimmed): + lookahead = lines[idx + 1: idx + 5] + if any(header_re.match(str(candidate or "").strip()) for candidate in lookahead): + break + + if idx > 0 and header_re.match(trimmed) and not str(lines[idx - 1] or "").strip(): + break + + kept.append(line) + + while kept and not str(kept[-1]).strip(): + kept.pop() + + return "\n".join(kept).strip() + + email_from_expr = "NULL" + if table_has_column("email_messages", "sender_email"): + email_from_expr = "e.sender_email" + elif table_has_column("email_messages", "from_email"): + email_from_expr = "e.from_email" + + email_to_expr = "NULL" + if table_has_column("email_messages", "recipient_email"): + email_to_expr = "e.recipient_email" + elif table_has_column("email_messages", "to_email"): + email_to_expr = "e.to_email" + + linked_emails = [] + email_comment_rows = [] + if _table_exists("sag_emails") and _table_exists("email_messages"): + try: + linked_emails = execute_query( + f""" + SELECT + e.received_date, + e.subject, + {email_from_expr} AS from_email, + {email_to_expr} AS to_email, + e.body_text + FROM sag_emails se + JOIN email_messages e ON e.id = se.email_id + WHERE se.sag_id = %s + ORDER BY e.received_date DESC NULLS LAST, e.id DESC + LIMIT 30 + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("⚠️ Work-order linked email query failed for SAG-%s: %s", sag_id, exc) + linked_emails = [] + + if _table_exists("sag_kommentarer"): + try: + email_comment_rows = execute_query( + """ + SELECT created_at, forfatter, indhold + FROM sag_kommentarer + WHERE sag_id = %s + AND deleted_at IS NULL + AND ( + COALESCE(indhold, '') ILIKE '%%Email-ID:%%' + OR COALESCE(indhold, '') ILIKE '%%πŸ“§%%' + OR COALESCE(indhold, '') ILIKE '%%IndgΓ₯ende email%%' + OR COALESCE(indhold, '') ILIKE '%%UdgΓ₯ende email%%' + ) + ORDER BY created_at DESC + LIMIT 30 + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("⚠️ Work-order email comment query failed for SAG-%s: %s", sag_id, exc) + email_comment_rows = [] + + has_name = table_has_column("hardware_assets", "name") + has_brand = table_has_column("hardware_assets", "brand") + has_model = table_has_column("hardware_assets", "model") + has_serial = table_has_column("hardware_assets", "serial_number") + has_asset_tag = table_has_column("hardware_assets", "asset_tag") + has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id") + has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id") + has_type = table_has_column("hardware_assets", "type") + has_asset_type = table_has_column("hardware_assets", "asset_type") + + name_expr_parts = [] + if has_name: + name_expr_parts.append("NULLIF(TRIM(h.name), '')") + if has_brand and has_model: + name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')") + if has_brand: + name_expr_parts.append("NULLIF(TRIM(h.brand), '')") + if has_model: + name_expr_parts.append("NULLIF(TRIM(h.model), '')") + if has_serial: + name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')") + name_expr_parts.append("CONCAT('Hardware #', h.id::text)") + name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")" + + serial_expr = "h.serial_number" if has_serial else "NULL" + + tag_expr_parts = [] + if has_asset_tag: + tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')") + if has_customer_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')") + if has_internal_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')") + tag_expr_parts.append("'-'") + tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")" + + type_expr_parts = [] + if has_type: + type_expr_parts.append("NULLIF(TRIM(h.type), '')") + if has_asset_type: + type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')") + type_expr_parts.append("'ukendt'") + type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")" + + hardware_rows = [] + if _table_exists("sag_hardware") and _table_exists("hardware_assets"): + try: + hardware_rows = execute_query( + f""" + SELECT + h.id, + {name_expr} AS label_name, + {serial_expr} AS serial_number, + {tag_expr} AS label_tag, + {type_expr} AS label_type + FROM sag_hardware sh + JOIN hardware_assets h ON h.id = sh.hardware_id + WHERE sh.sag_id = %s + AND sh.deleted_at IS NULL + ORDER BY label_name ASC + """, + (sag_id,), + ) or [] + except Exception as exc: + logger.warning("⚠️ Work-order hardware query failed for SAG-%s: %s", sag_id, exc) + hardware_rows = [] + + hardware_html = "".join( + ( + "" + f"{html.escape(str(hw.get('label_name') or '-'))}" + f"{html.escape(str(hw.get('serial_number') or '-'))}" + f"{html.escape(str(hw.get('label_tag') or '-'))}" + f"{html.escape(str(hw.get('label_type') or '-'))}" + "" + ) + for hw in hardware_rows + ) + if not hardware_html: + hardware_html = "Ingen hardware er knyttet til sagen." + + internal_filter = "COALESCE(er_system_besked, FALSE) = TRUE" + if table_has_column("sag_kommentarer", "er_intern"): + internal_filter = "(COALESCE(er_intern, FALSE) = TRUE OR COALESCE(er_system_besked, FALSE) = TRUE)" + + internal_messages = [] + if _table_exists("sag_kommentarer"): + internal_messages = execute_query( + f""" + SELECT created_at, forfatter, indhold + FROM sag_kommentarer + WHERE sag_id = %s + AND deleted_at IS NULL + AND {internal_filter} + ORDER BY created_at DESC + LIMIT 50 + """, + (sag_id,), + ) or [] + + emails_html = "".join( + ( + "
" + f"
{html.escape((row.get('subject') or '(Ingen emne)'))}
" + f"
Fra: {html.escape(row.get('from_email') or '-')} Β· Til: {html.escape(row.get('to_email') or '-')}
" + f"
Dato: {html.escape(str(row.get('received_date') or '-'))}
" + f"
{_nl2br(_clip(_strip_quoted_email_text(row.get('body_text')), 800))}
" + "
" + ) + for row in linked_emails + ) + if not linked_emails and email_comment_rows: + emails_html = "".join( + ( + "
" + f"
{html.escape(row.get('forfatter') or 'Email')} Β· {html.escape(str(row.get('created_at') or '-'))}
" + f"
{_nl2br(_clip(_strip_quoted_email_text(row.get('indhold')), 1200))}
" + "
" + ) + for row in email_comment_rows + ) + elif not emails_html: + emails_html = "
Ingen linkede emails.
" + + internal_messages_html = "".join( + ( + "
" + f"
{html.escape(row.get('forfatter') or 'Ukendt')} Β· {html.escape(str(row.get('created_at') or '-'))}
" + f"
{_nl2br(_clip(row.get('indhold'), 900))}
" + "
" + ) + for row in internal_messages + ) + if not internal_messages_html: + internal_messages_html = "
Ingen interne beskeder.
" + + html_doc = f""" + + + + + Arbejdsseddel SAG-{case['id']} + + + +
+
+
BMC Work Order
+
SAG-{case['id']} Β· {html.escape(case.get('status') or '-')}
Kunde: {html.escape(case.get('customer_name') or '-')}
+
+
{barcode_svg}
+
+ +
+
Sags titel
+
{html.escape(case.get('titel') or '-')}
+
+ +
+
Sagsbeskrivelse
+
{_nl2br(case.get('beskrivelse'))}
+
+ +
+
Opgaver (afkryds ved udfΓΈrelse)
+ {todo_items_html}
+
+ +
+
Hardware
+ + + + + + + + + + {hardware_html} +
EnhedSerienr.TagType
+
+ +
+
Linkede emails
+
{emails_html}
+
+ +
+
Interne beskeder
+
{internal_messages_html}
+
+ +
+
Underskrift
+
+
Dato: _____________    Navn: __________________________
+
+ +
Scan-token: {html.escape(token)}
+ + +""" + return HTMLResponse(content=html_doc) + + +@router.post("/sag/{sag_id}/work-orders/{token}/signature-canvas") +async def upload_work_order_signature_canvas(sag_id: int, token: str, payload: SignatureCanvasRequest): + """Save canvas signature as case file and consume token.""" + _resolve_case_row_for_documents(sag_id) + + token_row = execute_query_single( + """ + SELECT token, sag_id + FROM sag_document_tokens + WHERE token = %s AND token_type = 'work_order' AND sag_id = %s + """, + (token, sag_id), + ) + if not token_row: + raise HTTPException(status_code=404, detail="Work-order token not found") + + if "," not in payload.data_url: + raise HTTPException(status_code=400, detail="Invalid signature payload") + + _, encoded = payload.data_url.split(",", 1) + try: + signature_bytes = base64.b64decode(encoded) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid base64 signature: {exc}") + + saved = _store_generated_case_file( + sag_id=sag_id, + filename=f"SAG-{sag_id}-signature-{datetime.now().strftime('%Y%m%d-%H%M%S')}.png", + content_bytes=signature_bytes, + content_type="image/png", + source_type="signature_canvas", + source_token=token, + ) + + execute_query( + """ + UPDATE sag_document_tokens + SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP) + WHERE token = %s + """, + (token,), + ) + + return {"status": "saved", "file": saved} + + +@router.post("/sag/{sag_id}/work-orders/{token}/signature-file") +async def upload_work_order_signature_file( + sag_id: int, + token: str, + file: UploadFile = File(...), + source: str = Form("signature_upload"), +): + """Save uploaded signature file and consume token.""" + _resolve_case_row_for_documents(sag_id) + + token_row = execute_query_single( + """ + SELECT token, sag_id + FROM sag_document_tokens + WHERE token = %s AND token_type = 'work_order' AND sag_id = %s + """, + (token, sag_id), + ) + if not token_row: + raise HTTPException(status_code=404, detail="Work-order token not found") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Empty file") + + safe_name = Path(file.filename or "signature-upload.bin").name + saved = _store_generated_case_file( + sag_id=sag_id, + filename=f"SAG-{sag_id}-{safe_name}", + content_bytes=content, + content_type=file.content_type or "application/octet-stream", + source_type=source or "signature_upload", + source_token=token, + ) + + execute_query( + """ + UPDATE sag_document_tokens + SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP) + WHERE token = %s + """, + (token,), + ) + + return {"status": "saved", "file": saved} + + +@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse) +async def print_case_hardware_labels( + sag_id: int, + request: Request, + auto_print: bool = Query(False), +): + """Render printable hardware labels with IDs and barcodes.""" + _resolve_case_row_for_documents(sag_id) + + has_name = table_has_column("hardware_assets", "name") + has_brand = table_has_column("hardware_assets", "brand") + has_model = table_has_column("hardware_assets", "model") + has_serial = table_has_column("hardware_assets", "serial_number") + has_asset_tag = table_has_column("hardware_assets", "asset_tag") + has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id") + has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id") + has_type = table_has_column("hardware_assets", "type") + has_asset_type = table_has_column("hardware_assets", "asset_type") + + name_expr_parts = [] + if has_name: + name_expr_parts.append("NULLIF(TRIM(h.name), '')") + if has_brand and has_model: + name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')") + if has_brand: + name_expr_parts.append("NULLIF(TRIM(h.brand), '')") + if has_model: + name_expr_parts.append("NULLIF(TRIM(h.model), '')") + if has_serial: + name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')") + name_expr_parts.append("CONCAT('Hardware #', h.id::text)") + name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")" + + tag_expr_parts = [] + if has_asset_tag: + tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')") + if has_customer_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')") + if has_internal_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')") + tag_expr_parts.append("'-'") + tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")" + + type_expr_parts = [] + if has_type: + type_expr_parts.append("NULLIF(TRIM(h.type), '')") + if has_asset_type: + type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')") + type_expr_parts.append("'ukendt'") + type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")" + + serial_expr = "h.serial_number" if has_serial else "NULL" + + hardware_id_filter = None + if payload and payload.hardware_id is not None: + try: + hardware_id_filter = int(payload.hardware_id) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Ugyldigt hardware_id") + + hardware_query = f""" + SELECT + h.id, + {name_expr} AS label_name, + {serial_expr} AS serial_number, + {tag_expr} AS label_tag, + {type_expr} AS label_type + FROM sag_hardware sh + JOIN hardware_assets h ON h.id = sh.hardware_id + WHERE sh.sag_id = %s + AND sh.deleted_at IS NULL + """ + params = [sag_id] + if hardware_id_filter is not None: + hardware_query += " AND sh.hardware_id = %s" + params.append(hardware_id_filter) + hardware_query += " ORDER BY label_name ASC" + + hardware_rows = execute_query(hardware_query, tuple(params)) or [] + + user_id = None + try: + user_id = _get_user_id_from_request(request) + except HTTPException: + user_id = None + + label_cards = [] + for hw in hardware_rows: + token = _create_document_token( + sag_id=sag_id, + token_type="hardware_label", + user_id=user_id, + hardware_id=hw.get("id"), + ) + barcode_svg = _render_code39_svg( + token, + height=26, + narrow=1, + wide=2, + gap=0, + font_size=10, + include_text=False, + ) + label_cards.append( + f""" +
+
{html.escape(hw.get('label_name') or 'Ukendt enhed')}
+
ID: HW-{hw.get('id')} Β· SAG-{sag_id}
+
SN: {html.escape(hw.get('serial_number') or '-')} Β· Tag: {html.escape(hw.get('label_tag') or '-')} Β· Type: {html.escape(hw.get('label_type') or '-')}
+
{barcode_svg}
+
{html.escape(token)}
+
+ """ + ) + + if not label_cards: + label_cards.append("
Ingen hardware er knyttet til sagen endnu.
") + + auto_print_script = "" + if auto_print: + auto_print_script = ( + "" + ) + + html_doc = f""" + + + + + Hardware labels SAG-{sag_id} + + + +
+ {''.join(label_cards)} +
+ {auto_print_script} + + +""" + return HTMLResponse(content=html_doc) + + +@router.post("/sag/{sag_id}/labels/hardware/print-direct") +async def print_case_hardware_labels_direct( + sag_id: int, + request: Request, + payload: Optional[DirectPrintOverrideRequest] = None, +): + """Print hardware labels directly to configured Brother network printer.""" + _resolve_case_row_for_documents(sag_id) + + enabled = (_get_setting_value("label_printer_enabled", "false") or "false").strip().lower() == "true" + if not enabled: + raise HTTPException(status_code=400, detail="Direkte label-print er ikke aktiveret i indstillinger") + + host = (payload.printer_host if payload and payload.printer_host else _get_setting_value("label_printer_host", "")).strip() + port_raw = payload.printer_port if payload and payload.printer_port is not None else _get_setting_value("label_printer_port", "9100") + model = (payload.printer_model if payload and payload.printer_model else _get_setting_value("label_printer_model", "QL-710W")).strip() + label_size = (payload.label_size if payload and payload.label_size else _get_setting_value("label_printer_label_size", "62")).strip() + + try: + port = int(port_raw or 9100) + except ValueError: + raise HTTPException(status_code=400, detail="Ugyldig printer-port") + + if not host: + raise HTTPException(status_code=400, detail="Printer host/IP mangler i indstillinger") + + has_name = table_has_column("hardware_assets", "name") + has_brand = table_has_column("hardware_assets", "brand") + has_model = table_has_column("hardware_assets", "model") + has_serial = table_has_column("hardware_assets", "serial_number") + has_asset_tag = table_has_column("hardware_assets", "asset_tag") + has_customer_asset_id = table_has_column("hardware_assets", "customer_asset_id") + has_internal_asset_id = table_has_column("hardware_assets", "internal_asset_id") + has_type = table_has_column("hardware_assets", "type") + has_asset_type = table_has_column("hardware_assets", "asset_type") + + name_expr_parts = [] + if has_name: + name_expr_parts.append("NULLIF(TRIM(h.name), '')") + if has_brand and has_model: + name_expr_parts.append("NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), '')") + if has_brand: + name_expr_parts.append("NULLIF(TRIM(h.brand), '')") + if has_model: + name_expr_parts.append("NULLIF(TRIM(h.model), '')") + if has_serial: + name_expr_parts.append("NULLIF(TRIM(h.serial_number), '')") + name_expr_parts.append("CONCAT('Hardware #', h.id::text)") + name_expr = "COALESCE(" + ", ".join(name_expr_parts) + ")" + + tag_expr_parts = [] + if has_asset_tag: + tag_expr_parts.append("NULLIF(TRIM(h.asset_tag), '')") + if has_customer_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.customer_asset_id), '')") + if has_internal_asset_id: + tag_expr_parts.append("NULLIF(TRIM(h.internal_asset_id), '')") + tag_expr_parts.append("'-'") + tag_expr = "COALESCE(" + ", ".join(tag_expr_parts) + ")" + + type_expr_parts = [] + if has_type: + type_expr_parts.append("NULLIF(TRIM(h.type), '')") + if has_asset_type: + type_expr_parts.append("NULLIF(TRIM(h.asset_type), '')") + type_expr_parts.append("'ukendt'") + type_expr = "COALESCE(" + ", ".join(type_expr_parts) + ")" + + serial_expr = "h.serial_number" if has_serial else "NULL" + + hardware_rows = execute_query( + f""" + SELECT + h.id, + {name_expr} AS label_name, + {serial_expr} AS serial_number, + {tag_expr} AS label_tag, + {type_expr} AS label_type + FROM sag_hardware sh + JOIN hardware_assets h ON h.id = sh.hardware_id + WHERE sh.sag_id = %s + AND sh.deleted_at IS NULL + ORDER BY label_name ASC + """, + (sag_id,), + ) or [] + + if not hardware_rows: + if hardware_id_filter is not None: + raise HTTPException(status_code=404, detail="Valgt hardware er ikke knyttet til sagen") + raise HTTPException(status_code=400, detail="Ingen hardware er knyttet til sagen") + + user_id = None + try: + user_id = _get_user_id_from_request(request) + except HTTPException: + user_id = None + + jobs: List[LabelJob] = [] + for hw in hardware_rows: + token = _create_document_token( + sag_id=sag_id, + token_type="hardware_label", + user_id=user_id, + hardware_id=hw.get("id"), + ) + meta = ( + f"ID: HW-{hw.get('id')} SAG-{sag_id} " + f"SN: {hw.get('serial_number') or '-'} Tag: {hw.get('label_tag') or '-'} Type: {hw.get('label_type') or '-'}" + ) + jobs.append( + LabelJob( + name=str(hw.get("label_name") or "Ukendt enhed"), + meta_line=meta, + token=token, + ) + ) + + service = BrotherLabelPrintService( + model=model, + host=host, + port=port, + label_size=label_size, + ) + + try: + printed = service.print_jobs(jobs) + except Exception as exc: + logger.error("❌ Direct label print failed for SAG-%s: %s", sag_id, exc) + raise HTTPException(status_code=500, detail=f"Direkte print fejlede: {exc}") + + return { + "status": "ok", + "printed": printed, + "hardware_ids": [int(hw.get("id")) for hw in hardware_rows if hw.get("id") is not None], + "printer": { + "model": model, + "host": host, + "port": port, + "label_size": label_size, + }, + } + + # ============================================================================ # FILES - Case Files # ============================================================================ @@ -2281,6 +3237,53 @@ async def download_sag_file(sag_id: int, file_id: int, download: bool = False): headers=headers ) + +@router.get("/sag/{sag_id}/files/{file_id}/preview-image") +async def preview_sag_pdf_as_image(sag_id: int, file_id: int, page: int = Query(1, ge=1), scale: float = Query(2.8, ge=1.0, le=5.0)): + """Render a PDF page as PNG for consistent in-app preview sizing.""" + query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s" + result = execute_query(query, (file_id, sag_id)) + + if not result: + raise HTTPException(status_code=404, detail="File not found") + + file_data = result[0] + path = _resolve_attachment_path(file_data["stored_name"]) + + if not path.exists(): + raise HTTPException(status_code=404, detail="File lost on server") + + content_type = (file_data.get("content_type") or "").lower() + filename = str(file_data.get("filename") or "").lower() + if "pdf" not in content_type and not filename.endswith(".pdf"): + raise HTTPException(status_code=400, detail="Preview image is only supported for PDF files") + + try: + import pypdfium2 as pdfium + + document = pdfium.PdfDocument(str(path)) + page_index = min(max(page - 1, 0), max(len(document) - 1, 0)) + pdf_page = document.get_page(page_index) + bitmap = pdf_page.render(scale=scale) + png_bytes = bitmap.to_pil().convert("RGB") + + import io + buffer = io.BytesIO() + png_bytes.save(buffer, format="PNG", optimize=True) + data = buffer.getvalue() + + try: + pdf_page.close() + except Exception: + pass + + return Response(content=data, media_type="image/png") + except HTTPException: + raise + except Exception as e: + logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e) + raise HTTPException(status_code=500, detail="Could not render PDF preview") + @router.delete("/sag/{sag_id}/files/{file_id}") async def delete_sag_file(sag_id: int, file_id: int): """Delete a file.""" @@ -2327,17 +3330,25 @@ async def get_sag_emails(sag_id: int): e.*, COALESCE( NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''), - NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''), NULLIF( REGEXP_REPLACE( - LOWER(TRIM(COALESCE(e.subject, ''))), - '^(?:re|fw|fwd)\\s*:\\s*', + (REGEXP_SPLIT_TO_ARRAY(COALESCE(e.in_reply_to, ''), E'[\\s,]+'))[1], + '[<>\\s]', '', 'g' ), '' ), + NULLIF( + REGEXP_REPLACE( + LOWER(TRIM(COALESCE(e.subject, ''))), + '^(?:(?:re|fw|fwd|sv|aw)\\s*:\\s*)+', + '', + 'i' + ), + '' + ), NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''), CONCAT('email-', e.id::text) ) AS resolved_thread_key @@ -2515,12 +3526,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req in_reply_to_header = None references_header = None + selected_thread_key = None if payload.thread_email_id: thread_row = None try: thread_row = execute_query_single( """ - SELECT id, message_id, in_reply_to, email_references + SELECT id, message_id, in_reply_to, email_references, thread_key FROM email_messages WHERE id = %s """, @@ -2528,14 +3540,24 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req ) except Exception: # Backward compatibility for DBs without in_reply_to/email_references columns. - thread_row = execute_query_single( - """ - SELECT id, message_id - FROM email_messages - WHERE id = %s - """, - (payload.thread_email_id,), - ) + try: + thread_row = execute_query_single( + """ + SELECT id, message_id, thread_key + FROM email_messages + WHERE id = %s + """, + (payload.thread_email_id,), + ) + except Exception: + thread_row = execute_query_single( + """ + SELECT id, message_id + FROM email_messages + WHERE id = %s + """, + (payload.thread_email_id,), + ) if thread_row: base_message_id = str(thread_row.get("message_id") or "").strip() if base_message_id and not base_message_id.startswith("<"): @@ -2549,8 +3571,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req else: references_header = base_message_id + selected_thread_key = _derive_thread_key_for_outbound( + thread_row.get("thread_key"), + thread_row.get("in_reply_to"), + thread_row.get("email_references"), + thread_row.get("message_id"), + ) + + effective_payload_thread_key = payload.thread_key or selected_thread_key + if not effective_payload_thread_key: + # Brand-new thread: assign a local key immediately so signature/BMCid + # can carry a resolvable thread identity from the first outbound email. + effective_payload_thread_key = _generate_local_thread_key_for_new_outbound(sag_id) + provisional_thread_key = _derive_thread_key_for_outbound( - payload.thread_key, + effective_payload_thread_key, in_reply_to_header, references_header, None, @@ -2559,6 +3594,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req body_text = _append_signature_to_body(body_text, signature) body_html = _append_signature_to_html(payload.body_html, signature) + # Inject hidden BMCid tracker into HTML body so replies can be routed back + bmc_id_tag = _build_case_bmc_id_tag(sag_id, provisional_thread_key) + hidden_tracker = f'
BMCid: {bmc_id_tag}
' + if body_html: + body_html = f"{hidden_tracker}{body_html}" + elif body_text: + # Synthesize minimal HTML wrapper with tracker when only plain text exists + import html as _html + body_html = f"{hidden_tracker}
{_html.escape(body_text)}
" + + # Ensure subject carries [SAG-XX] prefix for reliable subject-line matching + sag_prefix = f"[SAG-{sag_id}]" + if sag_prefix not in subject: + subject = f"{sag_prefix} {subject}" + email_service = EmailService() success, send_message, generated_message_id, provider_thread_key = await email_service.send_email_with_attachments( to_addresses=to_addresses, @@ -2584,16 +3634,28 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" - thread_key = _normalize_message_id_token(provider_thread_key) + provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key) + + # Keep replies in the existing case thread when we already know the target thread. + # Some providers may return a new conversation id even for replies. + derived_thread_key = _derive_thread_key_for_outbound( + effective_payload_thread_key, + in_reply_to_header, + references_header, + None, + ) + + thread_key = derived_thread_key or provider_thread_key_normalized if not thread_key: thread_key = _derive_thread_key_for_outbound( - payload.thread_key, + effective_payload_thread_key, in_reply_to_header, references_header, generated_message_id, ) insert_result = None + insert_error = None try: insert_email_query = """ INSERT INTO email_messages ( @@ -2648,89 +3710,183 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req sag_id, ), ) - except Exception: - insert_email_query = """ - INSERT INTO email_messages ( - message_id, subject, sender_email, sender_name, - recipient_email, cc, body_text, body_html, - received_date, folder, has_attachments, attachment_count, - status, import_method, linked_case_id - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (message_id) DO UPDATE - SET - subject = EXCLUDED.subject, - sender_email = EXCLUDED.sender_email, - sender_name = EXCLUDED.sender_name, - recipient_email = EXCLUDED.recipient_email, - cc = EXCLUDED.cc, - body_text = EXCLUDED.body_text, - body_html = EXCLUDED.body_html, - folder = 'Sent', - has_attachments = EXCLUDED.has_attachments, - attachment_count = EXCLUDED.attachment_count, - status = 'sent', - import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method), - linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id), - updated_at = CURRENT_TIMESTAMP - RETURNING id - """ - insert_result = execute_query( - insert_email_query, - ( - generated_message_id, - subject, - sender_email, - sender_name, - ", ".join(to_addresses), - ", ".join(cc_addresses), - body_text, - body_html, - datetime.now(), - "Sent", - bool(smtp_attachments), - len(smtp_attachments), - "sent", - "manual_upload", - sag_id, - ), - ) + except Exception as e: + insert_error = e + logger.warning("⚠️ Outbound email full insert fallback for case %s: %s", sag_id, e) if not insert_result: - logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) - raise HTTPException(status_code=500, detail="Email sent but logging failed") + try: + insert_email_query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, body_html, + received_date, folder, has_attachments, attachment_count, + status, import_method, linked_case_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (message_id) DO UPDATE + SET + subject = EXCLUDED.subject, + sender_email = EXCLUDED.sender_email, + sender_name = EXCLUDED.sender_name, + recipient_email = EXCLUDED.recipient_email, + cc = EXCLUDED.cc, + body_text = EXCLUDED.body_text, + body_html = EXCLUDED.body_html, + folder = 'Sent', + has_attachments = EXCLUDED.has_attachments, + attachment_count = EXCLUDED.attachment_count, + status = 'sent', + import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method), + linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id), + updated_at = CURRENT_TIMESTAMP + RETURNING id + """ + insert_result = execute_query( + insert_email_query, + ( + generated_message_id, + subject, + sender_email, + sender_name, + ", ".join(to_addresses), + ", ".join(cc_addresses), + body_text, + body_html, + datetime.now(), + "Sent", + bool(smtp_attachments), + len(smtp_attachments), + "sent", + "manual_upload", + sag_id, + ), + ) + except Exception as e: + insert_error = e + logger.warning("⚠️ Outbound email medium insert fallback for case %s: %s", sag_id, e) - email_id = insert_result[0]["id"] + if not insert_result: + # Legacy-safe fallback: persist with minimal guaranteed columns. + try: + insert_email_query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, + received_date, folder, has_attachments, attachment_count + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (message_id) DO UPDATE + SET + subject = EXCLUDED.subject, + sender_email = EXCLUDED.sender_email, + sender_name = EXCLUDED.sender_name, + recipient_email = EXCLUDED.recipient_email, + cc = EXCLUDED.cc, + body_text = EXCLUDED.body_text, + folder = 'Sent', + has_attachments = EXCLUDED.has_attachments, + attachment_count = EXCLUDED.attachment_count, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """ + insert_result = execute_query( + insert_email_query, + ( + generated_message_id, + subject, + sender_email, + sender_name, + ", ".join(to_addresses), + ", ".join(cc_addresses), + body_text, + datetime.now(), + "Sent", + bool(smtp_attachments), + len(smtp_attachments), + ), + ) + except Exception as e: + insert_error = e + logger.error("❌ Email sent but outbound log insert failed for case %s: %s", sag_id, e) - if smtp_attachments: + email_id = None + if insert_result: + email_id = insert_result[0]["id"] + else: + # Last chance recovery: if row exists already, continue with that id. + existing_email = execute_query_single( + "SELECT id FROM email_messages WHERE message_id = %s", + (generated_message_id,), + ) if generated_message_id else None + if existing_email: + email_id = existing_email["id"] + else: + warning_detail = str(insert_error or "email logging failed") + logger.error("❌ Email sent but no local email_id could be resolved for case %s", sag_id) + return { + "status": "sent", + "email_id": None, + "message": send_message, + "warning": f"Email sent but could not be logged locally: {warning_detail}", + } + + if smtp_attachments and email_id: from psycopg2 import Binary for attachment in smtp_attachments: - execute_query( - """ - INSERT INTO email_attachments ( - email_id, filename, content_type, size_bytes, file_path, content_data - ) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( + try: + execute_query( + """ + INSERT INTO email_attachments ( + email_id, filename, content_type, size_bytes, file_path, content_data + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + email_id, + attachment["filename"], + attachment["content_type"], + attachment.get("size") or len(attachment["content"]), + attachment.get("file_path"), + Binary(attachment["content"]), + ), + ) + except Exception as e: + logger.warning( + "⚠️ Could not persist outbound email attachment '%s' for email_id=%s: %s", + attachment.get("filename"), email_id, - attachment["filename"], - attachment["content_type"], - attachment.get("size") or len(attachment["content"]), - attachment.get("file_path"), - Binary(attachment["content"]), - ), - ) + e, + ) - execute_query( - """ - INSERT INTO sag_emails (sag_id, email_id) - VALUES (%s, %s) - ON CONFLICT DO NOTHING - """, - (sag_id, email_id), - ) + linked_ok = False + try: + execute_query( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (sag_id, email_id), + ) + linked_ok = True + except Exception as e: + logger.warning("⚠️ Could not insert sag_emails link for case=%s email_id=%s: %s", sag_id, email_id, e) + if table_has_column("email_messages", "linked_case_id"): + try: + execute_query( + "UPDATE email_messages SET linked_case_id = %s WHERE id = %s", + (sag_id, email_id), + ) + linked_ok = True + except Exception as nested_e: + logger.warning( + "⚠️ Fallback linked_case_id update also failed for case=%s email_id=%s: %s", + sag_id, + email_id, + nested_e, + ) sent_ts = datetime.now().isoformat() outgoing_comment = ( @@ -2743,14 +3899,63 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req f"{body_text}" ) - comment_row = execute_query_single( - """ - INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) - VALUES (%s, %s, %s, %s) - RETURNING kommentar_id, created_at - """, - (sag_id, 'Email Bot', outgoing_comment, True), - ) or {} + comment_row = {} + try: + has_system_flag = table_has_column("sag_kommentarer", "er_system_besked") + attempted_errors = [] + + has_comment_id_col = table_has_column("sag_kommentarer", "kommentar_id") + has_id_col = table_has_column("sag_kommentarer", "id") + + # Prefer the variant that matches the live schema to avoid noisy SQL errors in logs. + if has_comment_id_col: + returning_variants = ["kommentar_id", "id AS kommentar_id"] + elif has_id_col: + returning_variants = ["id AS kommentar_id", "kommentar_id"] + else: + returning_variants = ["kommentar_id", "id AS kommentar_id"] + + if has_system_flag: + comment_variants = [ + ( + f""" + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + RETURNING {returning_expr}, created_at + """, + (sag_id, 'Email Bot', outgoing_comment, True), + ) + for returning_expr in returning_variants + ] + else: + comment_variants = [ + ( + f""" + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold) + VALUES (%s, %s, %s) + RETURNING {returning_expr}, created_at + """, + (sag_id, 'Email Bot', outgoing_comment), + ) + for returning_expr in returning_variants + ] + + for variant_query, variant_params in comment_variants: + try: + comment_row = execute_query_single(variant_query, variant_params) or {} + if comment_row: + break + except Exception as variant_error: + attempted_errors.append(str(variant_error)) + + if not comment_row and attempted_errors: + logger.warning( + "⚠️ Outbound email sent but comment logging variants failed for case %s: %s", + sag_id, + " | ".join(attempted_errors), + ) + except Exception as e: + logger.warning("⚠️ Outbound email sent but comment logging failed for case %s: %s", sag_id, e) comment_created_at = comment_row.get("created_at") if isinstance(comment_created_at, datetime): @@ -2759,17 +3964,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req comment_created_at = sent_ts logger.info( - "βœ… Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)", + "βœ… Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, payload_thread_key=%s, stored_thread_key=%s, provider_thread_key=%s, recipients=%s)", sag_id, email_id, payload.thread_email_id, - payload.thread_key, + effective_payload_thread_key, + thread_key, + provider_thread_key_normalized, ", ".join(to_addresses), ) return { "status": "sent", "email_id": email_id, "message": send_message, + "linked_to_case": linked_ok, + "warning": None if linked_ok else "Email sent, but automatic case-thread link fallback was required", "comment": { "kommentar_id": comment_row.get("kommentar_id"), "forfatter": "Email Bot", diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index f35fc8b..a2f53fc 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -12,6 +12,59 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _render_api_print_bridge(api_path: str, page_title: str) -> str: + safe_api_path = json.dumps(api_path) + safe_title = json.dumps(page_title) + return f""" + + + + + {page_title} + + + +
Henter printvisning...
+ + + +""" + + def _is_deadline_overdue(deadline_value) -> bool: if not deadline_value: return False @@ -128,7 +181,15 @@ async def sager_liste( COALESCE(u.full_name, u.username) AS ansvarlig_navn, g.name AS assigned_group_name, nt.title AS next_todo_title, - nt.due_date AS next_todo_due_date + nt.due_date AS next_todo_due_date, + COALESCE(ec.unread_email_count, 0) AS unread_email_count, + ec.oldest_unread_received_date, + CASE + WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none' + WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot' + WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm' + ELSE 'fresh' + END AS unread_email_level FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id @@ -157,6 +218,14 @@ async def sager_liste( t.created_at ASC LIMIT 1 ) nt ON true + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count, + MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date + FROM sag_emails se + JOIN email_messages em ON em.id = se.email_id + WHERE se.sag_id = s.id + ) ec ON true LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id WHERE s.deleted_at IS NULL """ @@ -196,10 +265,26 @@ async def sager_liste( COALESCE(u.full_name, u.username) AS ansvarlig_navn, NULL::text AS assigned_group_name, NULL::text AS next_todo_title, - NULL::timestamp AS next_todo_due_date + NULL::timestamp AS next_todo_due_date, + COALESCE(ec.unread_email_count, 0) AS unread_email_count, + ec.oldest_unread_received_date, + CASE + WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none' + WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot' + WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm' + ELSE 'fresh' + END AS unread_email_level FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count, + MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date + FROM sag_emails se + JOIN email_messages em ON em.id = se.email_id + WHERE se.sag_id = s.id + ) ec ON true WHERE s.deleted_at IS NULL """ fallback_params = [] @@ -289,6 +374,7 @@ async def sager_liste( "toggle_include_deferred_url": toggle_include_deferred_url, "assignment_users": _fetch_assignment_users(), "assignment_groups": _fetch_assignment_groups(), + "current_customer_id": customer_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_assigned_group_id": assigned_group_id_int, }) @@ -307,6 +393,7 @@ async def sager_liste( "toggle_include_deferred_url": str(request.url), "assignment_users": [], "assignment_groups": [], + "current_customer_id": customer_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_assigned_group_id": assigned_group_id_int, }) @@ -320,6 +407,32 @@ async def opret_sag_side(request: Request): "assignment_groups": _fetch_assignment_groups(), }) + +@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse) +async def sag_work_order_print_page(request: Request, sag_id: int): + auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"} + api_path = f"/api/v1/sag/{sag_id}/work-orders/print" + if auto_print: + api_path = f"{api_path}?auto_print=1" + html = _render_api_print_bridge( + api_path=api_path, + page_title=f"Arbejdsseddel SAG-{sag_id}", + ) + return HTMLResponse(content=html) + + +@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse) +async def sag_hardware_labels_print_page(request: Request, sag_id: int): + auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"} + api_path = f"/api/v1/sag/{sag_id}/labels/hardware/print" + if auto_print: + api_path = f"{api_path}?auto_print=1" + html = _render_api_print_bridge( + api_path=api_path, + page_title=f"Hardware labels SAG-{sag_id}", + ) + return HTMLResponse(content=html) + @router.get("/sag/varekob-salg", response_class=HTMLResponse) async def sag_varekob_salg(request: Request): """Display orders overview for all purchases and sales.""" diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 616457b..ba5dfa9 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -124,6 +124,18 @@ [data-bs-theme="dark"] .selected-item button { color: #a6d5fa; } + + .case-top-alerts .alert { + border-left: 6px solid; + } + + .case-top-alerts .alert-warning { + border-left-color: #f59f00; + } + + .case-top-alerts .alert-danger { + border-left-color: #e03131; + } {% endblock %} @@ -139,6 +151,8 @@
+
+ + + +