""" 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)) # ============================================================================ # TICKET CONTACTS ENDPOINTS # ============================================================================ @router.get("/tickets/{ticket_id}/contacts", tags=["Contacts"]) async def get_ticket_contacts(ticket_id: int): """Get all contacts for a ticket with their roles""" try: query = """ SELECT tc.id, tc.contact_id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, tc.role, tc.added_at, tc.notes FROM tticket_contacts tc JOIN contacts c ON tc.contact_id = c.id WHERE tc.ticket_id = %s ORDER BY CASE tc.role WHEN 'primary' THEN 1 WHEN 'requester' THEN 2 WHEN 'assignee' THEN 3 WHEN 'cc' THEN 4 WHEN 'observer' THEN 5 ELSE 6 END, tc.added_at """ contacts = execute_query(query, (ticket_id,)) return {"contacts": contacts} except Exception as e: logger.error(f"❌ Error fetching contacts for ticket {ticket_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/tickets/{ticket_id}/contacts", status_code=status.HTTP_201_CREATED, tags=["Contacts"]) async def add_ticket_contact( ticket_id: int, contact_id: int = Query(..., description="Contact ID to add"), role: str = Query("observer", description="Role: primary, cc, observer, assignee, requester, eller custom (ekstern_it, third_party, electrician, etc.)"), notes: Optional[str] = Query(None, description="Optional notes about this contact's role"), user_id: Optional[int] = Query(None, description="User adding the contact") ): """ Add a contact to a ticket with a specific role Standard Roles: - **primary**: Main contact person - **requester**: Original person who requested the ticket - **assignee**: Person assigned to work on it - **cc**: Should be kept in the loop (carbon copy) - **observer**: Passively following the ticket Custom Roles (eksempler): - **ekstern_it**: Ekstern IT konsulent - **third_party**: 3. parts leverandør - **electrician**: Elektriker - **consultant**: Konsulent - Eller hvilken som helst custom rolle """ try: # Normalize role (lowercase, underscores) role = role.lower().replace(' ', '_').replace('-', '_') # Check if ticket exists ticket_check = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (ticket_id,)) if not ticket_check: raise HTTPException(status_code=404, detail="Ticket not found") # Check if contact exists contact_check = execute_query_single("SELECT id, first_name, last_name FROM contacts WHERE id = %s", (contact_id,)) if not contact_check: raise HTTPException(status_code=404, detail="Contact not found") # Check if this is the first contact - if so, force role to 'primary' existing_contacts = execute_query("SELECT COUNT(*) as count FROM tticket_contacts WHERE ticket_id = %s", (ticket_id,)) if existing_contacts and existing_contacts[0]['count'] == 0: role = 'primary' logger.info(f"✨ First contact on ticket {ticket_id} - auto-setting role to 'primary'") # Insert (will fail if duplicate due to UNIQUE constraint) query = """ INSERT INTO tticket_contacts (ticket_id, contact_id, role, notes, added_by_user_id) VALUES (%s, %s, %s, %s, %s) RETURNING id """ result = execute_insert(query, (ticket_id, contact_id, role, notes, user_id)) logger.info(f"✅ Added contact {contact_id} ({contact_check['first_name']} {contact_check['last_name']}) to ticket {ticket_id} as {role}") return { "id": result, "ticket_id": ticket_id, "contact_id": contact_id, "role": role, "contact_name": f"{contact_check['first_name']} {contact_check['last_name']}" } except Exception as e: if "duplicate key" in str(e).lower(): raise HTTPException(status_code=400, detail="Contact already added to this ticket") logger.error(f"❌ Error adding contact to ticket: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.put("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"]) async def update_ticket_contact_role( ticket_id: int, contact_id: int, role: str = Query(..., description="New role (standard eller custom)"), notes: Optional[str] = Query(None, description="Updated notes") ): """Update a contact's role on a ticket. Accepts both standard and custom roles.""" try: # Normalize role role = role.lower().replace(' ', '_').replace('-', '_') query = """ UPDATE tticket_contacts SET role = %s, notes = %s WHERE ticket_id = %s AND contact_id = %s RETURNING id """ result = execute_update(query, (role, notes, ticket_id, contact_id)) if not result: raise HTTPException(status_code=404, detail="Contact not found on this ticket") logger.info(f"✅ Updated contact {contact_id} role to {role} on ticket {ticket_id}") return {"success": True, "role": role} except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating contact role: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"]) async def remove_ticket_contact(ticket_id: int, contact_id: int): """Remove a contact from a ticket""" try: query = "DELETE FROM tticket_contacts WHERE ticket_id = %s AND contact_id = %s RETURNING id" result = execute_query_single(query, (ticket_id, contact_id)) if not result: raise HTTPException(status_code=404, detail="Contact not found on this ticket") logger.info(f"✅ Removed contact {contact_id} from ticket {ticket_id}") return {"success": True, "removed": True} except HTTPException: raise except Exception as e: logger.error(f"❌ Error removing contact: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/contacts/roles", tags=["Contacts"]) async def get_contact_roles(): """Get all used contact roles with usage statistics""" try: query = "SELECT * FROM vw_ticket_contact_roles" roles = execute_query(query) # Add standard roles with 0 count if not used yet standard_roles = [ {'role': 'primary', 'label': '⭐ Primær kontakt', 'category': 'standard'}, {'role': 'requester', 'label': '📝 Anmoder', 'category': 'standard'}, {'role': 'assignee', 'label': '👤 Ansvarlig', 'category': 'standard'}, {'role': 'cc', 'label': '📧 CC', 'category': 'standard'}, {'role': 'observer', 'label': '👁 Observer', 'category': 'standard'}, {'role': 'ekstern_it', 'label': '💻 Ekstern IT', 'category': 'common'}, {'role': 'third_party', 'label': '🤝 3. part', 'category': 'common'}, {'role': 'electrician', 'label': '⚡ Elektriker', 'category': 'common'}, {'role': 'consultant', 'label': '🎓 Konsulent', 'category': 'common'}, {'role': 'vendor', 'label': '🏢 Leverandør', 'category': 'common'}, ] # Merge with used roles used_role_names = {r['role'] for r in roles} all_roles = standard_roles.copy() # Add custom roles that are actually in use for role in roles: if role['role'] not in {r['role'] for r in standard_roles}: all_roles.append({ 'role': role['role'], 'label': role['role'].replace('_', ' ').title(), 'category': 'custom', 'usage_count': role['usage_count'], 'tickets_count': role['tickets_count'] }) return {"roles": all_roles} except Exception as e: logger.error(f"❌ Error fetching contact roles: {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. If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active. """ try: from psycopg2.extras import Json # Handle prepaid card selection/validation prepaid_card_id = worklog_data.prepaid_card_id if worklog_data.billing_method.value == 'prepaid_card': # Get customer_id from ticket ticket = execute_query_single( "SELECT customer_id FROM tticket_tickets WHERE id = %s", (ticket_id,)) if not ticket: raise HTTPException(status_code=404, detail="Ticket not found") customer_id = ticket['customer_id'] # Get active prepaid cards for customer active_cards = execute_query( """SELECT id, remaining_hours, expires_at FROM tticket_prepaid_cards WHERE customer_id = %s AND status = 'active' ORDER BY expires_at ASC NULLS LAST, created_at ASC""", (customer_id,)) if not active_cards: raise HTTPException( status_code=400, detail="Kunden har ingen aktive klippekort") if len(active_cards) == 1: # Auto-select if only 1 active if not prepaid_card_id: prepaid_card_id = active_cards[0]['id'] logger.info(f"🎫 Auto-selected prepaid card {prepaid_card_id} (only active card)") else: # Multiple active cards: require explicit selection if not prepaid_card_id: raise HTTPException( status_code=400, detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.") # Validate selected card is active and belongs to customer selected = next((c for c in active_cards if c['id'] == prepaid_card_id), None) if not selected: raise HTTPException( status_code=400, detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden") worklog_id = execute_insert( """ INSERT INTO tticket_worklog (ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( 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, prepaid_card_id, worklog_data.is_internal ) ) # 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, "is_internal": worklog_data.is_internal } ) worklog = execute_query_single( "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. If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active. """ try: # Get current worklog current = execute_query_single( "SELECT w.*, t.customer_id FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.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) # Handle prepaid card selection/validation if billing_method is being set to prepaid_card if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card': customer_id = current['customer_id'] # Get active prepaid cards for customer active_cards = execute_query( """SELECT id, remaining_hours, expires_at FROM tticket_prepaid_cards WHERE customer_id = %s AND status = 'active' ORDER BY expires_at ASC NULLS LAST, created_at ASC""", (customer_id,)) if not active_cards: raise HTTPException( status_code=400, detail="Kunden har ingen aktive klippekort") if len(active_cards) == 1: # Auto-select if only 1 active and not explicitly provided if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']: update_dict['prepaid_card_id'] = active_cards[0]['id'] logger.info(f"🎫 Auto-selected prepaid card {update_dict['prepaid_card_id']} (only active card)") else: # Multiple active cards: require explicit selection if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']: raise HTTPException( status_code=400, detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.") # Validate selected card if provided if 'prepaid_card_id' in update_dict and update_dict['prepaid_card_id']: selected = next((c for c in active_cards if c['id'] == update_dict['prepaid_card_id']), None) if not selected: raise HTTPException( status_code=400, detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden") 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))