bmc_hub/app/ticket/backend/router.py
Christian ffb3d335bc feat: Add Simply-CRM integration setup documentation and configuration details
docs: Create vTiger & Simply-CRM integration setup guide with credential requirements

feat: Implement ticket system enhancements including relations, calendar events, templates, and AI suggestions

refactor: Update ticket system migration to include audit logging and enhanced email metadata
2025-12-16 15:36:11 +01:00

1411 lines
50 KiB
Python

"""
Ticket Module API Router
=========================
REST API endpoints for ticket system.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import JSONResponse
from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.economic_export import ticket_economic_service
from app.ticket.backend.models import (
TTicket,
TTicketCreate,
TTicketUpdate,
TTicketWithStats,
TTicketComment,
TTicketCommentCreate,
TTicketWorklog,
TTicketWorklogCreate,
TTicketWorklogUpdate,
TTicketWorklogWithDetails,
TicketListResponse,
TicketStatusUpdateRequest,
WorklogReviewResponse,
WorklogBillingRequest,
# Migration 026 models
TTicketRelation,
TTicketRelationCreate,
TTicketCalendarEvent,
TTicketCalendarEventCreate,
CalendarEventStatus,
TTicketTemplate,
TTicketTemplateCreate,
TemplateRenderRequest,
TemplateRenderResponse,
TTicketAISuggestion,
TTicketAISuggestionCreate,
AISuggestionStatus,
AISuggestionType,
AISuggestionReviewRequest,
TTicketAuditLog,
TicketMergeRequest,
TicketSplitRequest,
TicketDeadlineUpdateRequest
)
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from datetime import date
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# TICKET ENDPOINTS
# ============================================================================
@router.get("/tickets", response_model=TicketListResponse, tags=["Tickets"])
async def list_tickets(
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
customer_id: Optional[int] = Query(None, description="Filter by customer"),
assigned_to_user_id: Optional[int] = Query(None, description="Filter by assigned user"),
search: Optional[str] = Query(None, description="Search in subject/description"),
limit: int = Query(50, ge=1, le=100, description="Number of results"),
offset: int = Query(0, ge=0, description="Offset for pagination")
):
"""
List tickets with optional filters
- **status**: Filter by ticket status
- **priority**: Filter by priority level
- **customer_id**: Show tickets for specific customer
- **assigned_to_user_id**: Show tickets assigned to user
- **search**: Search in subject and description
"""
try:
tickets = TicketService.list_tickets(
status=status,
priority=priority,
customer_id=customer_id,
assigned_to_user_id=assigned_to_user_id,
search=search,
limit=limit,
offset=offset
)
# Get total count for pagination
total_query = "SELECT COUNT(*) as count FROM tticket_tickets WHERE 1=1"
params = []
if status:
total_query += " AND status = %s"
params.append(status)
if customer_id:
total_query += " AND customer_id = %s"
params.append(customer_id)
total_result = execute_query_single(total_query, tuple(params))
total = total_result['count'] if total_result else 0
return TicketListResponse(
tickets=tickets,
total=total,
page=offset // limit + 1,
page_size=limit
)
except Exception as e:
logger.error(f"❌ Error listing tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}", response_model=TTicketWithStats, tags=["Tickets"])
async def get_ticket(ticket_id: int):
"""
Get single ticket with statistics
Returns ticket with comment count, worklog hours, etc.
"""
try:
ticket = TicketService.get_ticket_with_stats(ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return ticket
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets", response_model=TTicket, status_code=status.HTTP_201_CREATED, tags=["Tickets"])
async def create_ticket(
ticket_data: TTicketCreate,
user_id: Optional[int] = Query(None, description="User creating ticket")
):
"""
Create new ticket
Ticket number will be auto-generated if not provided.
"""
try:
ticket = TicketService.create_ticket(ticket_data, user_id=user_id)
logger.info(f"✅ Created ticket {ticket['ticket_number']}")
return ticket
except Exception as e:
logger.error(f"❌ Error creating ticket: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/tickets/{ticket_id}", response_model=TTicket, tags=["Tickets"])
async def update_ticket(
ticket_id: int,
update_data: TTicketUpdate,
user_id: Optional[int] = Query(None, description="User making update")
):
"""
Update ticket (partial update)
Only provided fields will be updated.
"""
try:
ticket = TicketService.update_ticket(ticket_id, update_data, user_id=user_id)
return ticket
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error updating ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/status", response_model=TTicket, tags=["Tickets"])
async def update_ticket_status(
ticket_id: int,
request: TicketStatusUpdateRequest,
user_id: Optional[int] = Query(None, description="User changing status")
):
"""
Update ticket status with validation
Status transitions are validated according to workflow rules.
"""
try:
ticket = TicketService.update_ticket_status(
ticket_id,
request.status.value,
user_id=user_id,
note=request.note
)
return ticket
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error updating status for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/assign", response_model=TTicket, tags=["Tickets"])
async def assign_ticket(
ticket_id: int,
assigned_to_user_id: int = Query(..., description="User to assign to"),
user_id: Optional[int] = Query(None, description="User making assignment")
):
"""
Assign ticket to a user
"""
try:
ticket = TicketService.assign_ticket(ticket_id, assigned_to_user_id, user_id=user_id)
return ticket
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error assigning ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# COMMENT ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/comments", response_model=List[TTicketComment], tags=["Comments"])
async def list_comments(ticket_id: int):
"""
List all comments for a ticket
"""
try:
comments = execute_query_single(
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
(ticket_id,)
)
return comments or []
except Exception as e:
logger.error(f"❌ Error listing comments for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/comments", response_model=TTicketComment, status_code=status.HTTP_201_CREATED, tags=["Comments"])
async def add_comment(
ticket_id: int,
comment_text: str = Query(..., min_length=1, description="Comment text"),
is_internal: bool = Query(False, description="Is internal note"),
user_id: Optional[int] = Query(None, description="User adding comment")
):
"""
Add comment to ticket
- **is_internal**: If true, comment is only visible to staff
"""
try:
comment = TicketService.add_comment(
ticket_id,
comment_text,
user_id=user_id,
is_internal=is_internal
)
return comment
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"❌ Error adding comment to ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WORKLOG ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/worklog", response_model=List[TTicketWorklog], tags=["Worklog"])
async def list_worklog(ticket_id: int):
"""
List all worklog entries for a ticket
"""
try:
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE ticket_id = %s ORDER BY work_date DESC",
(ticket_id,)
)
return worklog or []
except Exception as e:
logger.error(f"❌ Error listing worklog for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/worklog", response_model=TTicketWorklog, status_code=status.HTTP_201_CREATED, tags=["Worklog"])
async def create_worklog(
ticket_id: int,
worklog_data: TTicketWorklogCreate,
user_id: Optional[int] = Query(None, description="User creating worklog")
):
"""
Create worklog entry for ticket
Creates time entry in draft status.
"""
try:
from psycopg2.extras import Json
worklog_id = execute_insert(
"""
INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
ticket_id,
worklog_data.work_date,
worklog_data.hours,
worklog_data.work_type.value,
worklog_data.description,
worklog_data.billing_method.value,
'draft',
user_id or worklog_data.user_id,
worklog_data.prepaid_card_id
)
)
# Log audit
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="created",
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
)
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
return worklog
except Exception as e:
logger.error(f"❌ Error creating worklog: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/worklog/{worklog_id}", response_model=TTicketWorklog, tags=["Worklog"])
async def update_worklog(
worklog_id: int,
update_data: TTicketWorklogUpdate,
user_id: Optional[int] = Query(None, description="User updating worklog")
):
"""
Update worklog entry (partial update)
Only draft entries can be fully edited.
"""
try:
# Get current worklog
current = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
# Build update query
updates = []
params = []
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if hasattr(value, 'value'):
value = value.value
updates.append(f"{field} = %s")
params.append(value)
if updates:
params.append(worklog_id)
query = f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s"
execute_update(query, tuple(params))
# Log audit
TicketService.log_audit(
ticket_id=current['ticket_id'],
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="updated",
details=update_dict
)
# Fetch updated
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
return worklog
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating worklog {worklog_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/worklog/review", response_model=WorklogReviewResponse, tags=["Worklog"])
async def review_worklog(
customer_id: Optional[int] = Query(None, description="Filter by customer"),
status: str = Query("draft", description="Filter by status (default: draft)")
):
"""
Get worklog entries for review/billing
Returns entries ready for review with ticket context.
"""
try:
from decimal import Decimal
query = """
SELECT w.*, t.ticket_number, t.subject AS ticket_subject,
t.customer_id, t.status AS ticket_status
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.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, t.customer_id"
worklogs = execute_query_single(query, tuple(params))
# Calculate totals
total_hours = Decimal('0')
total_billable_hours = Decimal('0')
for w in worklogs or []:
total_hours += Decimal(str(w['hours']))
if w['status'] in ['draft', 'billable']:
total_billable_hours += Decimal(str(w['hours']))
return WorklogReviewResponse(
worklogs=worklogs or [],
total=len(worklogs) if worklogs else 0,
total_hours=total_hours,
total_billable_hours=total_billable_hours
)
except Exception as e:
logger.error(f"❌ Error getting worklog review: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/mark-billable", tags=["Worklog"])
async def mark_worklog_billable(
request: WorklogBillingRequest,
user_id: Optional[int] = Query(None, description="User marking as billable")
):
"""
Mark worklog entries as billable
Changes status from draft to billable for selected entries.
"""
try:
updated_count = 0
for worklog_id in request.worklog_ids:
# Get worklog
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
if not worklog:
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
continue
if worklog['status'] != 'draft':
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
continue
# Update to billable
execute_update(
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",
(worklog_id,)
)
# Log audit
TicketService.log_audit(
ticket_id=worklog['ticket_id'],
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="marked_billable",
old_value="draft",
new_value="billable",
details={"note": request.note} if request.note else None
)
updated_count += 1
logger.info(f"✅ Marked {updated_count} worklog entries as billable")
return JSONResponse(
content={
"success": True,
"updated_count": updated_count,
"message": f"Marked {updated_count} entries as billable"
}
)
except Exception as e:
logger.error(f"❌ Error marking worklog as billable: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PREPAID CARD (KLIPPEKORT) ENDPOINTS
# ============================================================================
from app.ticket.backend.klippekort_service import KlippekortService
from app.ticket.backend.models import (
TPrepaidCard,
TPrepaidCardCreate,
TPrepaidCardUpdate,
TPrepaidCardWithStats,
TPrepaidTransaction,
PrepaidCardBalanceResponse,
PrepaidCardTopUpRequest
)
@router.get("/prepaid-cards", response_model=List[TPrepaidCard], tags=["Prepaid Cards"])
async def list_prepaid_cards(
customer_id: Optional[int] = Query(None, description="Filter by customer"),
status: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0)
):
"""
List prepaid cards with optional filters
"""
try:
cards = KlippekortService.list_cards(
customer_id=customer_id,
status=status,
limit=limit,
offset=offset
)
return cards
except Exception as e:
logger.error(f"❌ Error listing prepaid cards: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}", response_model=TPrepaidCardWithStats, tags=["Prepaid Cards"])
async def get_prepaid_card(card_id: int):
"""
Get prepaid card with usage statistics
"""
try:
card = KlippekortService.get_card_with_stats(card_id)
if not card:
raise HTTPException(status_code=404, detail=f"Prepaid card {card_id} not found")
return card
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting prepaid card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards", response_model=TPrepaidCard, status_code=status.HTTP_201_CREATED, tags=["Prepaid Cards"])
async def purchase_prepaid_card(
card_data: TPrepaidCardCreate,
user_id: Optional[int] = Query(None, description="User purchasing card")
):
"""
Purchase new prepaid card
CONSTRAINT: Only 1 active card allowed per customer.
Will fail if customer already has an active card.
"""
try:
card = KlippekortService.purchase_card(card_data, user_id=user_id)
logger.info(f"✅ Purchased prepaid card {card['card_number']}")
return card
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error purchasing prepaid card: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/customer/{customer_id}/balance", response_model=PrepaidCardBalanceResponse, tags=["Prepaid Cards"])
async def check_customer_balance(customer_id: int):
"""
Check prepaid card balance for customer
Returns balance info for customer's active card.
"""
try:
balance_info = KlippekortService.check_balance(customer_id)
if not balance_info['has_card']:
return PrepaidCardBalanceResponse(
card=None,
can_deduct=False,
message=f"Customer {customer_id} has no active prepaid card"
)
# Get card details
card = KlippekortService.get_card_with_stats(balance_info['card_id'])
return PrepaidCardBalanceResponse(
card=card,
can_deduct=balance_info['balance_hours'] > 0,
message=f"Balance: {balance_info['balance_hours']}h"
)
except Exception as e:
logger.error(f"❌ Error checking balance for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards/{card_id}/top-up", response_model=TPrepaidTransaction, tags=["Prepaid Cards"])
async def top_up_prepaid_card(
card_id: int,
request: PrepaidCardTopUpRequest,
user_id: Optional[int] = Query(None, description="User performing top-up")
):
"""
Top up prepaid card with additional hours
"""
try:
transaction = KlippekortService.top_up_card(
card_id,
request.hours,
user_id=user_id,
note=request.note
)
return transaction
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error topping up card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}/transactions", response_model=List[TPrepaidTransaction], tags=["Prepaid Cards"])
async def get_card_transactions(
card_id: int,
limit: int = Query(100, ge=1, le=500)
):
"""
Get transaction history for prepaid card
"""
try:
transactions = KlippekortService.get_transactions(card_id, limit=limit)
return transactions
except Exception as e:
logger.error(f"❌ Error getting transactions for card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id}", response_model=TPrepaidCard, tags=["Prepaid Cards"])
async def cancel_prepaid_card(
card_id: int,
reason: Optional[str] = Query(None, description="Cancellation reason"),
user_id: Optional[int] = Query(None, description="User cancelling card")
):
"""
Cancel/deactivate prepaid card
"""
try:
card = KlippekortService.cancel_card(card_id, user_id=user_id, reason=reason)
return card
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error cancelling card {card_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# STATISTICS ENDPOINTS
# ============================================================================
@router.get("/tickets/stats/by-status", tags=["Statistics"])
async def get_stats_by_status():
"""
Get ticket statistics grouped by status
"""
try:
stats = execute_query_single(
"SELECT * FROM tticket_stats_by_status ORDER BY status"
)
return stats or []
except Exception as e:
logger.error(f"❌ Error getting stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/stats/open", tags=["Statistics"])
async def get_open_tickets_stats():
"""
Get statistics for open tickets
"""
try:
stats = execute_query(
"""
SELECT
COUNT(*) as total_open,
COUNT(*) FILTER (WHERE status = 'open') as new_tickets,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
AVG(age_hours) as avg_age_hours
FROM tticket_open_tickets
""")
return stats or {}
except Exception as e:
logger.error(f"❌ Error getting open tickets stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# E-CONOMIC EXPORT ENDPOINTS
# ============================================================================
@router.post("/worklog/export/preview", tags=["E-conomic Export"])
async def preview_economic_export(
customer_id: int = Query(..., description="Customer ID"),
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
date_from: Optional[date] = Query(None, description="Start date filter"),
date_to: Optional[date] = Query(None, description="End date filter")
):
"""
Preview what would be exported to e-conomic without actually exporting
**Safety**: This is read-only and safe to call
"""
try:
preview = await ticket_economic_service.get_export_preview(
customer_id=customer_id,
worklog_ids=worklog_ids,
date_from=date_from,
date_to=date_to
)
return preview
except Exception as e:
logger.error(f"❌ Error generating export preview: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/worklog/export/execute", tags=["E-conomic Export"])
async def execute_economic_export(
customer_id: int = Query(..., description="Customer ID"),
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
date_from: Optional[date] = Query(None, description="Start date filter"),
date_to: Optional[date] = Query(None, description="End date filter")
):
"""
Export billable worklog entries to e-conomic as draft invoice
**⚠️ WARNING**: This creates invoices in e-conomic (subject to safety switches)
**Safety Switches**:
- `TICKET_ECONOMIC_READ_ONLY=true`: Blocks execution
- `TICKET_ECONOMIC_DRY_RUN=true`: Logs but doesn't execute
- Both must be `false` to actually export
**Process**:
1. Validates customer has e-conomic mapping
2. Collects billable worklog entries
3. Creates draft invoice in e-conomic
4. Marks worklog entries as "billed"
"""
try:
result = await ticket_economic_service.export_billable_worklog_batch(
customer_id=customer_id,
worklog_ids=worklog_ids,
date_from=date_from,
date_to=date_to
)
if result['status'] == 'blocked':
return JSONResponse(
status_code=403,
content={
'error': 'Export blocked by safety switches',
'read_only': result.get('read_only'),
'dry_run': result.get('dry_run'),
'message': 'Set TICKET_ECONOMIC_READ_ONLY=false and TICKET_ECONOMIC_DRY_RUN=false to enable'
}
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Error executing export: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# TICKET RELATIONS ENDPOINTS (Migration 026)
# ============================================================================
@router.post("/tickets/{ticket_id}/merge", tags=["Ticket Relations"])
async def merge_tickets(ticket_id: int, request: TicketMergeRequest):
"""
Flet flere tickets sammen til én primær ticket
**Process**:
1. Validerer at alle source tickets eksisterer
2. Kopierer kommentarer og worklogs til target ticket
3. Opretter relation records
4. Markerer source tickets som merged
5. Logger i audit trail
"""
try:
# Validate target ticket exists
target_ticket = execute_query_single(
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
(request.target_ticket_id,)
)
if not target_ticket:
raise HTTPException(status_code=404, detail=f"Target ticket {request.target_ticket_id} not found")
merged_count = 0
for source_id in request.source_ticket_ids:
# Validate source ticket
source_ticket = execute_query_single(
"SELECT id, ticket_number FROM tticket_tickets WHERE id = %s",
(source_id,)
)
if not source_ticket:
logger.warning(f"⚠️ Source ticket {source_id} not found, skipping")
continue
# Create relation
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, 'merged_into', %s, 1)
ON CONFLICT (ticket_id, related_ticket_id, relation_type) DO NOTHING""",
(source_id, request.target_ticket_id, request.reason)
)
# Mark source as merged
execute_query(
"""UPDATE tticket_tickets
SET is_merged = true, merged_into_ticket_id = %s, status = 'closed'
WHERE id = %s""",
(request.target_ticket_id, source_id),
fetch=False
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, 'merged_into', %s, %s)""",
(source_id, str(request.target_ticket_id), request.reason)
)
merged_count += 1
logger.info(f"✅ Merged ticket {source_id} into {request.target_ticket_id}")
return {
"status": "success",
"merged_count": merged_count,
"target_ticket": target_ticket,
"message": f"Successfully merged {merged_count} ticket(s)"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error merging tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/split", tags=["Ticket Relations"])
async def split_ticket(ticket_id: int, request: TicketSplitRequest):
"""
Opdel en ticket i to - flyt kommentarer til ny ticket
**Process**:
1. Opretter ny ticket med nyt subject
2. Flytter valgte kommentarer til ny ticket
3. Opretter relation
4. Logger i audit trail
"""
try:
# Validate source ticket
source_ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(request.source_ticket_id,)
)
if not source_ticket:
raise HTTPException(status_code=404, detail=f"Source ticket {request.source_ticket_id} not found")
# Create new ticket (inherit customer, contact, priority)
new_ticket_id = execute_insert(
"""INSERT INTO tticket_tickets
(subject, description, status, priority, customer_id, contact_id, source, created_by_user_id)
VALUES (%s, %s, 'open', %s, %s, %s, 'manual', 1)
RETURNING id""",
(request.new_subject, request.new_description, source_ticket['priority'],
source_ticket['customer_id'], source_ticket['contact_id'])
)
new_ticket_number = execute_query_single(
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
(new_ticket_id,)
)['ticket_number']
# Move comments
moved_comments = 0
for comment_id in request.comment_ids:
result = execute_query(
"UPDATE tticket_comments SET ticket_id = %s WHERE id = %s AND ticket_id = %s",
(new_ticket_id, comment_id, request.source_ticket_id),
fetch=False
)
if result:
moved_comments += 1
# Create relation
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, 'split_from', %s, 1)""",
(new_ticket_id, request.source_ticket_id, request.reason)
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, 'split_into', %s, %s)""",
(request.source_ticket_id, str(new_ticket_id), request.reason)
)
logger.info(f"✅ Split ticket {request.source_ticket_id} into {new_ticket_id}, moved {moved_comments} comments")
return {
"status": "success",
"new_ticket_id": new_ticket_id,
"new_ticket_number": new_ticket_number,
"moved_comments": moved_comments,
"message": f"Successfully split ticket into {new_ticket_number}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error splitting ticket: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
async def get_ticket_relations(ticket_id: int):
"""Hent alle relationer for en ticket (begge retninger)"""
try:
relations = execute_query(
"""SELECT r.*,
t.ticket_number as related_ticket_number,
t.subject as related_subject,
t.status as related_status
FROM tticket_all_relations r
LEFT JOIN tticket_tickets t ON r.related_ticket_id = t.id
WHERE r.ticket_id = %s
ORDER BY r.created_at DESC""",
(ticket_id,)
)
return {"relations": relations, "total": len(relations)}
except Exception as e:
logger.error(f"❌ Error fetching relations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
async def create_ticket_relation(ticket_id: int, relation: TTicketRelationCreate):
"""Opret en relation mellem to tickets"""
try:
# Validate both tickets exist
for tid in [relation.ticket_id, relation.related_ticket_id]:
ticket = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (tid,))
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {tid} not found")
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, %s, %s, 1)""",
(relation.ticket_id, relation.related_ticket_id, relation.relation_type, relation.reason)
)
return {"status": "success", "message": "Relation created"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating relation: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# CALENDAR EVENTS ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
async def get_calendar_events(ticket_id: int):
"""Hent alle kalender events for en ticket"""
try:
events = execute_query(
"""SELECT * FROM tticket_calendar_events
WHERE ticket_id = %s
ORDER BY event_date DESC, event_time DESC NULLS LAST""",
(ticket_id,)
)
return {"events": events, "total": len(events)}
except Exception as e:
logger.error(f"❌ Error fetching calendar events: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
async def create_calendar_event(ticket_id: int, event: TTicketCalendarEventCreate):
"""Opret kalender event (manual eller AI-foreslået)"""
try:
event_id = execute_insert(
"""INSERT INTO tticket_calendar_events
(ticket_id, title, description, event_type, event_date, event_time,
duration_minutes, all_day, status, suggested_by_ai, ai_confidence,
ai_source_text, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
RETURNING id""",
(ticket_id, event.title, event.description, event.event_type,
event.event_date, event.event_time, event.duration_minutes,
event.all_day, event.status, event.suggested_by_ai,
event.ai_confidence, event.ai_source_text)
)
logger.info(f"✅ Created calendar event {event_id} for ticket {ticket_id}")
return {"status": "success", "event_id": event_id, "message": "Calendar event created"}
except Exception as e:
logger.error(f"❌ Error creating calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
async def update_calendar_event(ticket_id: int, event_id: int, status: CalendarEventStatus):
"""Opdater calendar event status"""
try:
execute_query(
"""UPDATE tticket_calendar_events
SET status = %s, updated_at = CURRENT_TIMESTAMP,
completed_at = CASE WHEN %s = 'completed' THEN CURRENT_TIMESTAMP ELSE completed_at END
WHERE id = %s AND ticket_id = %s""",
(status, status, event_id, ticket_id),
fetch=False
)
return {"status": "success", "message": "Event updated"}
except Exception as e:
logger.error(f"❌ Error updating calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
async def delete_calendar_event(ticket_id: int, event_id: int):
"""Slet calendar event"""
try:
execute_query(
"DELETE FROM tticket_calendar_events WHERE id = %s AND ticket_id = %s",
(event_id, ticket_id),
fetch=False
)
return {"status": "success", "message": "Event deleted"}
except Exception as e:
logger.error(f"❌ Error deleting calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# TEMPLATES ENDPOINTS
# ============================================================================
@router.get("/templates", response_model=List[TTicketTemplate], tags=["Templates"])
async def list_templates(
category: Optional[str] = Query(None, description="Filter by category"),
active_only: bool = Query(True, description="Only show active templates")
):
"""List alle tilgængelige templates"""
try:
query = "SELECT * FROM tticket_templates WHERE 1=1"
params = []
if category:
query += " AND category = %s"
params.append(category)
if active_only:
query += " AND is_active = true"
query += " ORDER BY category, name"
templates = execute_query(query, tuple(params) if params else None)
return templates
except Exception as e:
logger.error(f"❌ Error listing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/templates", tags=["Templates"])
async def create_template(template: TTicketTemplateCreate):
"""Opret ny template"""
try:
template_id = execute_insert(
"""INSERT INTO tticket_templates
(name, description, category, subject_template, body_template,
available_placeholders, default_attachments, is_active,
requires_approval, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
RETURNING id""",
(template.name, template.description, template.category,
template.subject_template, template.body_template,
template.available_placeholders, template.default_attachments,
template.is_active, template.requires_approval)
)
logger.info(f"✅ Created template {template_id}: {template.name}")
return {"status": "success", "template_id": template_id, "message": "Template created"}
except Exception as e:
logger.error(f"❌ Error creating template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/render-template", response_model=TemplateRenderResponse, tags=["Templates"])
async def render_template(ticket_id: int, request: TemplateRenderRequest):
"""
Render template med ticket data
Erstatter placeholders med faktiske værdier:
- {{ticket_number}}
- {{ticket_subject}}
- {{customer_name}}
- {{contact_name}}
- etc.
"""
try:
# Get template
template = execute_query_single(
"SELECT * FROM tticket_templates WHERE id = %s",
(request.template_id,)
)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Get ticket with customer and contact data
ticket_data = execute_query_single(
"""SELECT t.*,
c.name as customer_name,
con.name as contact_name,
con.email as contact_email
FROM tticket_tickets t
LEFT JOIN customers c ON t.customer_id = c.id
LEFT JOIN contacts con ON t.contact_id = con.id
WHERE t.id = %s""",
(ticket_id,)
)
if not ticket_data:
raise HTTPException(status_code=404, detail="Ticket not found")
# Build replacement dict
replacements = {
'{{ticket_number}}': ticket_data.get('ticket_number', ''),
'{{ticket_subject}}': ticket_data.get('subject', ''),
'{{customer_name}}': ticket_data.get('customer_name', ''),
'{{contact_name}}': ticket_data.get('contact_name', ''),
'{{contact_email}}': ticket_data.get('contact_email', ''),
}
# Add custom data
if request.custom_data:
for key, value in request.custom_data.items():
replacements[f'{{{{{key}}}}}'] = str(value)
# Render subject and body
rendered_subject = template['subject_template']
rendered_body = template['body_template']
placeholders_used = []
for placeholder, value in replacements.items():
if placeholder in rendered_body or (rendered_subject and placeholder in rendered_subject):
placeholders_used.append(placeholder)
if rendered_subject:
rendered_subject = rendered_subject.replace(placeholder, value)
rendered_body = rendered_body.replace(placeholder, value)
return TemplateRenderResponse(
subject=rendered_subject,
body=rendered_body,
placeholders_used=placeholders_used
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rendering template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# AI SUGGESTIONS ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/suggestions", response_model=List[TTicketAISuggestion], tags=["AI Suggestions"])
async def get_ai_suggestions(
ticket_id: int,
status: Optional[AISuggestionStatus] = Query(None, description="Filter by status"),
suggestion_type: Optional[AISuggestionType] = Query(None, description="Filter by type")
):
"""Hent AI forslag for ticket"""
try:
query = "SELECT * FROM tticket_ai_suggestions WHERE ticket_id = %s"
params = [ticket_id]
if status:
query += " AND status = %s"
params.append(status)
if suggestion_type:
query += " AND suggestion_type = %s"
params.append(suggestion_type)
query += " ORDER BY created_at DESC"
suggestions = execute_query(query, tuple(params))
return suggestions
except Exception as e:
logger.error(f"❌ Error fetching AI suggestions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/suggestions/{suggestion_id}/review", tags=["AI Suggestions"])
async def review_ai_suggestion(ticket_id: int, suggestion_id: int, review: AISuggestionReviewRequest):
"""
Accepter eller afvis AI forslag
**VIGTIGT**: Denne endpoint ændrer KUN suggestion status.
Den udfører IKKE automatisk den foreslåede handling.
Brugeren skal selv implementere ændringen efter accept.
"""
try:
# Get suggestion
suggestion = execute_query_single(
"SELECT * FROM tticket_ai_suggestions WHERE id = %s AND ticket_id = %s",
(suggestion_id, ticket_id)
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
if suggestion['status'] != 'pending':
raise HTTPException(status_code=400, detail=f"Suggestion already {suggestion['status']}")
# Update status
new_status = 'accepted' if review.action == 'accept' else 'rejected'
execute_query(
"""UPDATE tticket_ai_suggestions
SET status = %s, reviewed_by_user_id = 1, reviewed_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(new_status, suggestion_id),
fetch=False
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, %s, %s, %s)""",
(ticket_id, f'ai_suggestion_{review.action}ed',
f"{suggestion['suggestion_type']}: {suggestion_id}", review.note)
)
logger.info(f"✅ AI suggestion {suggestion_id} {review.action}ed for ticket {ticket_id}")
return {
"status": "success",
"action": review.action,
"suggestion_type": suggestion['suggestion_type'],
"message": f"Suggestion {review.action}ed. Manual implementation required if accepted."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error reviewing AI suggestion: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# DEADLINE ENDPOINT
# ============================================================================
@router.put("/tickets/{ticket_id}/deadline", tags=["Tickets"])
async def update_ticket_deadline(ticket_id: int, request: TicketDeadlineUpdateRequest):
"""Opdater ticket deadline"""
try:
# Get current deadline
current = execute_query_single(
"SELECT deadline FROM tticket_tickets WHERE id = %s",
(ticket_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Ticket not found")
# Update deadline
execute_query(
"UPDATE tticket_tickets SET deadline = %s WHERE id = %s",
(request.deadline, ticket_id),
fetch=False
)
# Log audit (handled by trigger automatically)
if request.reason:
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value, reason)
VALUES (%s, 'deadline_change', 'deadline', %s, %s, %s)""",
(ticket_id, str(current.get('deadline')), str(request.deadline), request.reason)
)
logger.info(f"✅ Updated deadline for ticket {ticket_id}: {request.deadline}")
return {
"status": "success",
"old_deadline": current.get('deadline'),
"new_deadline": request.deadline,
"message": "Deadline updated"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating deadline: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# AUDIT LOG ENDPOINT
# ============================================================================
@router.get("/tickets/{ticket_id}/audit-log", response_model=List[TTicketAuditLog], tags=["Audit"])
async def get_audit_log(
ticket_id: int,
limit: int = Query(50, ge=1, le=200, description="Number of entries"),
offset: int = Query(0, ge=0, description="Offset for pagination")
):
"""Hent audit log for ticket (sporbarhed)"""
try:
logs = execute_query(
"""SELECT * FROM tticket_audit_log
WHERE ticket_id = %s
ORDER BY performed_at DESC
LIMIT %s OFFSET %s""",
(ticket_id, limit, offset)
)
return logs
except Exception as e:
logger.error(f"❌ Error fetching audit log: {e}")
raise HTTPException(status_code=500, detail=str(e))