diff --git a/app/core/config.py b/app/core/config.py
index bc3ded9..74607c5 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -67,6 +67,19 @@ class Settings(BaseSettings):
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
+ # Ticket System Module
+ TICKET_ENABLED: bool = True
+ TICKET_EMAIL_INTEGRATION: bool = False # 🚨 SAFETY: Disable email-to-ticket until configured
+ TICKET_AUTO_ASSIGN: bool = False # Auto-assign tickets based on rules
+ TICKET_DEFAULT_PRIORITY: str = "normal" # low|normal|high|urgent
+ TICKET_REQUIRE_CUSTOMER: bool = False # Allow tickets without customer link
+ TICKET_NOTIFICATION_ENABLED: bool = False # Notify on status changes
+
+ # Ticket System - e-conomic Integration
+ TICKET_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Block all writes to e-conomic
+ TICKET_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log without executing
+ TICKET_ECONOMIC_AUTO_EXPORT: bool = False # Auto-export billable worklog
+
# Email System Configuration
EMAIL_TO_TICKET_ENABLED: bool = False # 🚨 SAFETY: Disable auto-processing until configured
diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py
index 97b09f0..00e3de3 100644
--- a/app/services/email_workflow_service.py
+++ b/app/services/email_workflow_service.py
@@ -266,7 +266,8 @@ class EmailWorkflowService:
try:
# Dispatch to specific action handler
handler_map = {
- 'create_ticket': self._action_create_ticket,
+ 'create_ticket': self._action_create_ticket_system,
+ 'link_email_to_ticket': self._action_link_email_to_ticket,
'create_time_entry': self._action_create_time_entry,
'link_to_vendor': self._action_link_to_vendor,
'link_to_customer': self._action_link_to_customer,
@@ -302,19 +303,81 @@ class EmailWorkflowService:
# Action Handlers
- async def _action_create_ticket(self, params: Dict, email_data: Dict) -> Dict:
- """Create a ticket/case from email"""
- module = params.get('module', 'support_cases')
- priority = params.get('priority', 'normal')
+ async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict:
+ """Create a ticket from email using new ticket system"""
+ from app.ticket.backend.email_integration import EmailTicketIntegration
- # TODO: Integrate with actual case/ticket system
- logger.info(f"🎫 Would create ticket in module '{module}' with priority '{priority}'")
+ # Build email_data dict for ticket integration
+ ticket_email_data = {
+ 'message_id': email_data.get('message_id'),
+ 'subject': email_data.get('subject'),
+ 'from_address': email_data.get('sender_email'),
+ 'body': email_data.get('body_text', ''),
+ 'html_body': email_data.get('body_html'),
+ 'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
+ 'in_reply_to': None, # TODO: Extract from email headers
+ 'references': None # TODO: Extract from email headers
+ }
+
+ # Get params from workflow
+ customer_id = params.get('customer_id') or email_data.get('customer_id')
+ assigned_to_user_id = params.get('assigned_to_user_id')
+
+ logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}")
+
+ result = await EmailTicketIntegration.process_email_for_ticket(
+ email_data=ticket_email_data,
+ customer_id=customer_id,
+ assigned_to_user_id=assigned_to_user_id
+ )
+
+ logger.info(f"✅ Created ticket {result.get('ticket_number')} from email")
return {
'action': 'create_ticket',
- 'module': module,
- 'priority': priority,
- 'note': 'Ticket creation not yet implemented'
+ 'ticket_id': result.get('ticket_id'),
+ 'ticket_number': result.get('ticket_number'),
+ 'created': result.get('created', False),
+ 'linked': result.get('linked', False)
+ }
+
+ async def _action_link_email_to_ticket(self, params: Dict, email_data: Dict) -> Dict:
+ """Link email to existing ticket"""
+ from app.ticket.backend.email_integration import EmailTicketIntegration
+
+ ticket_number = params.get('ticket_number')
+
+ if not ticket_number:
+ logger.warning("⚠️ No ticket_number provided for link_email_to_ticket action")
+ return {
+ 'action': 'link_email_to_ticket',
+ 'status': 'failed',
+ 'error': 'ticket_number required'
+ }
+
+ ticket_email_data = {
+ 'message_id': email_data.get('message_id'),
+ 'subject': email_data.get('subject'),
+ 'from_address': email_data.get('sender_email'),
+ 'body': email_data.get('body_text', ''),
+ 'html_body': email_data.get('body_html'),
+ 'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
+ }
+
+ logger.info(f"🔗 Linking email to ticket {ticket_number}")
+
+ result = await EmailTicketIntegration.link_email_to_ticket(
+ ticket_number=ticket_number,
+ email_data=ticket_email_data
+ )
+
+ logger.info(f"✅ Linked email to ticket {ticket_number}")
+
+ return {
+ 'action': 'link_email_to_ticket',
+ 'ticket_id': result.get('ticket_id'),
+ 'ticket_number': result.get('ticket_number'),
+ 'linked': True
}
async def _action_create_time_entry(self, params: Dict, email_data: Dict) -> Dict:
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 6154478..78d3262 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -231,8 +231,12 @@
Support
@@ -491,9 +495,11 @@
+{% block content_wrapper %}
{% block content %}{% endblock %}
+{% endblock %}
+{% endblock %}
diff --git a/app/ticket/frontend/mockups/option1_splitscreen.html b/app/ticket/frontend/mockups/option1_splitscreen.html
new file mode 100644
index 0000000..fcb25f8
--- /dev/null
+++ b/app/ticket/frontend/mockups/option1_splitscreen.html
@@ -0,0 +1,265 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Concept 1: Split Screen - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content_wrapper %}
+
+{% endblock %}
diff --git a/app/ticket/frontend/mockups/option2_kanban.html b/app/ticket/frontend/mockups/option2_kanban.html
new file mode 100644
index 0000000..021092e
--- /dev/null
+++ b/app/ticket/frontend/mockups/option2_kanban.html
@@ -0,0 +1,251 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Concept 2: Kanban Board - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content_wrapper %}
+
+
+
+
+
+ Ny
+ 3
+
+
+
+
+ #TKT-005
+
+
+
Server nede i produktion
+
+ Server
+ Kritisk
+
+
+
+
+
+
+ #TKT-008
+
+
+
Ny medarbejder oprettelse
+
+
+
+
+
+
+
+
+
+
+ I Gang
+ 2
+
+
+
+
+
#TKT-002
+

+
+
Netværksproblem hovedkontor
+
+ Netværk
+
+
+
+
+
+
+
#TKT-004
+

+
+
Opdatering af firewall
+
+
+
+
+
+
+
+
+ Venter
+ 4
+
+
+ {% for i in range(4) %}
+
+
+ #TKT-01{{i}}
+
+
+
Bestilling af hardware {{i}}
+
+
+ {% endfor %}
+
+
+
+
+
+
+ Løst
+ 12
+
+
+
+
+ #TKT-001
+
+
+
Printer installation
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/ticket/frontend/mockups/option3_powertable.html b/app/ticket/frontend/mockups/option3_powertable.html
new file mode 100644
index 0000000..e4acc49
--- /dev/null
+++ b/app/ticket/frontend/mockups/option3_powertable.html
@@ -0,0 +1,239 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Concept 3: Power Table - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
Alle Tickets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Viser 1-50 af 142 tickets
+
+
+
+
+
+
+
3 valgt
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html
new file mode 100644
index 0000000..478682f
--- /dev/null
+++ b/app/ticket/frontend/ticket_detail.html
@@ -0,0 +1,409 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Beskrivelse
+
+
+ {{ ticket.description or 'Ingen beskrivelse' }}
+
+
+
+
+
+
+
+
+ Kommentarer ({{ comments|length }})
+
+ {% if comments %}
+ {% for comment in comments %}
+
+ {% endfor %}
+ {% else %}
+
+
+
Ingen kommentarer endnu
+
+ {% endif %}
+
+
+
+
+
+
+
+ Worklog ({{ worklog|length }})
+
+ {% if worklog %}
+
+
+
+
+ | Dato |
+ Timer |
+ Type |
+ Beskrivelse |
+ Status |
+ Medarbejder |
+
+
+
+ {% for entry in worklog %}
+
+ | {{ entry.work_date.strftime('%d-%m-%Y') if entry.work_date else '-' }} |
+ {{ "%.2f"|format(entry.hours) }}t |
+ {{ entry.work_type }} |
+ {{ entry.description or '-' }} |
+
+
+ {{ entry.status }}
+
+ |
+ {{ entry.user_name or '-' }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
Ingen worklog entries endnu
+
+ {% endif %}
+
+
+
+
+ {% if attachments %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ Ticket Information
+
+
+
+
+
+ {{ ticket.assigned_to_name or 'Ikke tildelt' }}
+
+
+
+
+
+ {{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
+
+
+
+
+
+ {{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}
+
+
+ {% if ticket.resolved_at %}
+
+
+
+ {{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}
+
+
+ {% endif %}
+ {% if ticket.first_response_at %}
+
+
+
+ {{ ticket.first_response_at.strftime('%d-%m-%Y %H:%M') }}
+
+
+ {% endif %}
+
+
+
+
+ {% if ticket.tags %}
+
+
+
+ Tags
+
+ {% for tag in ticket.tags %}
+
#{{ tag }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/ticket/frontend/ticket_list.html b/app/ticket/frontend/ticket_list.html
new file mode 100644
index 0000000..1f16c9a
--- /dev/null
+++ b/app/ticket/frontend/ticket_list.html
@@ -0,0 +1,272 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Alle Tickets - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Alle Tickets
+
+
Oversigt over alle tickets i systemet
+
+
+ Ny Ticket
+
+
+
+
+
+
+
+ {% if tickets %}
+
+
+
+
+
+ | Ticket |
+ Kunde |
+ Status |
+ Prioritet |
+ Tildelt |
+ Aktivitet |
+ Oprettet |
+
+
+
+ {% for ticket in tickets %}
+
+
+ {{ ticket.ticket_number }}
+
+ {{ ticket.subject }}
+ |
+
+ {% if ticket.customer_name %}
+ {{ ticket.customer_name }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+
+ {{ ticket.status.replace('_', ' ').title() }}
+
+ |
+
+
+ {{ ticket.priority.title() }}
+
+ |
+
+ {% if ticket.assigned_to_name %}
+ {{ ticket.assigned_to_name }}
+ {% else %}
+ Ikke tildelt
+ {% endif %}
+ |
+
+
+ {{ ticket.comment_count }}
+ {{ ticket.worklog_count }}
+
+ |
+
+ {{ ticket.created_at.strftime('%d-%m-%Y') if ticket.created_at else '-' }}
+
+ {{ ticket.created_at.strftime('%H:%M') if ticket.created_at else '' }}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ Viser {{ tickets|length }} tickets
+
+ {% else %}
+
+
+
+
Ingen tickets fundet
+
Prøv at justere dine filtre eller opret en ny ticket
+ {% if selected_status or selected_priority or selected_customer_id or search_query %}
+
+ Ryd filtre
+
+ {% endif %}
+
+
+ {% endif %}
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/ticket/frontend/views.py b/app/ticket/frontend/views.py
new file mode 100644
index 0000000..f089fb1
--- /dev/null
+++ b/app/ticket/frontend/views.py
@@ -0,0 +1,451 @@
+"""
+Ticket System Frontend Views
+HTML template routes for ticket management UI
+"""
+
+import logging
+from fastapi import APIRouter, Request, HTTPException, Form
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+from typing import Optional
+from datetime import date
+
+from app.core.database import execute_query, execute_update
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app")
+
+
+# ============================================================================
+# MOCKUP ROUTES (TEMPORARY)
+# ============================================================================
+
+@router.get("/mockups/1", response_class=HTMLResponse)
+async def mockup_option1(request: Request):
+ """Mockup: Split Screen Concept"""
+ return templates.TemplateResponse("ticket/frontend/mockups/option1_splitscreen.html", {"request": request})
+
+@router.get("/mockups/2", response_class=HTMLResponse)
+async def mockup_option2(request: Request):
+ """Mockup: Kanban Board Concept"""
+ return templates.TemplateResponse("ticket/frontend/mockups/option2_kanban.html", {"request": request})
+
+@router.get("/mockups/3", response_class=HTMLResponse)
+async def mockup_option3(request: Request):
+ """Mockup: Power Table Concept"""
+ return templates.TemplateResponse("ticket/frontend/mockups/option3_powertable.html", {"request": request})
+
+
+@router.get("/worklog/review", response_class=HTMLResponse)
+async def worklog_review_page(
+ request: Request,
+ customer_id: Optional[int] = None,
+ status: str = "draft"
+):
+ """
+ Worklog review page with single-entry approval
+
+ Query params:
+ customer_id: Filter by customer (optional)
+ status: Filter by status (default: draft)
+ """
+ try:
+ # Build query with filters
+ query = """
+ SELECT
+ w.id,
+ w.ticket_id,
+ w.user_id,
+ w.work_date,
+ w.hours,
+ w.work_type,
+ w.description,
+ w.billing_method,
+ w.status,
+ w.prepaid_card_id,
+ w.created_at,
+ t.ticket_number,
+ t.subject AS ticket_subject,
+ t.customer_id,
+ t.status AS ticket_status,
+ c.name AS customer_name,
+ u.username AS user_name,
+ pc.card_number,
+ pc.remaining_hours AS card_remaining_hours
+ FROM tticket_worklog w
+ INNER JOIN tticket_tickets t ON t.id = w.ticket_id
+ LEFT JOIN customers c ON c.id = t.customer_id
+ LEFT JOIN users u ON u.user_id = w.user_id
+ LEFT JOIN tticket_prepaid_cards pc ON pc.id = w.prepaid_card_id
+ WHERE w.status = %s
+ """
+
+ params = [status]
+
+ if customer_id:
+ query += " AND t.customer_id = %s"
+ params.append(customer_id)
+
+ query += " ORDER BY w.work_date DESC, w.created_at DESC"
+
+ worklogs = execute_query(query, tuple(params))
+
+ # Get customer list for filter dropdown
+ customers_query = """
+ SELECT DISTINCT c.id, c.name
+ FROM customers c
+ INNER JOIN tticket_tickets t ON t.customer_id = c.id
+ INNER JOIN tticket_worklog w ON w.ticket_id = t.id
+ WHERE w.status = %s
+ ORDER BY c.name
+ """
+ customers = execute_query(customers_query, (status,))
+
+ # Calculate totals
+ total_hours = sum(float(w['hours']) for w in worklogs)
+ total_billable = sum(
+ float(w['hours'])
+ for w in worklogs
+ if w['billing_method'] == 'invoice'
+ )
+
+ return templates.TemplateResponse(
+ "ticket/frontend/worklog_review.html",
+ {
+ "request": request,
+ "worklogs": worklogs,
+ "customers": customers,
+ "selected_customer_id": customer_id,
+ "selected_status": status,
+ "total_hours": total_hours,
+ "total_billable_hours": total_billable,
+ "total_entries": len(worklogs)
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"❌ Failed to load worklog review page: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/worklog/{worklog_id}/approve")
+async def approve_worklog_entry(
+ worklog_id: int,
+ redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
+):
+ """
+ Approve single worklog entry (change status draft → billable)
+
+ Form params:
+ redirect_to: URL to redirect after approval
+ """
+ try:
+ # Check entry exists and is draft
+ check_query = """
+ SELECT id, status, billing_method
+ FROM tticket_worklog
+ WHERE id = %s
+ """
+ entry = execute_query(check_query, (worklog_id,), fetchone=True)
+
+ if not entry:
+ raise HTTPException(status_code=404, detail="Worklog entry not found")
+
+ if entry['status'] != 'draft':
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cannot approve entry with status '{entry['status']}'"
+ )
+
+ # Approve entry
+ update_query = """
+ UPDATE tticket_worklog
+ SET status = 'billable',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """
+ execute_update(update_query, (worklog_id,))
+
+ logger.info(f"✅ Approved worklog entry {worklog_id}")
+
+ return RedirectResponse(url=redirect_to, status_code=303)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Failed to approve worklog entry: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/worklog/{worklog_id}/reject")
+async def reject_worklog_entry(
+ worklog_id: int,
+ reason: Optional[str] = Form(default=None),
+ redirect_to: Optional[str] = Form(default="/ticket/worklog/review")
+):
+ """
+ Reject single worklog entry (change status draft → rejected)
+
+ Form params:
+ reason: Rejection reason (optional)
+ redirect_to: URL to redirect after rejection
+ """
+ try:
+ # Check entry exists and is draft
+ check_query = """
+ SELECT id, status
+ FROM tticket_worklog
+ WHERE id = %s
+ """
+ entry = execute_query(check_query, (worklog_id,), fetchone=True)
+
+ if not entry:
+ raise HTTPException(status_code=404, detail="Worklog entry not found")
+
+ if entry['status'] != 'draft':
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cannot reject entry with status '{entry['status']}'"
+ )
+
+ # Reject entry (store reason in description)
+ update_query = """
+ UPDATE tticket_worklog
+ SET status = 'rejected',
+ description = COALESCE(description, '') ||
+ CASE WHEN %s IS NOT NULL
+ THEN E'\n\n[REJECTED: ' || %s || ']'
+ ELSE E'\n\n[REJECTED]'
+ END,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """
+ execute_update(update_query, (reason, reason, worklog_id))
+
+ logger.info(f"❌ Rejected worklog entry {worklog_id}" + (f": {reason}" if reason else ""))
+
+ return RedirectResponse(url=redirect_to, status_code=303)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Failed to reject worklog entry: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/dashboard", response_class=HTMLResponse)
+async def ticket_dashboard(request: Request):
+ """
+ Ticket system dashboard with statistics
+ """
+ try:
+ # Get ticket statistics
+ stats_query = """
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'open') AS open_count,
+ COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress_count,
+ COUNT(*) FILTER (WHERE status = 'pending_customer') AS pending_count,
+ COUNT(*) FILTER (WHERE status = 'resolved') AS resolved_count,
+ COUNT(*) FILTER (WHERE status = 'closed') AS closed_count,
+ COUNT(*) AS total_count
+ FROM tticket_tickets
+ """
+ stats = execute_query(stats_query, fetchone=True)
+
+ # Get recent tickets
+ recent_query = """
+ SELECT
+ t.id,
+ t.ticket_number,
+ t.subject,
+ t.status,
+ t.priority,
+ t.created_at,
+ c.name AS customer_name
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON c.id = t.customer_id
+ ORDER BY t.created_at DESC
+ LIMIT 10
+ """
+ recent_tickets = execute_query(recent_query)
+
+ # Get worklog statistics
+ worklog_stats_query = """
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'draft') AS draft_count,
+ COALESCE(SUM(hours) FILTER (WHERE status = 'draft'), 0) AS draft_hours,
+ COUNT(*) FILTER (WHERE status = 'billable') AS billable_count,
+ COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
+ FROM tticket_worklog
+ """
+ worklog_stats = execute_query(worklog_stats_query, fetchone=True)
+
+ return templates.TemplateResponse(
+ "ticket/frontend/dashboard.html",
+ {
+ "request": request,
+ "stats": stats,
+ "recent_tickets": recent_tickets,
+ "worklog_stats": worklog_stats
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"❌ Failed to load dashboard: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/tickets", response_class=HTMLResponse)
+async def ticket_list_page(
+ request: Request,
+ status: Optional[str] = None,
+ priority: Optional[str] = None,
+ customer_id: Optional[int] = None,
+ search: Optional[str] = None
+):
+ """
+ Ticket list page with filters
+ """
+ try:
+ # Build query with filters
+ query = """
+ SELECT
+ t.id,
+ t.ticket_number,
+ t.subject,
+ t.status,
+ t.priority,
+ t.created_at,
+ t.updated_at,
+ c.name AS customer_name,
+ u.username AS assigned_to_name,
+ (SELECT COUNT(*) FROM tticket_comments WHERE ticket_id = t.id) AS comment_count,
+ (SELECT COUNT(*) FROM tticket_worklog WHERE ticket_id = t.id) AS worklog_count
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON c.id = t.customer_id
+ LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
+ WHERE 1=1
+ """
+
+ params = []
+
+ if status:
+ query += " AND t.status = %s"
+ params.append(status)
+
+ if priority:
+ query += " AND t.priority = %s"
+ params.append(priority)
+
+ if customer_id:
+ query += " AND t.customer_id = %s"
+ params.append(customer_id)
+
+ if search:
+ query += " AND (t.subject ILIKE %s OR t.description ILIKE %s OR t.ticket_number ILIKE %s)"
+ search_pattern = f"%{search}%"
+ params.extend([search_pattern, search_pattern, search_pattern])
+
+ query += " ORDER BY t.created_at DESC LIMIT 100"
+
+ tickets = execute_query(query, tuple(params)) if params else execute_query(query)
+
+ # Get filter options
+ customers = execute_query(
+ """SELECT DISTINCT c.id, c.name
+ FROM customers c
+ INNER JOIN tticket_tickets t ON t.customer_id = c.id
+ ORDER BY c.name"""
+ )
+
+ return templates.TemplateResponse(
+ "ticket/frontend/ticket_list.html",
+ {
+ "request": request,
+ "tickets": tickets,
+ "customers": customers,
+ "selected_status": status,
+ "selected_priority": priority,
+ "selected_customer_id": customer_id,
+ "search_query": search
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"❌ Failed to load ticket list: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
+async def ticket_detail_page(request: Request, ticket_id: int):
+ """
+ Ticket detail page with comments and worklog
+ """
+ try:
+ # Get ticket details
+ ticket_query = """
+ SELECT
+ t.*,
+ c.name AS customer_name,
+ c.email AS customer_email,
+ u.username AS assigned_to_name
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON c.id = t.customer_id
+ LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
+ WHERE t.id = %s
+ """
+ ticket = execute_query(ticket_query, (ticket_id,), fetchone=True)
+
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ # Get comments
+ comments_query = """
+ SELECT
+ c.*,
+ u.username AS user_name
+ FROM tticket_comments c
+ LEFT JOIN users u ON u.user_id = c.user_id
+ WHERE c.ticket_id = %s
+ ORDER BY c.created_at ASC
+ """
+ comments = execute_query(comments_query, (ticket_id,))
+
+ # Get worklog
+ worklog_query = """
+ SELECT
+ w.*,
+ u.username AS user_name
+ FROM tticket_worklog w
+ LEFT JOIN users u ON u.user_id = w.user_id
+ WHERE w.ticket_id = %s
+ ORDER BY w.work_date DESC, w.created_at DESC
+ """
+ worklog = execute_query(worklog_query, (ticket_id,))
+
+ # Get attachments
+ attachments_query = """
+ SELECT * FROM tticket_attachments
+ WHERE ticket_id = %s
+ ORDER BY created_at DESC
+ """
+ attachments = execute_query(attachments_query, (ticket_id,))
+
+ return templates.TemplateResponse(
+ "ticket/frontend/ticket_detail.html",
+ {
+ "request": request,
+ "ticket": ticket,
+ "comments": comments,
+ "worklog": worklog,
+ "attachments": attachments
+ }
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Failed to load ticket detail: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/ticket/frontend/worklog_review.html b/app/ticket/frontend/worklog_review.html
new file mode 100644
index 0000000..1b1eca8
--- /dev/null
+++ b/app/ticket/frontend/worklog_review.html
@@ -0,0 +1,560 @@
+
+
+
+
+
+ Worklog Godkendelse - BMC Hub
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Worklog Godkendelse
+
+
Godkend eller afvis enkelt-entries fra draft worklog
+
+
+
+
+
+
+
+
{{ total_entries }}
+
Entries til godkendelse
+
+
+
+
+
{{ "%.2f"|format(total_hours) }}t
+
Total timer
+
+
+
+
+
{{ "%.2f"|format(total_billable_hours) }}t
+
Fakturerbare timer
+
+
+
+
+
+
+
+
+ {% if worklogs %}
+
+
+
+
+
+ | Ticket |
+ Kunde |
+ Dato |
+ Timer |
+ Type |
+ Fakturering |
+ Beskrivelse |
+ Medarbejder |
+ Handlinger |
+
+
+
+ {% for worklog in worklogs %}
+
+
+ {{ worklog.ticket_number }}
+
+ {{ worklog.ticket_subject[:30] }}...
+ |
+
+ {% if worklog.customer_name %}
+ {{ worklog.customer_name }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {{ worklog.work_date.strftime('%d-%m-%Y') if worklog.work_date else '-' }}
+ |
+
+ {{ "%.2f"|format(worklog.hours) }}t
+ |
+
+ {% if worklog.work_type == 'support' %}
+ Support
+ {% elif worklog.work_type == 'development' %}
+ Udvikling
+ {% else %}
+ {{ worklog.work_type }}
+ {% endif %}
+ |
+
+ {% if worklog.billing_method == 'invoice' %}
+
+ Faktura
+
+ {% elif worklog.billing_method == 'prepaid' %}
+
+ Klippekort
+
+ {% if worklog.card_number %}
+ {{ worklog.card_number }}
+ {% endif %}
+ {% endif %}
+ |
+
+
+ {{ worklog.description or '-' }}
+
+ |
+
+ {{ worklog.user_name or 'N/A' }}
+ |
+
+ {% if worklog.status == 'draft' %}
+
+
+
+
+ {% else %}
+ {{ worklog.status }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
+
+
Ingen worklog entries
+
Der er ingen entries med status "{{ selected_status }}" {% if selected_customer_id %}for denne kunde{% endif %}.
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
diff --git a/docs/TICKET_EMAIL_INTEGRATION.md b/docs/TICKET_EMAIL_INTEGRATION.md
new file mode 100644
index 0000000..68ff927
--- /dev/null
+++ b/docs/TICKET_EMAIL_INTEGRATION.md
@@ -0,0 +1,423 @@
+# Ticket System - Email Integration
+
+## Overview
+
+The ticket system integrates with BMC Hub's email workflow engine to automatically create tickets from incoming emails. Uses **Option B: Message-ID Threading** for robust email-to-ticket linkage.
+
+## Architecture
+
+```
+Incoming Email → Email Classifier → Email Workflow Engine → Ticket Integration
+ ↓
+ create_ticket action
+ ↓
+ EmailTicketIntegration.process_email_for_ticket()
+ ↓
+ ┌───────┴───────┐
+ ↓ ↓
+ New Ticket Reply to Existing
+ (creates TKT-XXX) (links via Message-ID)
+```
+
+## Email Threading Logic
+
+### Message-ID Based Threading (Option B)
+
+The system uses email headers to detect if an email is part of an existing ticket thread:
+
+1. **In-Reply-To Header**: Check if contains `TKT-YYYYMMDD-XXX` pattern
+2. **References Header**: Check all message IDs in chain for ticket number
+3. **Subject Line**: Check for `[TKT-YYYYMMDD-XXX]` or `Re: TKT-YYYYMMDD-XXX`
+
+If ticket number found → **Link to existing ticket**
+If NOT found → **Create new ticket**
+
+### Ticket Number Format
+
+Pattern: `TKT-YYYYMMDD-XXX`
+- `TKT`: Prefix
+- `YYYYMMDD`: Date (e.g., 20251215)
+- `XXX`: Sequential number (001-999)
+
+Example: `TKT-20251215-001`
+
+## Workflow Actions
+
+### 1. `create_ticket`
+
+Creates new ticket OR links to existing ticket (smart routing).
+
+**Workflow Definition:**
+```json
+{
+ "name": "Support Request → Ticket",
+ "classification_trigger": "support_request",
+ "workflow_steps": [
+ {
+ "action": "create_ticket",
+ "params": {
+ "customer_id": 123,
+ "assigned_to_user_id": 5
+ }
+ }
+ ]
+}
+```
+
+**Parameters:**
+- `customer_id` (optional): BMC customer ID to link ticket to
+- `assigned_to_user_id` (optional): User ID to assign ticket to
+- `priority` (optional): Override priority detection (low/normal/high/critical)
+
+**Returns:**
+```json
+{
+ "ticket_id": 42,
+ "ticket_number": "TKT-20251215-001",
+ "created": true,
+ "linked": false
+}
+```
+
+### 2. `link_email_to_ticket`
+
+Explicitly link email to a specific ticket (manual routing).
+
+**Workflow Definition:**
+```json
+{
+ "action": "link_email_to_ticket",
+ "params": {
+ "ticket_number": "TKT-20251215-001"
+ }
+}
+```
+
+**Parameters:**
+- `ticket_number` (required): Target ticket number
+
+**Returns:**
+```json
+{
+ "ticket_id": 42,
+ "ticket_number": "TKT-20251215-001",
+ "linked": true
+}
+```
+
+## Automatic Features
+
+### Tag Extraction
+
+Extracts `#hashtags` from email body and adds as ticket tags:
+
+```
+Email body: "We need help with #billing #urgent issue"
+→ Ticket tags: ["billing", "urgent"]
+```
+
+- Max 10 tags per ticket
+- Minimum 3 characters
+- Converted to lowercase
+
+### Priority Detection
+
+Automatically determines ticket priority from email content:
+
+**Critical**: kritisk, critical, down, nede, urgent, akut
+**High**: høj, high, vigtig, important, haster
+**Low**: lav, low, spørgsmål, question, info
+**Normal**: Everything else (default)
+
+Checks both subject line and extracted tags.
+
+### Email Metadata
+
+Stores rich metadata in ticket:
+
+- **Description**: Formatted with sender email, received date, original body
+- **Custom Fields**: `email_from`, `email_message_id`, `created_from_email`
+- **Email Log**: Full threading data in `tticket_email_log` table
+
+## Database Storage
+
+### `tticket_email_log` Table
+
+Stores all email-ticket linkages for audit trail:
+
+```sql
+CREATE TABLE tticket_email_log (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id),
+ email_message_id TEXT NOT NULL,
+ email_subject TEXT,
+ email_from TEXT NOT NULL,
+ email_received_at TIMESTAMP,
+ is_reply BOOLEAN DEFAULT FALSE,
+ thread_data JSONB,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+**`thread_data` JSONB structure:**
+```json
+{
+ "message_id": "",
+ "in_reply_to": "",
+ "references": " "
+}
+```
+
+## Usage Examples
+
+### Example 1: Basic Support Request
+
+**Incoming Email:**
+```
+From: customer@example.com
+Subject: Help with network issue
+Body: Our internet is down. Can you help?
+```
+
+**Workflow Configuration:**
+```json
+{
+ "name": "Support Email → Ticket",
+ "classification_trigger": "support_request",
+ "confidence_threshold": 0.7,
+ "workflow_steps": [
+ {
+ "action": "create_ticket",
+ "params": {
+ "assigned_to_user_id": 5
+ }
+ }
+ ]
+}
+```
+
+**Result:**
+- Creates ticket `TKT-20251215-001`
+- Priority: `normal` (no urgent keywords)
+- Tags: [] (no hashtags found)
+- Assigned to user 5
+- Email logged in `tticket_email_log`
+
+### Example 2: Email Reply to Ticket
+
+**Incoming Email:**
+```
+From: customer@example.com
+Subject: Re: TKT-20251215-001
+In-Reply-To:
+Body: Thanks, issue is resolved now
+```
+
+**Result:**
+- **Detects existing ticket** via In-Reply-To header
+- **Adds comment** to ticket `TKT-20251215-001`
+- Does NOT create new ticket
+- Logs as reply (`is_reply=true`)
+
+### Example 3: Tagged Urgent Request
+
+**Incoming Email:**
+```
+From: vip@example.com
+Subject: URGENT: Server down!
+Body: Production server is down #critical #server
+```
+
+**Result:**
+- Creates ticket with priority `critical` (subject keyword)
+- Tags: `["critical", "server"]`
+- Custom field: `created_from_email=true`
+
+## API Endpoints
+
+### Get Ticket Email Thread
+
+```http
+GET /api/v1/tickets/{ticket_id}/emails
+```
+
+Returns chronological list of all emails linked to ticket.
+
+**Response:**
+```json
+{
+ "ticket_id": 42,
+ "ticket_number": "TKT-20251215-001",
+ "emails": [
+ {
+ "id": 1,
+ "email_message_id": "",
+ "email_from": "customer@example.com",
+ "email_subject": "Help with issue",
+ "email_received_at": "2025-12-15T10:00:00Z",
+ "is_reply": false,
+ "created_at": "2025-12-15T10:01:00Z"
+ },
+ {
+ "id": 2,
+ "email_message_id": "",
+ "email_from": "customer@example.com",
+ "email_subject": "Re: TKT-20251215-001",
+ "email_received_at": "2025-12-15T11:00:00Z",
+ "is_reply": true,
+ "created_at": "2025-12-15T11:01:00Z"
+ }
+ ]
+}
+```
+
+### Find Tickets by Email Address
+
+```http
+GET /api/v1/tickets/by-email/{email_address}
+```
+
+Returns all tickets associated with an email address.
+
+**Response:**
+```json
+{
+ "email_address": "customer@example.com",
+ "tickets": [
+ {
+ "id": 42,
+ "ticket_number": "TKT-20251215-001",
+ "subject": "Network issue",
+ "status": "open",
+ "created_at": "2025-12-15T10:00:00Z"
+ }
+ ]
+}
+```
+
+## Configuration
+
+### Settings (`.env`)
+
+```bash
+# Ticket system enabled
+TICKET_ENABLED=true
+
+# Email integration enabled (requires email system)
+TICKET_EMAIL_INTEGRATION=true
+
+# Auto-assign new tickets (requires user_id)
+TICKET_AUTO_ASSIGN=false
+TICKET_DEFAULT_ASSIGNEE_ID=5
+
+# Default priority for new tickets
+TICKET_DEFAULT_PRIORITY=normal
+```
+
+## Testing
+
+### Test Email-to-Ticket Creation
+
+```bash
+curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email_data": {
+ "message_id": "",
+ "subject": "Test ticket",
+ "from_address": "test@example.com",
+ "body": "This is a test #testing",
+ "received_at": "2025-12-15T10:00:00Z"
+ }
+ }'
+```
+
+### Test Email Reply Linking
+
+```bash
+curl -X POST http://localhost:8001/api/v1/test/email-to-ticket \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email_data": {
+ "message_id": "",
+ "subject": "Re: TKT-20251215-001",
+ "from_address": "test@example.com",
+ "body": "Reply to existing ticket",
+ "received_at": "2025-12-15T11:00:00Z",
+ "in_reply_to": ""
+ }
+ }'
+```
+
+## Troubleshooting
+
+### Tickets Not Created
+
+**Check:**
+1. Email workflow engine enabled (`EMAIL_WORKFLOWS_ENABLED=true`)
+2. Workflow exists for classification trigger
+3. Confidence threshold met
+4. Workflow action is `create_ticket` (NOT old `create_ticket` stub)
+
+**Debug:**
+```sql
+-- Check workflow executions
+SELECT * FROM email_workflow_executions
+WHERE status = 'failed'
+ORDER BY created_at DESC
+LIMIT 10;
+```
+
+### Email Not Linked to Ticket
+
+**Check:**
+1. Ticket number format correct (`TKT-YYYYMMDD-XXX`)
+2. Ticket exists in database
+3. Email headers contain ticket number (In-Reply-To, References, Subject)
+
+**Debug:**
+```sql
+-- Check email logs
+SELECT * FROM tticket_email_log
+WHERE ticket_id = 42
+ORDER BY created_at DESC;
+```
+
+### Duplicate Tickets Created
+
+**Check:**
+1. Email reply headers missing ticket number
+2. Subject line doesn't match pattern (e.g., `Re: Ticket 123` instead of `Re: TKT-20251215-001`)
+
+**Solution:**
+- Ensure outgoing ticket emails include ticket number in subject: `[TKT-20251215-001]`
+- Add ticket number to Message-ID: ``
+
+## Best Practices
+
+1. **Include Ticket Number in Replies**: Always include `[TKT-YYYYMMDD-XXX]` in subject line
+2. **Use Message-ID with Ticket Number**: Format: ``
+3. **Set Customer ID in Workflow**: Improves ticket organization and reporting
+4. **Monitor Workflow Executions**: Check `email_workflow_executions` table regularly
+5. **Review Failed Actions**: Alert on repeated workflow failures
+
+## Security Considerations
+
+1. **No Email Body Storage**: Only stores metadata and body in ticket description
+2. **Sender Validation**: Consider implementing sender verification (SPF/DKIM)
+3. **Spam Prevention**: Email classifier should filter spam before workflow execution
+4. **Customer Isolation**: Ensure `customer_id` properly set to prevent data leakage
+
+## Future Enhancements
+
+- **Attachment Handling**: Link email attachments to ticket attachments
+- **Email Templates**: Auto-reply with ticket number
+- **SLA Integration**: Start SLA timer on ticket creation from email
+- **Multi-Ticket Threading**: Support one email creating multiple tickets
+- **Smart Customer Detection**: Auto-detect customer from sender domain
+
+## Related Documentation
+
+- [Ticket System Architecture](TICKET_SYSTEM_ARCHITECTURE.md)
+- [Email Workflow System](EMAIL_WORKFLOWS.md)
+- [Database Schema](../migrations/025_ticket_module.sql)
diff --git a/main.py b/main.py
index 07d30e1..7fca2a2 100644
--- a/main.py
+++ b/main.py
@@ -42,6 +42,8 @@ from app.emails.frontend import views as emails_views
from app.backups.backend import router as backups_api
from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
+from app.ticket.backend import router as ticket_api
+from app.ticket.frontend import views as ticket_views
# Configure logging
logging.basicConfig(
@@ -131,6 +133,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
app.include_router(backups_api.router, prefix="/api/v1", tags=["Backup System"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Email System"])
+app.include_router(ticket_api.router, prefix="/api/v1", tags=["Ticket System"])
# Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"])
@@ -144,6 +147,7 @@ app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
+app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend - Tickets"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/002_auth_system.sql b/migrations/002_auth_system.sql
index 2682ea2..bba0754 100644
--- a/migrations/002_auth_system.sql
+++ b/migrations/002_auth_system.sql
@@ -106,6 +106,17 @@ INSERT INTO permissions (code, description, category) VALUES
('billing.edit', 'Edit billing information', 'billing'),
('billing.approve', 'Approve billing items', 'billing'),
+-- Tickets
+('tickets.view', 'View tickets', 'tickets'),
+('tickets.create', 'Create tickets', 'tickets'),
+('tickets.edit', 'Edit tickets', 'tickets'),
+('tickets.delete', 'Delete tickets', 'tickets'),
+('tickets.assign', 'Assign tickets to users', 'tickets'),
+('tickets.worklog.create', 'Create worklog entries', 'tickets'),
+('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
+('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
+('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets'),
+
-- System
('system.view', 'View system settings', 'system'),
('system.edit', 'Edit system settings', 'system'),
@@ -148,6 +159,8 @@ WHERE g.name = 'Managers' AND p.code IN (
'customers.view', 'customers.edit', 'customers.create',
'hardware.view',
'billing.view', 'billing.edit', 'billing.approve',
+ 'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
+ 'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage',
'reports.view', 'reports.export'
)
ON CONFLICT DO NOTHING;
@@ -160,6 +173,7 @@ CROSS JOIN permissions p
WHERE g.name = 'Technicians' AND p.code IN (
'customers.view',
'hardware.view', 'hardware.edit', 'hardware.create', 'hardware.assign',
+ 'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create',
'reports.view'
)
ON CONFLICT DO NOTHING;
@@ -173,6 +187,7 @@ WHERE g.name = 'Viewers' AND p.code IN (
'customers.view',
'hardware.view',
'billing.view',
+ 'tickets.view',
'reports.view'
)
ON CONFLICT DO NOTHING;
diff --git a/migrations/025_ticket_module.sql b/migrations/025_ticket_module.sql
new file mode 100644
index 0000000..6f0d1db
--- /dev/null
+++ b/migrations/025_ticket_module.sql
@@ -0,0 +1,484 @@
+-- ============================================================================
+-- Migration 025: Ticket System & Klippekort Modul (Isoleret)
+-- ============================================================================
+-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data.
+-- Alle tabeller har prefix 'tticket_' for at markere tilhørsforhold til modulet.
+-- Ved uninstall køres DROP-scriptet i bunden af denne fil.
+-- ============================================================================
+
+-- Metadata tabel til at tracke modulets tilstand
+CREATE TABLE IF NOT EXISTS tticket_metadata (
+ id SERIAL PRIMARY KEY,
+ module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ installed_by INTEGER, -- Reference til users.user_id (read-only, ingen FK)
+ last_sync_at TIMESTAMP,
+ is_active BOOLEAN DEFAULT true,
+ settings JSONB DEFAULT '{}'::jsonb
+);
+
+-- Indsæt initial metadata
+INSERT INTO tticket_metadata (module_version, is_active)
+VALUES ('1.0.0', true)
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- TICKETS (hovedtabel)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_tickets (
+ id SERIAL PRIMARY KEY,
+ ticket_number VARCHAR(50) UNIQUE NOT NULL, -- Format: TKT-YYYYMMDD-XXX
+
+ -- Core felter
+ subject VARCHAR(500) NOT NULL,
+ description TEXT,
+ status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal', 'resolved', 'closed')),
+ priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
+ category VARCHAR(100), -- support, bug, feature_request, question, etc.
+
+ -- Relationer (read-only references - INGEN FK til core tables)
+ customer_id INTEGER, -- Reference til customers.id
+ contact_id INTEGER, -- Reference til contacts.id
+ assigned_to_user_id INTEGER, -- Reference til users.user_id
+ created_by_user_id INTEGER, -- Reference til users.user_id
+
+ -- Kilde
+ source VARCHAR(50) DEFAULT 'manual' CHECK (source IN ('email', 'portal', 'phone', 'manual', 'api')),
+
+ -- Metadata
+ tags TEXT[],
+ custom_fields JSONB,
+
+ -- Timestamps
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP,
+ first_response_at TIMESTAMP, -- For SLA tracking (future)
+ resolved_at TIMESTAMP,
+ closed_at TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_tickets_number ON tticket_tickets(ticket_number);
+CREATE INDEX idx_tticket_tickets_customer ON tticket_tickets(customer_id);
+CREATE INDEX idx_tticket_tickets_assigned ON tticket_tickets(assigned_to_user_id);
+CREATE INDEX idx_tticket_tickets_status ON tticket_tickets(status);
+CREATE INDEX idx_tticket_tickets_priority ON tticket_tickets(priority);
+CREATE INDEX idx_tticket_tickets_created ON tticket_tickets(created_at DESC);
+
+-- ============================================================================
+-- COMMENTS (beskeder på tickets)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_comments (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
+ user_id INTEGER, -- Reference til users.user_id (read-only, ingen FK)
+ comment_text TEXT NOT NULL,
+ is_internal BOOLEAN DEFAULT false, -- Intern note vs. kunde-synlig
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_comments_ticket ON tticket_comments(ticket_id);
+CREATE INDEX idx_tticket_comments_created ON tticket_comments(created_at);
+
+-- ============================================================================
+-- ATTACHMENTS (filer på tickets)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_attachments (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
+ file_name VARCHAR(255) NOT NULL,
+ file_path VARCHAR(500) NOT NULL,
+ file_size INTEGER,
+ mime_type VARCHAR(100),
+ uploaded_by_user_id INTEGER, -- Reference til users.user_id (read-only)
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_attachments_ticket ON tticket_attachments(ticket_id);
+
+-- ============================================================================
+-- WORKLOG (tidsregistrering på tickets)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_worklog (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
+
+ -- Arbejdsdata
+ work_date DATE NOT NULL,
+ hours DECIMAL(5,2) NOT NULL CHECK (hours > 0),
+ work_type VARCHAR(50) DEFAULT 'support' CHECK (work_type IN ('support', 'development', 'troubleshooting', 'on_site', 'meeting', 'other')),
+ description TEXT,
+
+ -- Fakturering
+ billing_method VARCHAR(20) DEFAULT 'invoice' CHECK (billing_method IN ('prepaid_card', 'invoice', 'internal', 'warranty')),
+ status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'billable', 'billed', 'non_billable')),
+ prepaid_card_id INTEGER, -- Reference til tticket_prepaid_cards (read-only)
+
+ -- Metadata
+ user_id INTEGER, -- Reference til users.user_id (read-only)
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP,
+ billed_at TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_worklog_ticket ON tticket_worklog(ticket_id);
+CREATE INDEX idx_tticket_worklog_status ON tticket_worklog(status);
+CREATE INDEX idx_tticket_worklog_date ON tticket_worklog(work_date);
+CREATE INDEX idx_tticket_worklog_card ON tticket_worklog(prepaid_card_id);
+
+-- ============================================================================
+-- PREPAID CARDS (klippekort - kun 1 aktivt per virksomhed)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_prepaid_cards (
+ id SERIAL PRIMARY KEY,
+ card_number VARCHAR(50) UNIQUE NOT NULL, -- Format: CARD-YYYYMMDD-XXX
+
+ -- Tilknytning (kun 1 aktivt kort per customer)
+ customer_id INTEGER NOT NULL, -- Reference til customers.id (read-only, ingen FK)
+
+ -- Saldo
+ purchased_hours DECIMAL(8,2) NOT NULL CHECK (purchased_hours > 0),
+ used_hours DECIMAL(8,2) DEFAULT 0 CHECK (used_hours >= 0),
+ remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (purchased_hours - used_hours) STORED,
+
+ -- Priser
+ price_per_hour DECIMAL(10,2) NOT NULL,
+ total_amount DECIMAL(12,2) NOT NULL,
+
+ -- Lifecycle
+ status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'depleted', 'expired', 'cancelled')),
+ purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP, -- NULL = ingen udløb
+
+ -- e-conomic integration
+ economic_invoice_number VARCHAR(50),
+ economic_product_number VARCHAR(50),
+
+ -- Metadata
+ notes TEXT,
+ created_by_user_id INTEGER, -- Reference til users.user_id (read-only)
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP
+);
+
+-- CRITICAL: Kun 1 aktivt kort per customer
+CREATE UNIQUE INDEX idx_tticket_prepaid_unique_active ON tticket_prepaid_cards(customer_id)
+WHERE status = 'active';
+
+CREATE INDEX idx_tticket_prepaid_customer ON tticket_prepaid_cards(customer_id);
+CREATE INDEX idx_tticket_prepaid_status ON tticket_prepaid_cards(status);
+CREATE INDEX idx_tticket_prepaid_expires ON tticket_prepaid_cards(expires_at);
+
+-- ============================================================================
+-- PREPAID TRANSACTIONS (immutable log af forbrug)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_prepaid_transactions (
+ id SERIAL PRIMARY KEY,
+ card_id INTEGER NOT NULL REFERENCES tticket_prepaid_cards(id) ON DELETE CASCADE,
+ worklog_id INTEGER, -- Reference til tticket_worklog (read-only, NULL for purchases/top-ups)
+
+ -- Transaction type
+ transaction_type VARCHAR(20) NOT NULL CHECK (transaction_type IN ('purchase', 'top_up', 'usage', 'refund', 'expiration', 'cancellation')),
+
+ -- Beløb (positiv = tilføj, negativ = træk)
+ hours DECIMAL(8,2) NOT NULL,
+ balance_after DECIMAL(8,2) NOT NULL,
+
+ -- Beskrivelse
+ description TEXT,
+
+ -- Metadata (immutable)
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_by_user_id INTEGER -- Reference til users.user_id (read-only)
+);
+
+CREATE INDEX idx_tticket_transactions_card ON tticket_prepaid_transactions(card_id);
+CREATE INDEX idx_tticket_transactions_worklog ON tticket_prepaid_transactions(worklog_id);
+CREATE INDEX idx_tticket_transactions_created ON tticket_prepaid_transactions(created_at DESC);
+
+-- ============================================================================
+-- EMAIL INTEGRATION LOG (email → ticket mapping)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_email_log (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE CASCADE,
+ email_id INTEGER, -- Reference til email_messages.id (read-only, ingen FK)
+ email_message_id VARCHAR(500), -- Email Message-ID header for threading
+ action VARCHAR(50) NOT NULL, -- created|comment_added|updated|linked
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_email_log_ticket ON tticket_email_log(ticket_id);
+CREATE INDEX idx_tticket_email_log_email ON tticket_email_log(email_id);
+CREATE INDEX idx_tticket_email_log_message_id ON tticket_email_log(email_message_id);
+
+-- ============================================================================
+-- AUDIT LOG (alle ændringer)
+-- ============================================================================
+CREATE TABLE IF NOT EXISTS tticket_audit_log (
+ id SERIAL PRIMARY KEY,
+ ticket_id INTEGER, -- NULL for system-level events
+ entity_type VARCHAR(50) NOT NULL, -- ticket, comment, worklog, prepaid_card, etc.
+ entity_id INTEGER,
+ user_id INTEGER, -- Reference til users.user_id (read-only)
+ action VARCHAR(50) NOT NULL, -- created, updated, deleted, status_changed, assigned, etc.
+ old_value TEXT,
+ new_value TEXT,
+ details JSONB,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
+CREATE INDEX idx_tticket_audit_entity ON tticket_audit_log(entity_type, entity_id);
+CREATE INDEX idx_tticket_audit_created ON tticket_audit_log(created_at DESC);
+
+-- ============================================================================
+-- FUNCTIONS
+-- ============================================================================
+
+-- Auto-update timestamp function
+CREATE OR REPLACE FUNCTION tticket_update_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Generate ticket number function
+CREATE OR REPLACE FUNCTION tticket_generate_ticket_number()
+RETURNS VARCHAR AS $$
+DECLARE
+ today_str VARCHAR(8);
+ last_number INTEGER;
+ next_number INTEGER;
+ new_ticket_number VARCHAR(50);
+BEGIN
+ -- Format: TKT-YYYYMMDD-XXX
+ today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
+
+ -- Find last ticket number for today
+ SELECT COALESCE(
+ MAX(CAST(SPLIT_PART(ticket_number, '-', 3) AS INTEGER)),
+ 0
+ ) INTO last_number
+ FROM tticket_tickets
+ WHERE ticket_number LIKE 'TKT-' || today_str || '-%';
+
+ next_number := last_number + 1;
+ new_ticket_number := 'TKT-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
+
+ RETURN new_ticket_number;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Generate prepaid card number function
+CREATE OR REPLACE FUNCTION tticket_generate_card_number()
+RETURNS VARCHAR AS $$
+DECLARE
+ today_str VARCHAR(8);
+ last_number INTEGER;
+ next_number INTEGER;
+ new_card_number VARCHAR(50);
+BEGIN
+ -- Format: CARD-YYYYMMDD-XXX
+ today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
+
+ -- Find last card number for today
+ SELECT COALESCE(
+ MAX(CAST(SPLIT_PART(card_number, '-', 3) AS INTEGER)),
+ 0
+ ) INTO last_number
+ FROM tticket_prepaid_cards
+ WHERE card_number LIKE 'CARD-' || today_str || '-%';
+
+ next_number := last_number + 1;
+ new_card_number := 'CARD-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0');
+
+ RETURN new_card_number;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ============================================================================
+-- TRIGGERS
+-- ============================================================================
+
+-- Auto-update timestamps
+CREATE TRIGGER tticket_tickets_update
+ BEFORE UPDATE ON tticket_tickets
+ FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
+
+CREATE TRIGGER tticket_comments_update
+ BEFORE UPDATE ON tticket_comments
+ FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
+
+CREATE TRIGGER tticket_worklog_update
+ BEFORE UPDATE ON tticket_worklog
+ FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
+
+CREATE TRIGGER tticket_prepaid_cards_update
+ BEFORE UPDATE ON tticket_prepaid_cards
+ FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp();
+
+-- Auto-generate ticket number if not provided
+CREATE OR REPLACE FUNCTION tticket_auto_generate_ticket_number()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN
+ NEW.ticket_number := tticket_generate_ticket_number();
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER tticket_tickets_generate_number
+ BEFORE INSERT ON tticket_tickets
+ FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_ticket_number();
+
+-- Auto-generate card number if not provided
+CREATE OR REPLACE FUNCTION tticket_auto_generate_card_number()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.card_number IS NULL OR NEW.card_number = '' THEN
+ NEW.card_number := tticket_generate_card_number();
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER tticket_prepaid_cards_generate_number
+ BEFORE INSERT ON tticket_prepaid_cards
+ FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_card_number();
+
+-- ============================================================================
+-- VIEWS (common queries)
+-- ============================================================================
+
+-- View: Open tickets with statistics
+CREATE OR REPLACE VIEW tticket_open_tickets AS
+SELECT
+ t.*,
+ COUNT(DISTINCT c.id) AS comment_count,
+ COUNT(DISTINCT a.id) AS attachment_count,
+ SUM(w.hours) FILTER (WHERE w.status IN ('draft', 'billable')) AS pending_hours,
+ SUM(w.hours) FILTER (WHERE w.status = 'billed') AS billed_hours,
+ MAX(c.created_at) AS last_comment_at,
+ EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.created_at))/3600 AS age_hours
+FROM tticket_tickets t
+LEFT JOIN tticket_comments c ON t.id = c.ticket_id
+LEFT JOIN tticket_attachments a ON t.id = a.ticket_id
+LEFT JOIN tticket_worklog w ON t.id = w.ticket_id
+WHERE t.status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal')
+GROUP BY t.id;
+
+-- View: Worklog entries ready for billing
+CREATE OR REPLACE VIEW tticket_billable_worklog AS
+SELECT
+ w.*,
+ t.ticket_number,
+ t.subject AS ticket_subject,
+ t.customer_id,
+ t.status AS ticket_status,
+ CASE
+ WHEN w.billing_method = 'prepaid_card' THEN pc.remaining_hours >= w.hours
+ ELSE true
+ END AS has_sufficient_balance
+FROM tticket_worklog w
+JOIN tticket_tickets t ON w.ticket_id = t.id
+LEFT JOIN tticket_prepaid_cards pc ON w.prepaid_card_id = pc.id
+WHERE w.status = 'billable';
+
+-- View: Prepaid card balances
+CREATE OR REPLACE VIEW tticket_prepaid_balances AS
+SELECT
+ pc.*,
+ COUNT(w.id) AS usage_count,
+ SUM(w.hours) AS total_hours_used,
+ COUNT(w.id) FILTER (WHERE w.status = 'billed') AS billed_usage_count
+FROM tticket_prepaid_cards pc
+LEFT JOIN tticket_worklog w ON pc.id = w.prepaid_card_id
+GROUP BY pc.id;
+
+-- View: Ticket statistics by status
+CREATE OR REPLACE VIEW tticket_stats_by_status AS
+SELECT
+ status,
+ priority,
+ COUNT(*) AS ticket_count,
+ AVG(EXTRACT(EPOCH FROM (COALESCE(resolved_at, CURRENT_TIMESTAMP) - created_at))/3600) AS avg_age_hours
+FROM tticket_tickets
+GROUP BY status, priority;
+
+-- ============================================================================
+-- INITIAL DATA
+-- ============================================================================
+
+-- Log installation in audit log
+INSERT INTO tticket_audit_log (entity_type, action, details)
+VALUES (
+ 'system',
+ 'module_installed',
+ jsonb_build_object(
+ 'version', '1.0.0',
+ 'timestamp', CURRENT_TIMESTAMP,
+ 'tables_created', ARRAY[
+ 'tticket_metadata', 'tticket_tickets', 'tticket_comments',
+ 'tticket_attachments', 'tticket_worklog', 'tticket_prepaid_cards',
+ 'tticket_prepaid_transactions', 'tticket_email_log', 'tticket_audit_log'
+ ]
+ )
+);
+
+-- ============================================================================
+-- UNINSTALL SCRIPT (bruges ved modul-sletning)
+-- ============================================================================
+-- ADVARSEL: Dette script sletter ALLE data i modulet!
+-- Kør kun hvis modulet skal fjernes fuldstændigt.
+--
+-- For at uninstalle, kør følgende kommandoer i rækkefølge:
+--
+-- -- Drop views
+-- DROP VIEW IF EXISTS tticket_stats_by_status CASCADE;
+-- DROP VIEW IF EXISTS tticket_prepaid_balances CASCADE;
+-- DROP VIEW IF EXISTS tticket_billable_worklog CASCADE;
+-- DROP VIEW IF EXISTS tticket_open_tickets CASCADE;
+--
+-- -- Drop triggers
+-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_generate_number ON tticket_prepaid_cards;
+-- DROP TRIGGER IF EXISTS tticket_tickets_generate_number ON tticket_tickets;
+-- DROP TRIGGER IF EXISTS tticket_prepaid_cards_update ON tticket_prepaid_cards;
+-- DROP TRIGGER IF EXISTS tticket_worklog_update ON tticket_worklog;
+-- DROP TRIGGER IF EXISTS tticket_comments_update ON tticket_comments;
+-- DROP TRIGGER IF EXISTS tticket_tickets_update ON tticket_tickets;
+--
+-- -- Drop functions
+-- DROP FUNCTION IF EXISTS tticket_generate_card_number() CASCADE;
+-- DROP FUNCTION IF EXISTS tticket_generate_ticket_number() CASCADE;
+-- DROP FUNCTION IF EXISTS tticket_auto_generate_card_number() CASCADE;
+-- DROP FUNCTION IF EXISTS tticket_auto_generate_ticket_number() CASCADE;
+-- DROP FUNCTION IF EXISTS tticket_update_timestamp() CASCADE;
+--
+-- -- Drop tables (reverse dependency order)
+-- DROP TABLE IF EXISTS tticket_audit_log CASCADE;
+-- DROP TABLE IF EXISTS tticket_email_log CASCADE;
+-- DROP TABLE IF EXISTS tticket_prepaid_transactions CASCADE;
+-- DROP TABLE IF EXISTS tticket_prepaid_cards CASCADE;
+-- DROP TABLE IF EXISTS tticket_worklog CASCADE;
+-- DROP TABLE IF EXISTS tticket_attachments CASCADE;
+-- DROP TABLE IF EXISTS tticket_comments CASCADE;
+-- DROP TABLE IF EXISTS tticket_tickets CASCADE;
+-- DROP TABLE IF EXISTS tticket_metadata CASCADE;
+--
+-- -- Log uninstall
+-- -- (Dette vil fejle hvis tticket_audit_log er droppet, men det er OK)
+-- DO $$
+-- BEGIN
+-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tticket_audit_log') THEN
+-- INSERT INTO tticket_audit_log (entity_type, action, details)
+-- VALUES ('system', 'module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP));
+-- END IF;
+-- EXCEPTION WHEN OTHERS THEN
+-- NULL; -- Ignorer fejl
+-- END $$;
+--
+-- ============================================================================
diff --git a/migrations/026_ticket_permissions.sql b/migrations/026_ticket_permissions.sql
new file mode 100644
index 0000000..524eb0c
--- /dev/null
+++ b/migrations/026_ticket_permissions.sql
@@ -0,0 +1,59 @@
+-- Migration 026: Add Ticket System Permissions
+-- Description: Adds ticket-related permissions to auth system
+-- Date: 2025-12-15
+
+-- Add ticket permissions
+INSERT INTO permissions (code, description, category) VALUES
+('tickets.view', 'View tickets', 'tickets'),
+('tickets.create', 'Create tickets', 'tickets'),
+('tickets.edit', 'Edit tickets', 'tickets'),
+('tickets.delete', 'Delete tickets', 'tickets'),
+('tickets.assign', 'Assign tickets to users', 'tickets'),
+('tickets.worklog.create', 'Create worklog entries', 'tickets'),
+('tickets.worklog.approve', 'Approve worklog for billing', 'tickets'),
+('tickets.billing.export', 'Export billable worklog to e-conomic', 'tickets'),
+('tickets.prepaid.manage', 'Manage prepaid cards (klippekort)', 'tickets')
+ON CONFLICT (code) DO NOTHING;
+
+-- Assign ticket permissions to Administrators group (all permissions)
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Administrators' AND p.category = 'tickets'
+ON CONFLICT DO NOTHING;
+
+-- Assign ticket permissions to Managers group
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Managers' AND p.code IN (
+ 'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.assign',
+ 'tickets.worklog.approve', 'tickets.billing.export', 'tickets.prepaid.manage'
+)
+ON CONFLICT DO NOTHING;
+
+-- Assign ticket permissions to Technicians group
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Technicians' AND p.code IN (
+ 'tickets.view', 'tickets.create', 'tickets.edit', 'tickets.worklog.create'
+)
+ON CONFLICT DO NOTHING;
+
+-- Assign ticket view permission to Viewers group
+INSERT INTO group_permissions (group_id, permission_id)
+SELECT g.id, p.id
+FROM groups g
+CROSS JOIN permissions p
+WHERE g.name = 'Viewers' AND p.code = 'tickets.view'
+ON CONFLICT DO NOTHING;
+
+-- Verify migration
+SELECT 'Ticket permissions added successfully' AS status,
+ COUNT(*) AS ticket_permissions_count
+FROM permissions
+WHERE category = 'tickets';
diff --git a/test_ticket_module.py b/test_ticket_module.py
new file mode 100644
index 0000000..139c640
--- /dev/null
+++ b/test_ticket_module.py
@@ -0,0 +1,490 @@
+"""
+Test script for Ticket Module
+==============================
+
+Tester database schema, funktioner, constraints og services.
+"""
+
+import asyncio
+import sys
+from datetime import date, datetime
+from decimal import Decimal
+
+# Add parent directory to path
+sys.path.insert(0, '/app')
+
+# Initialize database pool before importing services
+from app.core.database import init_db, execute_query, execute_insert, execute_update
+from app.ticket.backend.ticket_service import TicketService
+from app.ticket.backend.models import (
+ TTicketCreate,
+ TicketStatus,
+ TicketPriority,
+ TicketSource,
+ WorkType,
+ BillingMethod
+)
+
+def print_test(name: str, passed: bool, details: str = ""):
+ """Print test result"""
+ emoji = "✅" if passed else "❌"
+ print(f"{emoji} {name}")
+ if details:
+ print(f" {details}")
+ print()
+
+
+def test_tables_exist():
+ """Test 1: Check all tables are created"""
+ print("=" * 60)
+ print("TEST 1: Database Tables")
+ print("=" * 60)
+
+ tables = [
+ 'tticket_metadata',
+ 'tticket_tickets',
+ 'tticket_comments',
+ 'tticket_attachments',
+ 'tticket_worklog',
+ 'tticket_prepaid_cards',
+ 'tticket_prepaid_transactions',
+ 'tticket_email_log',
+ 'tticket_audit_log'
+ ]
+
+ for table in tables:
+ result = execute_query(
+ """
+ SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_name = %s
+ )
+ """,
+ (table,),
+ fetchone=True
+ )
+ print_test(f"Table '{table}' exists", result['exists'])
+
+ # Check views
+ views = [
+ 'tticket_open_tickets',
+ 'tticket_billable_worklog',
+ 'tticket_prepaid_balances',
+ 'tticket_stats_by_status'
+ ]
+
+ for view in views:
+ result = execute_query(
+ """
+ SELECT EXISTS (
+ SELECT FROM information_schema.views
+ WHERE table_name = %s
+ )
+ """,
+ (view,),
+ fetchone=True
+ )
+ print_test(f"View '{view}' exists", result['exists'])
+
+
+def test_ticket_number_generation():
+ """Test 2: Auto-generate ticket number"""
+ print("=" * 60)
+ print("TEST 2: Ticket Number Generation")
+ print("=" * 60)
+
+ # Insert ticket without ticket_number (should auto-generate)
+ ticket_id = execute_insert(
+ """
+ INSERT INTO tticket_tickets (subject, description, status)
+ VALUES (%s, %s, %s)
+ """,
+ ("Test Ticket", "Testing auto-generation", "open")
+ )
+
+ ticket = execute_query(
+ "SELECT ticket_number FROM tticket_tickets WHERE id = %s",
+ (ticket_id,),
+ fetchone=True
+ )
+
+ # Check format: TKT-YYYYMMDD-XXX
+ import re
+ pattern = r'TKT-\d{8}-\d{3}'
+ matches = re.match(pattern, ticket['ticket_number'])
+
+ print_test(
+ "Ticket number auto-generated",
+ matches is not None,
+ f"Generated: {ticket['ticket_number']}"
+ )
+
+ # Test generating multiple tickets
+ ticket_id2 = execute_insert(
+ """
+ INSERT INTO tticket_tickets (subject, status)
+ VALUES (%s, %s)
+ """,
+ ("Test Ticket 2", "open")
+ )
+
+ ticket2 = execute_query(
+ "SELECT ticket_number FROM tticket_tickets WHERE id = %s",
+ (ticket_id2,),
+ fetchone=True
+ )
+
+ print_test(
+ "Sequential ticket numbers",
+ ticket2['ticket_number'] > ticket['ticket_number'],
+ f"First: {ticket['ticket_number']}, Second: {ticket2['ticket_number']}"
+ )
+
+
+def test_prepaid_card_constraints():
+ """Test 3: Prepaid card constraints (1 active per customer)"""
+ print("=" * 60)
+ print("TEST 3: Prepaid Card Constraints")
+ print("=" * 60)
+
+ # Create first active card for customer 1
+ card1_id = execute_insert(
+ """
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price_per_hour, total_amount, status)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (1, Decimal('10.0'), Decimal('850.0'), Decimal('8500.0'), 'active')
+ )
+
+ card1 = execute_query(
+ "SELECT card_number, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
+ (card1_id,),
+ fetchone=True
+ )
+
+ print_test(
+ "First prepaid card created",
+ card1 is not None,
+ f"Card: {card1['card_number']}, Balance: {card1['remaining_hours']} hours"
+ )
+
+ # Try to create second active card for same customer (should fail due to UNIQUE constraint)
+ try:
+ card2_id = execute_insert(
+ """
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price_per_hour, total_amount, status)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (1, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
+ )
+ print_test(
+ "Cannot create 2nd active card (UNIQUE constraint)",
+ False,
+ "ERROR: Constraint not enforced!"
+ )
+ except Exception as e:
+ print_test(
+ "Cannot create 2nd active card (UNIQUE constraint)",
+ "unique" in str(e).lower() or "duplicate" in str(e).lower(),
+ f"Constraint enforced: {str(e)[:80]}"
+ )
+
+ # Create inactive card for same customer (should work)
+ card3_id = execute_insert(
+ """
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price_per_hour, total_amount, status)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (1, Decimal('5.0'), Decimal('850.0'), Decimal('4250.0'), 'depleted')
+ )
+
+ print_test(
+ "Can create inactive card for same customer",
+ card3_id is not None,
+ "Multiple inactive cards allowed"
+ )
+
+
+def test_generated_column():
+ """Test 4: Generated column (remaining_hours)"""
+ print("=" * 60)
+ print("TEST 4: Generated Column (remaining_hours)")
+ print("=" * 60)
+
+ # Create card
+ card_id = execute_insert(
+ """
+ INSERT INTO tticket_prepaid_cards
+ (customer_id, purchased_hours, price_per_hour, total_amount, status)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (2, Decimal('20.0'), Decimal('850.0'), Decimal('17000.0'), 'active')
+ )
+
+ card = execute_query(
+ "SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
+ (card_id,),
+ fetchone=True
+ )
+
+ print_test(
+ "Initial remaining_hours calculated",
+ card['remaining_hours'] == Decimal('20.0'),
+ f"Purchased: {card['purchased_hours']}, Used: {card['used_hours']}, Remaining: {card['remaining_hours']}"
+ )
+
+ # Use some hours
+ execute_update(
+ "UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
+ (Decimal('5.5'), card_id)
+ )
+
+ card = execute_query(
+ "SELECT purchased_hours, used_hours, remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
+ (card_id,),
+ fetchone=True
+ )
+
+ expected = Decimal('14.5')
+ print_test(
+ "remaining_hours auto-updates",
+ card['remaining_hours'] == expected,
+ f"After using 5.5h: {card['remaining_hours']}h (expected: {expected}h)"
+ )
+
+
+def test_ticket_service():
+ """Test 5: Ticket Service business logic"""
+ print("=" * 60)
+ print("TEST 5: Ticket Service")
+ print("=" * 60)
+
+ # Test create ticket
+ ticket_data = TTicketCreate(
+ subject="Test Service Ticket",
+ description="Testing TicketService",
+ priority=TicketPriority.HIGH,
+ customer_id=1,
+ source=TicketSource.MANUAL
+ )
+
+ ticket = TicketService.create_ticket(ticket_data, user_id=1)
+
+ print_test(
+ "TicketService.create_ticket() works",
+ ticket is not None and 'id' in ticket,
+ f"Created ticket: {ticket['ticket_number']}"
+ )
+
+ # Test status transition validation
+ is_valid, error = TicketService.validate_status_transition('open', 'in_progress')
+ print_test(
+ "Valid status transition (open → in_progress)",
+ is_valid and error is None,
+ "Transition allowed"
+ )
+
+ is_valid, error = TicketService.validate_status_transition('closed', 'open')
+ print_test(
+ "Invalid status transition (closed → open)",
+ not is_valid and error is not None,
+ f"Transition blocked: {error}"
+ )
+
+ # Test update status
+ try:
+ updated = TicketService.update_ticket_status(
+ ticket['id'],
+ 'in_progress',
+ user_id=1,
+ note="Starting work"
+ )
+
+ print_test(
+ "TicketService.update_ticket_status() works",
+ updated['status'] == 'in_progress',
+ f"Status changed: {ticket['status']} → {updated['status']}"
+ )
+ except Exception as e:
+ print_test(
+ "TicketService.update_ticket_status() works",
+ False,
+ f"ERROR: {e}"
+ )
+
+ # Test add comment
+ try:
+ comment = TicketService.add_comment(
+ ticket['id'],
+ "Test comment from service",
+ user_id=1,
+ is_internal=False
+ )
+
+ print_test(
+ "TicketService.add_comment() works",
+ comment is not None and 'id' in comment,
+ f"Comment added (ID: {comment['id']})"
+ )
+ except Exception as e:
+ print_test(
+ "TicketService.add_comment() works",
+ False,
+ f"ERROR: {e}"
+ )
+
+
+def test_worklog_creation():
+ """Test 6: Worklog creation"""
+ print("=" * 60)
+ print("TEST 6: Worklog")
+ print("=" * 60)
+
+ # Get a ticket
+ ticket = execute_query(
+ "SELECT id FROM tticket_tickets LIMIT 1",
+ fetchone=True
+ )
+
+ if not ticket:
+ print_test("Worklog creation", False, "No tickets found to test worklog")
+ return
+
+ # Create worklog entry
+ worklog_id = execute_insert(
+ """
+ INSERT INTO tticket_worklog
+ (ticket_id, work_date, hours, work_type, billing_method, status, user_id)
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (ticket['id'], date.today(), Decimal('2.5'), 'support', 'invoice', 'draft', 1)
+ )
+
+ worklog = execute_query(
+ "SELECT * FROM tticket_worklog WHERE id = %s",
+ (worklog_id,),
+ fetchone=True
+ )
+
+ print_test(
+ "Worklog entry created",
+ worklog is not None,
+ f"ID: {worklog_id}, Hours: {worklog['hours']}, Status: {worklog['status']}"
+ )
+
+ # Check view
+ billable_count = execute_query(
+ "SELECT COUNT(*) as count FROM tticket_billable_worklog WHERE status = 'billable'",
+ fetchone=True
+ )
+
+ print_test(
+ "Billable worklog view accessible",
+ billable_count is not None,
+ f"Found {billable_count['count']} billable entries"
+ )
+
+
+def test_audit_logging():
+ """Test 7: Audit logging"""
+ print("=" * 60)
+ print("TEST 7: Audit Logging")
+ print("=" * 60)
+
+ # Check if audit entries were created
+ audit_count = execute_query(
+ "SELECT COUNT(*) as count FROM tticket_audit_log",
+ fetchone=True
+ )
+
+ print_test(
+ "Audit log has entries",
+ audit_count['count'] > 0,
+ f"Found {audit_count['count']} audit entries"
+ )
+
+ # Check recent audit entries
+ recent = execute_query(
+ """
+ SELECT action, entity_type, created_at
+ FROM tticket_audit_log
+ ORDER BY created_at DESC
+ LIMIT 5
+ """
+ )
+
+ if recent:
+ print("Recent audit entries:")
+ for entry in recent:
+ print(f" - {entry['action']} on {entry['entity_type']} at {entry['created_at']}")
+ print()
+
+
+def test_views():
+ """Test 8: Database views"""
+ print("=" * 60)
+ print("TEST 8: Database Views")
+ print("=" * 60)
+
+ # Test tticket_open_tickets view
+ open_tickets = execute_query(
+ "SELECT ticket_number, comment_count, age_hours FROM tticket_open_tickets LIMIT 5"
+ )
+
+ print_test(
+ "tticket_open_tickets view works",
+ open_tickets is not None,
+ f"Found {len(open_tickets)} open tickets"
+ )
+
+ # Test tticket_stats_by_status view
+ stats = execute_query(
+ "SELECT status, ticket_count FROM tticket_stats_by_status"
+ )
+
+ if stats:
+ print("Ticket statistics by status:")
+ for stat in stats:
+ print(f" - {stat['status']}: {stat['ticket_count']} tickets")
+ print()
+
+
+def main():
+ """Run all tests"""
+ print("\n")
+ print("🎫 TICKET MODULE TEST SUITE")
+ print("=" * 60)
+ print()
+
+ # Initialize database pool
+ print("🔌 Initializing database connection...")
+ init_db()
+ print("✅ Database connected\n")
+
+ try:
+ test_tables_exist()
+ test_ticket_number_generation()
+ test_prepaid_card_constraints()
+ test_generated_column()
+ test_ticket_service()
+ test_worklog_creation()
+ test_audit_logging()
+ test_views()
+
+ print("=" * 60)
+ print("✅ ALL TESTS COMPLETED")
+ print("=" * 60)
+ print()
+
+ except Exception as e:
+ print(f"\n❌ TEST SUITE FAILED: {e}\n")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()