From 3806c7d0117bf51235226f232aa38a3be7770749 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 15 Dec 2025 23:40:23 +0100 Subject: [PATCH] feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite - Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views. --- app/core/config.py | 13 + app/services/email_workflow_service.py | 83 +- app/shared/frontend/base.html | 10 +- app/ticket/__init__.py | 10 + app/ticket/backend/__init__.py | 6 + app/ticket/backend/economic_export.py | 402 +++++++++ app/ticket/backend/email_integration.py | 472 ++++++++++ app/ticket/backend/klippekort_service.py | 504 +++++++++++ app/ticket/backend/models.py | 497 +++++++++++ app/ticket/backend/router.py | 818 ++++++++++++++++++ app/ticket/backend/ticket_service.py | 543 ++++++++++++ app/ticket/frontend/__init__.py | 6 + app/ticket/frontend/dashboard.html | 361 ++++++++ .../frontend/mockups/option1_splitscreen.html | 265 ++++++ .../frontend/mockups/option2_kanban.html | 251 ++++++ .../frontend/mockups/option3_powertable.html | 239 +++++ app/ticket/frontend/ticket_detail.html | 409 +++++++++ app/ticket/frontend/ticket_list.html | 272 ++++++ app/ticket/frontend/views.py | 451 ++++++++++ app/ticket/frontend/worklog_review.html | 560 ++++++++++++ docs/TICKET_EMAIL_INTEGRATION.md | 423 +++++++++ main.py | 4 + migrations/002_auth_system.sql | 15 + migrations/025_ticket_module.sql | 484 +++++++++++ migrations/026_ticket_permissions.sql | 59 ++ test_ticket_module.py | 490 +++++++++++ 26 files changed, 7635 insertions(+), 12 deletions(-) create mode 100644 app/ticket/__init__.py create mode 100644 app/ticket/backend/__init__.py create mode 100644 app/ticket/backend/economic_export.py create mode 100644 app/ticket/backend/email_integration.py create mode 100644 app/ticket/backend/klippekort_service.py create mode 100644 app/ticket/backend/models.py create mode 100644 app/ticket/backend/router.py create mode 100644 app/ticket/backend/ticket_service.py create mode 100644 app/ticket/frontend/__init__.py create mode 100644 app/ticket/frontend/dashboard.html create mode 100644 app/ticket/frontend/mockups/option1_splitscreen.html create mode 100644 app/ticket/frontend/mockups/option2_kanban.html create mode 100644 app/ticket/frontend/mockups/option3_powertable.html create mode 100644 app/ticket/frontend/ticket_detail.html create mode 100644 app/ticket/frontend/ticket_list.html create mode 100644 app/ticket/frontend/views.py create mode 100644 app/ticket/frontend/worklog_review.html create mode 100644 docs/TICKET_EMAIL_INTEGRATION.md create mode 100644 migrations/025_ticket_module.sql create mode 100644 migrations/026_ticket_permissions.sql create mode 100644 test_ticket_module.py 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 %} +
+
+ +
+
+
+ + +
+
+ Alle + Mine + Uløste +
+
+ +
+ +
+
+ Høj + 14:32 +
+
Netværksproblem i hovedkontoret
+
+ + Tech Corp A/S +
+
+ + +
+
+ Ny + 12:15 +
+
Printer løbet tør for toner
+
+ + Advokathuset +
+
+ +
+
+ Løst + I går +
+
Opsætning af ny bruger (Mette)
+
+ + Byg ApS +
+
+ + + {% for i in range(5) %} +
+
+ Venter + 2 dage siden +
+
Licens fornyelse Office 365
+
+ + Kunde {{i}} +
+
+ {% endfor %} +
+
+ + +
+
+
+
+ #TKT-20251215-005 + I Gang +
+
Netværksproblem i hovedkontoret
+
+
+ + + +
+
+ +
+ +
+
+
+ + Jens Jensen + via Email +
+ 15. dec 14:32 +
+

Hej Support,

Vi har problemer med internettet på hovedkontoret. Ingen kan komme på Wi-Fi, og kablet forbindelse virker heller ikke. Det haster meget!

+
+ + +
+
+
+ + Intern Note +
+ 15. dec 14:45 +
+

Jeg har tjekket Unifi controlleren. Switch #3 svarer ikke. Det er nok den der er nede.

+
+ + +
+
+
+ + Christian (Support) +
+ 15. dec 14:50 +
+

Hej Jens,

Jeg kigger på det med det samme. Jeg kan se vi har mistet forbindelsen til en af jeres switche. Jeg prøver at genstarte den remote.

+
+
+ + +
+
+
+{% 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 +
+
+
+ + Fabrik A/S +
+ 10m +
+
+ +
+
+ #TKT-008 + +
+
Ny medarbejder oprettelse
+
+
+ + Kontor ApS +
+ 2t +
+
+ + +
+
+ + +
+
+ I Gang + 2 +
+
+
+
+ #TKT-002 + +
+
Netværksproblem hovedkontor
+
+ Netværk +
+
+
+ + Tech Corp +
+ 1d +
+
+ +
+
+ #TKT-004 + +
+
Opdatering af firewall
+
+
+ + Sikkerhed A/S +
+ 3d +
+
+
+
+ + +
+
+ Venter + 4 +
+
+ {% for i in range(4) %} +
+
+ #TKT-01{{i}} + +
+
Bestilling af hardware {{i}}
+
+
+ + Kunde {{i}} +
+ 5d +
+
+ {% endfor %} +
+
+ + +
+
+ Løst + 12 +
+
+
+
+ #TKT-001 + +
+
Printer installation
+
+
+ + Advokat +
+ 1u +
+
+
+
+
+
+{% 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 +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for i in range(10) %} + + + + + + + + + + + {% endfor %} + +
IDEmneKundeStatusPrioritetTildeltOpdateret
#TKT-005Netværksproblem i hovedkontoret + + Tech Corp A/S + I GangHøj + + Christian + 10 min siden
#TKT-004Server nede i produktion + + Fabrik A/S + NyKritiskUfordelt1 time siden
#TKT-00{{i}}Support sag vedrørende faktura {{i}} + + Kunde {{i}} ApS + LøstNormal + + Morten + {{i}} dage siden
+
+
+
+{% 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 %} +
+ +
+
{{ ticket.ticket_number }}
+
{{ ticket.subject }}
+
+ + {{ ticket.status.replace('_', ' ').title() }} + + + {{ ticket.priority.title() }} Priority + +
+
+ + +
+ + Rediger + + + + + Tilbage + +
+ +
+ +
+ +
+
+
+ Beskrivelse +
+
+ {{ ticket.description or 'Ingen beskrivelse' }} +
+
+
+ + +
+
+
+ Kommentarer ({{ comments|length }}) +
+ {% if comments %} + {% for comment in comments %} +
+
+ + + {{ comment.user_name or 'System' }} + {% if comment.internal_note %} + Internal + {% endif %} + + + {{ comment.created_at.strftime('%d-%m-%Y %H:%M') if comment.created_at else '-' }} + +
+
+ {{ comment.comment_text }} +
+
+ {% endfor %} + {% else %} +
+ +

Ingen kommentarer endnu

+
+ {% endif %} +
+
+ + +
+
+
+ Worklog ({{ worklog|length }}) +
+ {% if worklog %} +
+ + + + + + + + + + + + + {% for entry in worklog %} + + + + + + + + + {% endfor %} + +
DatoTimerTypeBeskrivelseStatusMedarbejder
{{ 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 '-' }}
+
+ {% else %} +
+ +

Ingen worklog entries endnu

+
+ {% endif %} +
+
+ + + {% if attachments %} +
+
+
+ Vedhæftninger ({{ attachments|length }}) +
+ {% for attachment in attachments %} + + + {{ attachment.filename }} + ({{ (attachment.file_size / 1024)|round(1) }} KB) + + {% endfor %} +
+
+ {% endif %} +
+ + +
+ +
+
+
+ Ticket Information +
+
+ +
+ {% if ticket.customer_name %} + + {{ ticket.customer_name }} + + {% else %} + Ikke angivet + {% endif %} +
+
+
+ +
+ {{ 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 %} +
+
+ + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + {% endfor %} + +
TicketKundeStatusPrioritetTildeltAktivitetOprettet
+ {{ 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 '' }} +
+
+
+
+ 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

+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + Ryd filtre + +
+
+
+ + + {% if worklogs %} +
+
+ + + + + + + + + + + + + + + + {% for worklog in worklogs %} + + + + + + + + + + + + {% endfor %} + +
TicketKundeDatoTimerTypeFaktureringBeskrivelseMedarbejderHandlinger
+ {{ 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 %} +
+
+
+ {% 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()