feat: Implement bug reporting feature with screenshot support

- Added a new modal for reporting bugs, including fields for describing the issue and attaching files.
- Implemented backend API for creating bug reports, including rate limiting and metadata logging.
- Introduced a new database table to track bug report submissions for auditing purposes.
- Enhanced the frontend to capture screenshots automatically and allow manual file uploads.
- Added error handling and user feedback for the bug reporting process.
- Updated existing templates and scripts to integrate the new bug reporting functionality.
This commit is contained in:
Christian 2026-05-05 19:13:54 +02:00
parent 1a44baba62
commit 71f6372496
16 changed files with 1119 additions and 27 deletions

View File

View File

View File

@ -0,0 +1,20 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class BugReportPayload(BaseModel):
actual: str = Field(..., min_length=3, max_length=8000)
expected: str = Field(..., min_length=3, max_length=8000)
screenshot_base64: Optional[str] = Field(default=None, max_length=25_000_000)
metadata: Dict[str, Any] = Field(default_factory=dict)
logs: List[Dict[str, Any]] = Field(default_factory=list)
extra_file_name: Optional[str] = Field(default=None, max_length=255)
extra_file_base64: Optional[str] = Field(default=None, max_length=25_000_000)
class BugReportResult(BaseModel):
success: bool
sag_id: int
case_url: str
message: str

View File

@ -0,0 +1,212 @@
import base64
import json
import logging
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from app.bug_reports.backend.models import BugReportPayload, BugReportResult
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve()
SAG_FILE_SUBDIR = "sag_files"
(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True)
def _table_exists(table_name: str) -> bool:
row = execute_query_single(
"""
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
LIMIT 1
""",
(table_name,),
)
return bool(row)
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
# Expected format: data:image/png;base64,....
match = re.match(r"^data:([\w/+.-]+);base64,(.+)$", data_url or "", flags=re.DOTALL)
if not match:
raise HTTPException(status_code=400, detail="Invalid base64 data URL")
content_type = match.group(1)
b64_data = match.group(2)
try:
raw = base64.b64decode(b64_data, validate=True)
except Exception:
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
return raw, content_type
def _store_raw_file(raw: bytes, filename: str) -> tuple[str, int]:
safe_name = Path(filename).name
stored_name = f"{SAG_FILE_SUBDIR}/{uuid4().hex}_{safe_name}"
destination = UPLOAD_BASE_PATH / stored_name
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as f:
f.write(raw)
return stored_name, len(raw)
def _create_sag_file_record(sag_id: int, filename: str, content_type: str, size_bytes: int, stored_name: str) -> None:
execute_query(
"""
INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name)
VALUES (%s, %s, %s, %s, %s)
""",
(sag_id, filename, content_type, size_bytes, stored_name),
)
def _rate_limit(user_id: int) -> None:
if not _table_exists("bug_report_submissions"):
# If migration is not yet applied, fail-open to avoid blocking support workflows.
return
max_per_hour = max(1, int(settings.BUG_REPORT_MAX_PER_HOUR))
row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM bug_report_submissions
WHERE user_id = %s
AND created_at >= %s
""",
(user_id, datetime.utcnow() - timedelta(hours=1)),
)
count = int((row or {}).get("count") or 0)
if count >= max_per_hour:
raise HTTPException(status_code=429, detail="Rate limit exceeded for bug reports")
def _resolve_customer_id() -> int:
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
if configured_row:
return int(configured_row["id"])
named_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE LOWER(name) = LOWER(%s)
ORDER BY id ASC
LIMIT 1
""",
("BMC Networks",),
)
if named_row:
return int(named_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
if fallback:
return int(fallback["id"])
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
@router.post("/bug-reports", response_model=BugReportResult)
async def create_bug_report(payload: BugReportPayload, request: Request):
user_id = getattr(request.state, "user_id", None) or 1
_rate_limit(int(user_id))
title_seed = (payload.actual or "").strip().splitlines()[0][:80]
title = f"Bug: {title_seed or 'Ukendt fejl'}"
metadata_json = json.dumps(payload.metadata or {}, ensure_ascii=False, indent=2)
logs_preview = (payload.logs or [])[:50]
logs_json = json.dumps(logs_preview, ensure_ascii=False, indent=2)
description = (
"## Hvad gik galt\n"
f"{payload.actual.strip()}\n\n"
"## Hvad burde være sket\n"
f"{payload.expected.strip()}\n\n"
"## Metadata\n"
f"```json\n{metadata_json}\n```\n\n"
"## Log preview (seneste 50)\n"
f"```json\n{logs_json}\n```\n"
)
customer_id = _resolve_customer_id()
assigned_user_id: Optional[int] = settings.BUG_REPORT_AUTO_ASSIGN_USER_ID
created = execute_query(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id)
VALUES
(%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title,
description,
"bug_report",
"åben",
customer_id,
assigned_user_id,
user_id,
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create bug case")
sag_id = int(created[0]["id"])
# Attach screenshot if provided
if payload.screenshot_base64:
raw, content_type = _decode_data_url(payload.screenshot_base64)
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large")
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
# Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
stored_name, size = _store_raw_file(logs_raw, f"bugreport_{sag_id}_logs.json")
_create_sag_file_record(sag_id, "logs.json", "application/json", size, stored_name)
# Optional extra file
if payload.extra_file_base64 and payload.extra_file_name:
raw, content_type = _decode_data_url(payload.extra_file_base64)
if len(raw) > settings.BUG_REPORT_MAX_ATTACHMENT_BYTES:
raise HTTPException(status_code=400, detail="Extra file too large")
stored_name, size = _store_raw_file(raw, payload.extra_file_name)
_create_sag_file_record(sag_id, payload.extra_file_name, content_type, size, stored_name)
# Track submission for rate-limiting/audit
if _table_exists("bug_report_submissions"):
execute_query(
"""
INSERT INTO bug_report_submissions (sag_id, user_id, screenshot_attached)
VALUES (%s, %s, %s)
""",
(sag_id, user_id, bool(payload.screenshot_base64)),
)
logger.info("✅ Bug report case created: SAG-%s by user_id=%s", sag_id, user_id)
return BugReportResult(
success=True,
sag_id=sag_id,
case_url=f"/sag/{sag_id}/v3",
message="Fejl rapporteret og sag oprettet",
)

View File

@ -208,6 +208,15 @@ class Settings(BaseSettings):
BACKUP_INCLUDE_LOGS: bool = True # Include logs/ in file backups BACKUP_INCLUDE_LOGS: bool = True # Include logs/ in file backups
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
UPLOAD_DIR: str = "uploads" # Upload directory path UPLOAD_DIR: str = "uploads" # Upload directory path
# Bug report capture
BUG_REPORT_ENABLED: bool = True
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
BUG_REPORT_MAX_PER_HOUR: int = 12
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
# Offsite Backup Settings (SFTP) # Offsite Backup Settings (SFTP)
OFFSITE_ENABLED: bool = False OFFSITE_ENABLED: bool = False

View File

@ -1181,6 +1181,12 @@ async def create_contact(location_id: int, data: ContactCreate):
- 500: Database error - 500: Database error
""" """
try: try:
def _none_if_empty(value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
# Check location exists # Check location exists
location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL" location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
location_check = execute_query(location_query, (location_id,)) location_check = execute_query(location_query, (location_id,))
@ -1193,6 +1199,43 @@ async def create_contact(location_id: int, data: ContactCreate):
) )
location_name = location_check[0]['name'] location_name = location_check[0]['name']
contact_name = (data.contact_name or "").strip()
contact_email = _none_if_empty(data.contact_email)
contact_phone = _none_if_empty(data.contact_phone)
contact_role = _none_if_empty(data.role)
if data.existing_contact_id:
existing_contact_query = """
SELECT id, first_name, last_name, email, phone, mobile, title
FROM contacts
WHERE id = %s
"""
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
if not existing_contact_result:
raise HTTPException(
status_code=404,
detail=f"Existing contact with id {data.existing_contact_id} not found"
)
existing_contact = existing_contact_result[0]
existing_full_name = (
f"{(existing_contact.get('first_name') or '').strip()} "
f"{(existing_contact.get('last_name') or '').strip()}"
).strip()
if not contact_name:
contact_name = existing_full_name
if not contact_email:
contact_email = _none_if_empty(existing_contact.get('email'))
if not contact_phone:
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
if not contact_role:
contact_role = _none_if_empty(existing_contact.get('title'))
if not contact_name:
raise HTTPException(status_code=400, detail="contact_name is required")
# If is_primary is true, unset primary flag on other contacts # If is_primary is true, unset primary flag on other contacts
if data.is_primary: if data.is_primary:
@ -1215,10 +1258,10 @@ async def create_contact(location_id: int, data: ContactCreate):
params = ( params = (
location_id, location_id,
data.contact_name, contact_name,
data.contact_email, contact_email,
data.contact_phone, contact_phone,
data.role, contact_role,
data.is_primary data.is_primary
) )
@ -1230,7 +1273,16 @@ async def create_contact(location_id: int, data: ContactCreate):
contact = Contact(**result[0]) contact = Contact(**result[0])
logger.info(f"✅ Contact added: {data.contact_name} at {location_name} (Location ID: {location_id})") if data.existing_contact_id:
logger.info(
"✅ Contact added from existing contact %s: %s at %s (Location ID: %s)",
data.existing_contact_id,
contact_name,
location_name,
location_id,
)
else:
logger.info(f"✅ Contact added: {contact_name} at {location_name} (Location ID: {location_id})")
return contact return contact
except HTTPException: except HTTPException:

View File

@ -120,6 +120,11 @@ class ContactBase(BaseModel):
class ContactCreate(ContactBase): class ContactCreate(ContactBase):
"""Request model for creating contact""" """Request model for creating contact"""
is_primary: bool = Field(False, description="Set as primary contact for location") is_primary: bool = Field(False, description="Set as primary contact for location")
existing_contact_id: Optional[int] = Field(
None,
ge=1,
description="Optional ID of an existing global contact to copy data from"
)
class ContactUpdate(BaseModel): class ContactUpdate(BaseModel):

View File

@ -3098,13 +3098,48 @@
} }
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */ /* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
#caseTabs {
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
background: rgba(11, 53, 90, 0.36);
border-radius: 10px 10px 0 0;
padding: 0.2rem 0.3rem 0;
}
#caseTabs .nav-link { #caseTabs .nav-link {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 600;
color: rgba(255, 255, 255, 0.9);
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 0.58rem 0.9rem;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
#caseTabs .nav-link:hover,
#caseTabs .nav-link:focus-visible {
color: #ffffff;
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.28);
outline: none;
}
#caseTabs .nav-link.active {
color: #0f4c75;
background: #ffffff;
border-color: rgba(255, 255, 255, 0.78);
border-bottom-color: #ffffff;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
} }
#caseTabs .nav-link i { #caseTabs .nav-link i {
font-size: 1rem; font-size: 1rem;
opacity: 0.95;
}
#caseTabs .nav-link.active i {
color: #0f4c75;
opacity: 1;
} }
.case-detail-page-shell .header-bg .badge { .case-detail-page-shell .header-bg .badge {
@ -3357,10 +3392,10 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="case-detail-page-shell" style="background-color: #f1f5f9; min-height: 100vh;"> <div class="case-detail-page-shell" style="background: linear-gradient(to right, #0f4c75, #1e3c72) 0 0 / 100% 420px no-repeat, #ffffff; min-height: 100vh; margin-top: -1.35rem;">
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors --> <!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -1rem;"> <div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
<div class="container-fluid px-3 px-xl-4 position-relative"> <div class="container-fluid px-3 px-xl-4 position-relative">
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row"> <div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
@ -3391,7 +3426,7 @@
<div class="mb-1"> <div class="mb-1">
<div id="sag-titel-view" class="d-flex align-items-center gap-2"> <div id="sag-titel-view" class="d-flex align-items-center gap-2">
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="letter-spacing: -0.5px;">{{ case.titel }}</h2> <h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="font-size: 1.55rem; letter-spacing: -0.35px;">{{ case.titel }}</h2>
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button> <button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
</div> </div>
<div id="sag-titel-editor" class="d-none"> <div id="sag-titel-editor" class="d-none">
@ -3415,7 +3450,7 @@
</div> </div>
<!-- WIDGET DASHBOARD --> <!-- WIDGET DASHBOARD -->
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -1.25rem;"> <div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -0.95rem;">
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<!-- Widget 1: Klassifikation --> <!-- Widget 1: Klassifikation -->
@ -3426,7 +3461,7 @@
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" onchange="saveCaseStatusFromTopbar()" style="width: 62%;"> <select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% for st in status_options %} {% for st in status_options %}
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option> <option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
{% endfor %} {% endfor %}
@ -3435,7 +3470,7 @@
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" onchange="saveCaseTypeFromTopbar()" style="width: 62%;"> <select id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %} {% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option> <option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option> <option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
@ -3448,7 +3483,7 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" onchange="saveCasePriorityFromTopbar()" style="width: 62%;"> <select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% set topbar_priority = (case.priority or 'normal')|lower %} {% set topbar_priority = (case.priority or 'normal')|lower %}
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option> <option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option> <option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
@ -3468,7 +3503,7 @@
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;"> <select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<option value="">Ingen bruger</option> <option value="">Ingen bruger</option>
{% for user in assignment_users or [] %} {% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option> <option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
@ -3478,7 +3513,7 @@
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;"> <select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<option value="">Ingen gruppe</option> <option value="">Ingen gruppe</option>
{% for group in assignment_groups or [] %} {% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option> <option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
@ -3514,7 +3549,7 @@
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
<div class="input-group input-group-sm" style="width: 62%;"> <div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" onchange="saveCaseStartDateFromTopbar()"> <input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button> <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li> <li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
@ -3531,7 +3566,7 @@
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
<div class="input-group input-group-sm" style="width: 62%;"> <div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" onchange="updateDeferredUntil(this.value || null)"> <input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button> <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li> <li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
@ -3545,7 +3580,7 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label> <label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
<div class="input-group input-group-sm" style="width: 62%;"> <div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" onchange="updateDeadline(this.value || null)"> <input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}">
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button> <button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
</div> </div>
</div> </div>
@ -3568,8 +3603,8 @@
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div> <div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseAnyDeskModal()"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button> <button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
<button type="button" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseWorkOrderPrint()"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button> <button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
</div> </div>
</div> </div>
</div> </div>
@ -3642,6 +3677,93 @@
window.forceCaseTabActivation(tabId); window.forceCaseTabActivation(tabId);
return false; return false;
}; };
(function bindTopWidgetActions() {
const localCaseId = Number({{ case.id }});
const patchCase = async (payload) => {
const response = await fetch(`/api/v1/sag/${localCaseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
};
const bindChange = (id, handler) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', async () => {
try {
await handler(el);
} catch (error) {
console.error(`Top widget change failed for ${id}`, error);
if (typeof showToast === 'function') {
showToast('Kunne ikke gemme ændringen', 'danger');
}
}
});
};
bindChange('topbarStatusSelect', async (el) => {
await patchCase({ status: el.value || 'åben' });
location.reload();
});
bindChange('topbarTypeSelect', async (el) => {
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
location.reload();
});
bindChange('topbarPrioritySelect', async (el) => {
await patchCase({ priority: String(el.value || 'normal').toLowerCase() });
location.reload();
});
bindChange('tabsAssignmentUserSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ ansvarlig_bruger_id: raw ? Number(raw) : null });
location.reload();
});
bindChange('tabsAssignmentGroupSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ assigned_group_id: raw ? Number(raw) : null });
location.reload();
});
bindChange('topbarStartDateInput', async (el) => {
await patchCase({ start_date: el.value || null });
location.reload();
});
bindChange('topbarDeferredInput', async (el) => {
await patchCase({ deferred_until: el.value || null });
location.reload();
});
bindChange('topbarDeadlineInput', async (el) => {
await patchCase({ deadline: el.value || null });
location.reload();
});
const printBtn = document.getElementById('topbarPrintWorkOrderBtn');
if (printBtn) {
printBtn.addEventListener('click', () => {
window.open(`/sag/${localCaseId}/work-orders/print`, '_blank', 'noopener');
});
}
const anydeskBtn = document.getElementById('caseAnyDeskOpenBtn');
if (anydeskBtn && typeof window.openCaseAnyDeskModal === 'function') {
anydeskBtn.addEventListener('click', () => {
window.openCaseAnyDeskModal();
});
}
})();
</script> </script>
<!-- Tabs Navigation --> <!-- Tabs Navigation -->

View File

@ -349,20 +349,34 @@ function renderLinkContactResults(results) {
async function searchContacts(query) { async function searchContacts(query) {
const token = ++linkContactState.searchToken; const token = ++linkContactState.searchToken;
const container = document.getElementById('linkContactResults'); const container = document.getElementById('linkContactResults');
const trimmed = String(query || '').trim();
if (trimmed.length < 2) {
renderLinkContactResults([]);
return;
}
if (container) { if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>'; container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
} }
try { try {
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' }); const qs = new URLSearchParams({ q: trimmed });
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' }); let res = await fetch(`/api/v1/search/contacts?${qs.toString()}`, { credentials: 'include' });
// Backward compatible fallback in case search router is unavailable.
if (!res.ok && (res.status === 404 || res.status === 405)) {
const legacyQs = new URLSearchParams({ search: trimmed, limit: '30', offset: '0' });
res = await fetch(`/api/v1/contacts?${legacyQs.toString()}`, { credentials: 'include' });
}
if (token !== linkContactState.searchToken) return; if (token !== linkContactState.searchToken) return;
if (!res.ok) { if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>'; if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
return; return;
} }
const data = await res.json(); const data = await res.json();
const results = Array.isArray(data?.contacts) ? data.contacts : []; const results = Array.isArray(data) ? data : (Array.isArray(data?.contacts) ? data.contacts : []);
renderLinkContactResults(results); renderLinkContactResults(results);
} catch (e) { } catch (e) {
if (token !== linkContactState.searchToken) return; if (token !== linkContactState.searchToken) return;

View File

@ -882,6 +882,9 @@
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)"> <button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i> <i class="bi bi-plus-circle-fill fs-5"></i>
</button> </button>
<button class="btn btn-light rounded-circle border-0" id="bugReportBtn" style="background: var(--accent-light); color: var(--accent);" title="Rapporter fejl (Ctrl+Shift+B)">
<i class="bi bi-bug"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp"> <button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</button> </button>
@ -1263,12 +1266,14 @@ window.addEventListener('unhandledrejection', function(event) {
}); });
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.2"></script> <script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script> <script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script> <script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script> <script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script> <script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script> <script src="/static/js/bottom-bar.js?v=2.23"></script>
<script src="/static/js/bug-report.js?v=1.6"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }}; window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
@ -2018,6 +2023,9 @@ window.addEventListener('unhandledrejection', function(event) {
<!-- QuickCreate Modal (AI-Powered Case Creation) --> <!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %} {% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Bug Report Modal -->
{% include ["bug_report_modal.html", "shared/frontend/bug_report_modal.html"] ignore missing %}
<!-- Manual Help Modal --> <!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %} {% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}

View File

@ -0,0 +1,47 @@
<div class="modal fade" id="bugReportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-bug me-2"></i>Rapporter fejl</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
Screenshot tages automatisk af den aktuelle side.
</div>
<div class="mb-3">
<label for="bugActualInput" class="form-label">Hvad gik galt?</label>
<textarea id="bugActualInput" class="form-control" rows="4" maxlength="8000" placeholder="Beskriv problemet"></textarea>
</div>
<div class="mb-3">
<label for="bugExpectedInput" class="form-label">Hvad burde være sket?</label>
<textarea id="bugExpectedInput" class="form-control" rows="4" maxlength="8000" placeholder="Forventet adfærd"></textarea>
</div>
<div class="mb-3">
<label for="bugExtraFileInput" class="form-label">Ekstra fil (valgfri)</label>
<input id="bugExtraFileInput" type="file" class="form-control" />
</div>
<div class="mb-2">
<label class="form-label">Screenshot preview</label>
<div id="bugScreenshotPreviewWrap" class="border rounded p-2 bg-light text-center">
<span id="bugScreenshotPreviewPlaceholder" class="text-muted small">Ingen screenshot endnu</span>
<img id="bugScreenshotPreview" alt="Screenshot preview" style="display:none;max-width:100%;height:auto;border-radius:6px;" />
</div>
</div>
<div id="bugReportStatus" class="small text-muted mt-2"></div>
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Du kan også indsætte med Cmd+V.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="bugReportSubmitBtn">
<i class="bi bi-send me-1"></i>Send fejlrapport
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,90 @@
-- Fix malformed caller numbers for Per Krag's Yealink phone.
--
-- Run via:
-- podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d hubdb_v2 -f /dev/stdin < fix_telefoni_per_krag_numbers.sql
BEGIN;
-- STEP 1: Fix any remaining numbers that still have the 1857220892 suffix
-- (threshold lowered from >15 to >10 to catch shorter corrupted numbers)
UPDATE telefoni_opkald
SET ekstern_nummer = CASE
-- Too few remaining digits → NULL
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) <= 3
THEN NULL
-- 8 remaining digits = Danish number → +45 prefix
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
-- Other length → keep + prefix
ELSE '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 10;
-- STEP 2: Fix numbers that were previously half-patched: '+' + exactly 8 digits.
-- '+[8 digits]' is not valid E.164 (minimum 9 digits needed), so these are
-- Danish numbers that lost their +45 prefix during the first patch.
UPDATE telefoni_opkald
SET ekstern_nummer = '+45' || regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')
WHERE ekstern_nummer ~ '^\+[0-9]{8}$';
-- Verify: show any remaining suspicious numbers for this user
SELECT id, started_at, ekstern_nummer
FROM telefoni_opkald t
JOIN users u ON u.user_id = t.bruger_id
WHERE u.full_name ILIKE '%per krag%'
ORDER BY started_at DESC
LIMIT 20;
COMMIT;
-- Preview what will be changed
SELECT
id,
ekstern_nummer AS before,
CASE
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
THEN NULL
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
WHEN ekstern_nummer LIKE '+%'
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END AS after
FROM telefoni_opkald
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15
ORDER BY started_at DESC;
-- Apply the fix
UPDATE telefoni_opkald
SET ekstern_nummer = CASE
-- Strip last 10 digits (the local SIP suffix)
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
THEN NULL
-- 8 remaining digits = Danish number → prefix +45
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
-- Otherwise keep original + prefix
WHEN ekstern_nummer LIKE '+%'
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
SELECT concat('Retter ', count(*), ' rækker') AS resultat
FROM telefoni_opkald
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
COMMIT;

44
main.py
View File

@ -141,6 +141,7 @@ from app.modules.rentals.backend import router as rentals_api
from app.modules.task_templates.backend import router as task_templates_api from app.modules.task_templates.backend import router as task_templates_api
from app.economy.backend import router as economy_api from app.economy.backend import router as economy_api
from app.economy.frontend import views as economy_views from app.economy.frontend import views as economy_views
from app.bug_reports.backend import router as bug_reports_api
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -154,6 +155,34 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class _AccessLogPathFilter(logging.Filter):
"""Filter out high-frequency access log noise for specific polling endpoints."""
def filter(self, record: logging.LogRecord) -> bool:
try:
target = "/api/v1/bottom-bar/state"
# Match rendered access message (works across uvicorn format variations).
message = record.getMessage() or ""
if f"GET {target}" in message or f'"GET {target}' in message:
return False
# Fallback: match structured args when available.
args = record.args if isinstance(record.args, tuple) else ()
if len(args) >= 3:
method = str(args[1])
path = str(args[2])
if method == "GET" and path.startswith(target):
return False
except Exception:
# Never break logging on filter errors.
return True
return True
logging.getLogger("uvicorn.access").addFilter(_AccessLogPathFilter())
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Lifecycle management - startup and shutdown""" """Lifecycle management - startup and shutdown"""
@ -460,6 +489,7 @@ app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"]) app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"]) app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api from app.modules.links.backend import router as links_api
@ -516,7 +546,19 @@ async def health_check():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
from copy import deepcopy
from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG
import os import os
log_config = deepcopy(UVICORN_LOGGING_CONFIG)
log_config.setdefault("filters", {})["suppress_bottom_bar_polling"] = {
"()": _AccessLogPathFilter,
}
access_handler = log_config.get("handlers", {}).get("access", {})
existing_filters = list(access_handler.get("filters", []))
if "suppress_bottom_bar_polling" not in existing_filters:
existing_filters.append("suppress_bottom_bar_polling")
access_handler["filters"] = existing_filters
# Only enable reload in local development (not in Docker) - check both variables # Only enable reload in local development (not in Docker) - check both variables
enable_reload = ( enable_reload = (
@ -533,6 +575,7 @@ if __name__ == "__main__":
reload_includes=["*.py"], reload_includes=["*.py"],
reload_dirs=["app"], reload_dirs=["app"],
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"], reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
log_config=log_config,
log_level="info" log_level="info"
) )
else: else:
@ -544,5 +587,6 @@ if __name__ == "__main__":
workers=2, workers=2,
timeout_keep_alive=65, timeout_keep_alive=65,
access_log=True, access_log=True,
log_config=log_config,
log_level="info" log_level="info"
) )

View File

@ -0,0 +1,15 @@
-- Track bug report submissions for rate limiting and auditing.
CREATE TABLE IF NOT EXISTS bug_report_submissions (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
screenshot_attached BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user_time
ON bug_report_submissions(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_sag
ON bug_report_submissions(sag_id);

452
static/js/bug-report.js Normal file
View File

@ -0,0 +1,452 @@
(function () {
const logs = [];
const MAX_LOGS = 200;
let bugModal = null;
let screenshotDataUrl = null;
let pendingScreenshotPromise = null;
const pushLog = (type, args) => {
try {
logs.push({
type,
message: (args || []).map((x) => {
if (typeof x === 'string') return x;
try {
return JSON.stringify(x);
} catch (_) {
return String(x);
}
}).join(' '),
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
} catch (_) {
// no-op
}
};
['log', 'warn', 'error'].forEach((type) => {
const original = console[type];
console[type] = function (...args) {
pushLog(type, args);
original.apply(console, args);
};
});
window.addEventListener('error', (event) => {
logs.push({
type: 'error',
message: event.message,
url: event.filename,
line: event.lineno,
col: event.colno,
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
});
function getCurrentUser() {
const metaUser = document.querySelector('meta[name="user-id"]');
const userFromMeta = metaUser ? metaUser.getAttribute('content') : null;
let userFromToken = null;
const token = localStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
userFromToken = payload.sub || payload.user_id || null;
} catch (_) {
userFromToken = null;
}
}
return userFromToken || userFromMeta || null;
}
function getMetadata() {
return {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
screenSize: `${window.innerWidth}x${window.innerHeight}`,
user: getCurrentUser(),
};
}
async function toDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function isCrossOriginUrl(url) {
try {
if (!url) return false;
const parsed = new URL(url, window.location.href);
return parsed.origin !== window.location.origin;
} catch (_) {
return false;
}
}
function shouldIgnoreInScreenshot(el) {
if (!el || !el.tagName) return false;
const tag = String(el.tagName).toUpperCase();
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
return true;
}
if (tag === 'IMG') {
const src = el.getAttribute('src') || '';
return isCrossOriginUrl(src);
}
return false;
}
async function renderScreenshot(target, opts) {
const canvas = await window.html2canvas(target, opts);
return canvas.toDataURL('image/png');
}
async function takeScreenshotViaDisplayMedia() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('Display media API not supported');
}
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
try {
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error('Kunne ikke læse videostream'));
});
await video.play();
const width = Math.max(1, video.videoWidth || window.innerWidth);
const height = Math.max(1, video.videoHeight || window.innerHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context unavailable');
}
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/png');
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
async function takeScreenshot() {
if (!window.html2canvas) {
throw new Error('Screenshot library not loaded');
}
const doc = document.documentElement;
const body = document.body;
const fullWidth = Math.max(
doc ? doc.scrollWidth : 0,
doc ? doc.offsetWidth : 0,
doc ? doc.clientWidth : 0,
body ? body.scrollWidth : 0,
body ? body.offsetWidth : 0,
window.innerWidth || 0
);
const fullHeight = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
doc ? doc.clientHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
window.innerHeight || 0
);
const common = {
useCORS: true,
allowTaint: false,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 3000,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
...common,
width: fullWidth,
height: fullHeight,
windowWidth: fullWidth,
windowHeight: fullHeight,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
const contentRoot =
document.querySelector('.container-fluid.px-4.py-4') ||
document.querySelector('[data-bugreport-root]') ||
document.querySelector('#main-content') ||
document.querySelector('#content') ||
document.querySelector('main') ||
document.querySelector('.content-wrapper') ||
document.documentElement;
try {
return await renderScreenshot(contentRoot, {
...common,
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
scrollX: 0,
scrollY: 0,
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
}
throw new Error('Automatic screenshot failed');
}
function setStatus(text, isError) {
const el = document.getElementById('bugReportStatus');
if (!el) return;
el.textContent = text || '';
el.className = isError ? 'small text-danger mt-2' : 'small text-muted mt-2';
}
function setPreview(dataUrl) {
const img = document.getElementById('bugScreenshotPreview');
const placeholder = document.getElementById('bugScreenshotPreviewPlaceholder');
if (!img || !placeholder) return;
if (dataUrl) {
img.src = dataUrl;
img.style.display = '';
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.style.display = 'none';
placeholder.style.display = '';
}
}
async function handlePasteScreenshot(event) {
const modalVisible = document.getElementById('bugReportModal')?.classList.contains('show');
if (!modalVisible) return;
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find((item) => item.kind === 'file' && item.type.startsWith('image/'));
if (!imageItem) return;
const blob = imageItem.getAsFile();
if (!blob) return;
event.preventDefault();
try {
const dataUrl = await toDataUrl(blob);
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot indsat fra clipboard.');
} catch (e) {
console.warn('Clipboard screenshot parse failed', e);
setStatus('Kunne ikke indsætte screenshot fra clipboard.', true);
}
}
async function openBugReportModal() {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
if (!modalEl || !window.bootstrap) return;
bugModal = new bootstrap.Modal(modalEl);
}
setStatus('Tager screenshot...');
screenshotDataUrl = null;
setPreview(null);
try {
screenshotDataUrl = pendingScreenshotPromise
? await pendingScreenshotPromise
: await takeScreenshot();
setPreview(screenshotDataUrl);
setStatus('Screenshot klar. Udfyld felterne og send.');
} catch (e) {
console.warn('Bug report screenshot failed', e);
setStatus('Kunne ikke tage screenshot automatisk. Vedhæft et billede manuelt eller indsæt med Cmd+V.', true);
} finally {
pendingScreenshotPromise = null;
}
bugModal.show();
}
function prepareScreenshotFromTrigger() {
pendingScreenshotPromise = (async () => {
try {
return await takeScreenshot();
} catch (e) {
console.warn('Bug report html2canvas capture failed, trying display media', e);
return await takeScreenshotViaDisplayMedia();
}
})();
return pendingScreenshotPromise;
}
async function submitBugReport() {
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
const fileInput = document.getElementById('bugExtraFileInput');
const submitBtn = document.getElementById('bugReportSubmitBtn');
if (!actual || !expected) {
setStatus('Udfyld både "Hvad gik galt" og "Hvad burde være sket".', true);
return;
}
let extraFileName = null;
let extraFileBase64 = null;
if (fileInput && fileInput.files && fileInput.files[0]) {
const f = fileInput.files[0];
extraFileName = f.name;
try {
extraFileBase64 = await toDataUrl(f);
} catch (_) {
setStatus('Kunne ikke læse ekstra fil.', true);
return;
}
}
const payload = {
actual,
expected,
screenshot_base64: screenshotDataUrl,
metadata: getMetadata(),
logs,
extra_file_name: extraFileName,
extra_file_base64: extraFileBase64,
};
const prevText = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
}
try {
const endpoints = ['/api/v1/bug-reports', '/bug-reports'];
let res = null;
let data = {};
for (const endpoint of endpoints) {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
data = await res.json().catch(() => ({}));
// Try fallback endpoint only when path is missing.
if (res.status === 404) {
continue;
}
break;
}
if (!res || !res.ok) {
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
throw new Error(detail);
}
setStatus('Fejl rapporteret.');
const target = data.case_url || '/sag';
setTimeout(() => {
window.location.href = target;
}, 500);
} catch (e) {
setStatus(e.message || 'Kunne ikke sende fejlrapport', true);
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = prevText;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('bugReportBtn');
const submitBtn = document.getElementById('bugReportSubmitBtn');
const modalEl = document.getElementById('bugReportModal');
if (btn) {
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
submitBugReport();
});
}
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
const actual = document.getElementById('bugActualInput');
const expected = document.getElementById('bugExpectedInput');
const fileInput = document.getElementById('bugExtraFileInput');
if (actual) actual.value = '';
if (expected) expected.value = '';
if (fileInput) fileInput.value = '';
screenshotDataUrl = null;
setPreview(null);
setStatus('');
});
}
document.addEventListener('paste', (e) => {
handlePasteScreenshot(e);
});
document.addEventListener('keydown', (e) => {
const isTyping = ['INPUT', 'TEXTAREA'].includes((e.target?.tagName || '').toUpperCase());
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
}
});
});
})();

View File

@ -28,9 +28,6 @@ fi
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown") CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
CURRENT_DIR=$(pwd) CURRENT_DIR=$(pwd)
DEFAULT_STACK_NAME="prod" DEFAULT_STACK_NAME="prod"
if [ "$CURRENT_DIR" = "/srv/podman/bmc_hub_v2" ]; then
DEFAULT_STACK_NAME="v2"
fi
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!" echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
@ -166,9 +163,14 @@ fi
# Guard against host port conflicts before attempting startup # Guard against host port conflicts before attempting startup
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}" POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_PORT="${POSTGRES_PORT:-5432}"
if podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" >/dev/null 2>&1; then PORT_HOLDERS=$(podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" || true)
if [ -n "$PORT_HOLDERS" ]; then
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)" echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433" echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
if echo "$PORT_HOLDERS" | grep -q 'bmc-hub-postgres-prod' && [ "$STACK_NAME" != "prod" ]; then
echo " Tip: Aktiv prod-stack fundet. Kør med korrekt stack-navn:"
echo " STACK_NAME=prod ./updateto.sh $VERSION"
fi
podman ps --format 'table {{.Names}}\t{{.Ports}}' podman ps --format 'table {{.Names}}\t{{.Ports}}'
exit 1 exit 1
fi fi