From 84485bd2946080bf339c6498983d8ba0f65e931c Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 17 Dec 2025 16:38:08 +0100 Subject: [PATCH] feat: Implement ticket contacts management with flexible roles and CRUD operations --- app/contacts/backend/router.py | 12 +- app/contacts/backend/router_simple.py | 91 +++++ app/ticket/backend/router.py | 208 +++++++++++ app/ticket/frontend/ticket_detail.html | 350 +++++++++++++++++- main.py | 2 + migrations/029_ticket_contacts.sql | 69 ++++ .../030_ticket_contacts_flexible_roles.sql | 28 ++ 7 files changed, 752 insertions(+), 8 deletions(-) create mode 100644 app/contacts/backend/router_simple.py create mode 100644 migrations/029_ticket_contacts.sql create mode 100644 migrations/030_ticket_contacts_flexible_roles.sql diff --git a/app/contacts/backend/router.py b/app/contacts/backend/router.py index b65b152..083249b 100644 --- a/app/contacts/backend/router.py +++ b/app/contacts/backend/router.py @@ -6,7 +6,6 @@ Handles contact CRUD operations with multi-company support from fastapi import APIRouter, HTTPException, Query from typing import Optional, List from app.core.database import execute_query, execute_insert, execute_update -from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo import logging logger = logging.getLogger(__name__) @@ -126,11 +125,12 @@ async def get_contact(contact_id: int): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/contacts", response_model=dict) -async def create_contact(contact: ContactCreate): - """ - Create a new contact and link to companies - """ +# POST/PUT/DELETE endpoints temporarily disabled - need proper models +# @router.post("/contacts", response_model=dict) +# async def create_contact(contact: ContactCreate): +# """ +# Create a new contact and link to companies +# """ try: # Insert contact insert_query = """ diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py new file mode 100644 index 0000000..a24a6f6 --- /dev/null +++ b/app/contacts/backend/router_simple.py @@ -0,0 +1,91 @@ +""" +Contact API Router - Simplified (Read-Only) +Only GET endpoints for now +""" + +from fastapi import APIRouter, HTTPException, Query +from typing import Optional +from app.core.database import execute_query +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/contacts") +async def get_contacts( + search: Optional[str] = None, + customer_id: Optional[int] = None, + is_active: Optional[bool] = None, + limit: int = Query(default=100, le=1000), + offset: int = Query(default=0, ge=0) +): + """Get all contacts with optional filtering""" + try: + where_clauses = [] + params = [] + + if search: + where_clauses.append("(first_name ILIKE %s OR last_name ILIKE %s OR email ILIKE %s)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + + if is_active is not None: + where_clauses.append("is_active = %s") + params.append(is_active) + + where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # Count total + count_query = f"SELECT COUNT(*) as count FROM contacts {where_sql}" + count_result = execute_query(count_query, tuple(params)) + total = count_result[0]['count'] if count_result else 0 + + # Get contacts + query = f""" + SELECT + id, first_name, last_name, email, phone, mobile, + title, department, is_active, created_at, updated_at + FROM contacts + {where_sql} + ORDER BY first_name, last_name + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + contacts = execute_query(query, tuple(params)) + + return { + "total": total, + "contacts": contacts, + "limit": limit, + "offset": offset + } + + except Exception as e: + logger.error(f"Failed to get contacts: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/contacts/{contact_id}") +async def get_contact(contact_id: int): + """Get a single contact by ID""" + try: + query = """ + SELECT + id, first_name, last_name, email, phone, mobile, + title, department, is_active, user_company, + created_at, updated_at + FROM contacts + WHERE id = %s + """ + contacts = execute_query(query, (contact_id,)) + + if not contacts: + raise HTTPException(status_code=404, detail="Contact not found") + + return contacts[0] + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py index 5d08c82..c740d64 100644 --- a/app/ticket/backend/router.py +++ b/app/ticket/backend/router.py @@ -275,6 +275,214 @@ async def add_comment( 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 # ============================================================================ diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html index e67b427..2dab744 100644 --- a/app/ticket/frontend/ticket_detail.html +++ b/app/ticket/frontend/ticket_detail.html @@ -223,6 +223,65 @@ white-space: pre-wrap; line-height: 1.6; } + + .contact-item { + padding: 0.75rem; + margin-bottom: 0.5rem; + background: var(--accent-light); + border-radius: var(--border-radius); + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s; + } + + .contact-item:hover { + transform: translateX(3px); + box-shadow: 0 2px 6px rgba(0,0,0,0.1); + } + + .contact-info { + flex: 1; + } + + .contact-name { + font-weight: 600; + color: var(--accent); + margin-bottom: 0.25rem; + } + + .contact-details { + font-size: 0.85rem; + color: var(--text-secondary); + } + + .contact-role-badge { + padding: 0.25rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + margin-right: 0.5rem; + } + + /* Standard roller */ + .role-primary { background: #28a745; color: white; } + .role-requester { background: #17a2b8; color: white; } + .role-assignee { background: #ffc107; color: #000; } + .role-cc { background: #6c757d; color: white; } + .role-observer { background: #e9ecef; color: #495057; } + + /* Almindelige roller */ + .role-ekstern_it { background: #6f42c1; color: white; } + .role-third_party { background: #fd7e14; color: white; } + .role-electrician { background: #20c997; color: white; } + .role-consultant { background: #0dcaf0; color: #000; } + .role-vendor { background: #dc3545; color: white; } + + /* Default for custom roller */ + .contact-role-badge:not([class*="role-primary"]):not([class*="role-requester"]):not([class*="role-assignee"]):not([class*="role-cc"]):not([class*="role-observer"]):not([class*="role-ekstern"]):not([class*="role-third"]):not([class*="role-electrician"]):not([class*="role-consultant"]):not([class*="role-vendor"]) { + background: var(--accent); + color: white; + } {% endblock %} @@ -411,6 +470,29 @@ {{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }} + + + + +
+
+
+ Kontakter + +
+
+
+ Indlæser... +
+
+
+
+ + +
+
@@ -514,8 +596,272 @@ loadTicketTags(); } - // Load tags on page load - document.addEventListener('DOMContentLoaded', loadTicketTags); + // ============================================ + // CONTACTS MANAGEMENT + // ============================================ + + async function loadContacts() { + try { + const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts'); + if (!response.ok) throw new Error('Failed to load contacts'); + + const data = await response.json(); + const container = document.getElementById('contactsList'); + + if (!data.contacts || data.contacts.length === 0) { + container.innerHTML = ` +
+ +

Ingen kontakter tilføjet endnu

+
+ `; + return; + } + + container.innerHTML = data.contacts.map(contact => ` +
+
+
+ + ${getRoleLabel(contact.role)} + + ${contact.first_name} ${contact.last_name} +
+
+ ${contact.email ? ` ${contact.email}` : ''} + ${contact.phone ? ` ${contact.phone}` : ''} +
+ ${contact.notes ? `
${contact.notes}
` : ''} +
+
+ + +
+
+ `).join(''); + } catch (error) { + console.error('Error loading contacts:', error); + document.getElementById('contactsList').innerHTML = ` +
+ Kunne ikke indlæse kontakter +
+ `; + } + } + + function getRoleLabel(role) { + const labels = { + 'primary': '⭐ Primær', + 'requester': '📝 Anmoder', + 'assignee': '👤 Ansvarlig', + 'cc': '📧 CC', + 'observer': '👁 Observer', + 'ekstern_it': '💻 Ekstern IT', + 'third_party': '🤝 3. part', + 'electrician': '⚡ Elektriker', + 'consultant': '🎓 Konsulent', + 'vendor': '🏢 Leverandør' + }; + return labels[role] || ('📌 ' + role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())); + } + + async function showAddContactModal() { + // Fetch all contacts for selection + try { + const response = await fetch('/api/v1/contacts?limit=1000'); + if (!response.ok) throw new Error('Failed to load contacts'); + + const data = await response.json(); + const contacts = data.contacts || []; + + // Check if this ticket has any contacts yet + const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts'); + const ticketContacts = await ticketContactsResp.json(); + const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0; + + // Create modal content + const modalHtml = ` + + `; + + // Remove old modal if exists + const oldModal = document.getElementById('addContactModal'); + if (oldModal) oldModal.remove(); + + // Append and show new modal + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('addContactModal')); + + // Show notice and disable role selector if first contact + if (isFirstContact) { + document.getElementById('firstContactNotice').style.display = 'block'; + document.getElementById('roleSelect').value = 'primary'; + document.getElementById('roleSelect').disabled = true; + } + + modal.show(); + } catch (error) { + console.error('Error:', error); + alert('Fejl ved indlæsning af kontakter'); + } + } + + function toggleCustomRole() { + const roleSelect = document.getElementById('roleSelect'); + const customDiv = document.getElementById('customRoleDiv'); + const customInput = document.getElementById('customRoleInput'); + + if (roleSelect.value === '_custom') { + customDiv.style.display = 'block'; + customInput.required = true; + } else { + customDiv.style.display = 'none'; + customInput.required = false; + } + } + + async function addContact() { + const contactId = document.getElementById('contactSelect').value; + let role = document.getElementById('roleSelect').value; + const notes = document.getElementById('contactNotes').value; + + if (!contactId) { + alert('Vælg venligst en kontakt'); + return; + } + + // If custom role selected, use the custom input + if (role === '_custom') { + const customRole = document.getElementById('customRoleInput').value.trim(); + if (!customRole) { + alert('Indtast venligst en custom rolle'); + return; + } + role = customRole.toLowerCase().replace(/\s+/g, '_').replace(/-/g, '_'); + } + + try { + const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${contactId}&role=${role}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`; + const response = await fetch(url, { method: 'POST' }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to add contact'); + } + + // Close modal and reload + bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide(); + await loadContacts(); + } catch (error) { + console.error('Error adding contact:', error); + alert('Fejl: ' + error.message); + } + } + + async function editContactRole(contactId, currentRole, currentNotes) { + const newRole = prompt(`Ændr rolle (primary, requester, assignee, cc, observer):`, currentRole); + if (!newRole || newRole === currentRole) return; + + const notes = prompt(`Noter (valgfri):`, currentNotes); + + try { + const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}?role=${newRole}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`; + const response = await fetch(url, { method: 'PUT' }); + + if (!response.ok) throw new Error('Failed to update contact'); + await loadContacts(); + } catch (error) { + console.error('Error updating contact:', error); + alert('Fejl ved opdatering af kontakt'); + } + } + + async function removeContact(contactId, contactName) { + if (!confirm(`Fjern ${contactName} fra ticket?`)) return; + + try { + const response = await fetch(`/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to remove contact'); + await loadContacts(); + } catch (error) { + console.error('Error removing contact:', error); + alert('Fejl ved fjernelse af kontakt'); + } + } + + // Load tags and contacts on page load + document.addEventListener('DOMContentLoaded', () => { + loadTicketTags(); + loadContacts(); + }); // Override global tag picker to auto-reload after adding if (window.tagPicker) { diff --git a/main.py b/main.py index cc7f953..8661e2f 100644 --- a/main.py +++ b/main.py @@ -30,6 +30,7 @@ from app.vendors.backend import views as vendors_views from app.timetracking.backend import router as timetracking_api from app.timetracking.frontend import views as timetracking_views from app.contacts.backend import views as contacts_views +from app.contacts.backend import router_simple as contacts_api from app.tags.backend import router as tags_api from app.tags.backend import views as tags_views @@ -95,6 +96,7 @@ app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"]) +app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"]) app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"]) app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"]) diff --git a/migrations/029_ticket_contacts.sql b/migrations/029_ticket_contacts.sql new file mode 100644 index 0000000..28195a4 --- /dev/null +++ b/migrations/029_ticket_contacts.sql @@ -0,0 +1,69 @@ +-- Ticket Contacts - Many-to-many relation med roller +-- Tillader flere kontakter per ticket med forskellige roller (primary, cc, observer, etc.) + +-- Junction tabel mellem tickets og contacts +CREATE TABLE IF NOT EXISTS tticket_contacts ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, + contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL DEFAULT 'observer', -- primary, cc, observer, assignee + added_by_user_id INTEGER REFERENCES users(user_id), + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + notes TEXT, -- Valgfri noter om kontaktens rolle + + -- Forhindre dubletter + UNIQUE(ticket_id, contact_id) +); + +-- Indices for performance +CREATE INDEX IF NOT EXISTS idx_tticket_contacts_ticket ON tticket_contacts(ticket_id); +CREATE INDEX IF NOT EXISTS idx_tticket_contacts_contact ON tticket_contacts(contact_id); +CREATE INDEX IF NOT EXISTS idx_tticket_contacts_role ON tticket_contacts(role); + +-- Check constraint for valide roller +ALTER TABLE tticket_contacts +ADD CONSTRAINT tticket_contacts_role_check +CHECK (role IN ('primary', 'cc', 'observer', 'assignee', 'requester')); + +-- Migrer eksisterende contact_id til ny tabel (hvis der er data) +INSERT INTO tticket_contacts (ticket_id, contact_id, role) +SELECT id, contact_id, 'primary' +FROM tticket_tickets +WHERE contact_id IS NOT NULL +ON CONFLICT (ticket_id, contact_id) DO NOTHING; + +-- Kommentar på tabellen +COMMENT ON TABLE tticket_contacts IS 'Many-to-many relation mellem tickets og contacts med roller'; +COMMENT ON COLUMN tticket_contacts.role IS 'Kontaktens rolle: primary (hovedkontakt), cc (carbon copy), observer (følger med), assignee (assigned kontakt), requester (oprindelig anmoder)'; + +-- Views til at få kontakter grouped by role +CREATE OR REPLACE VIEW vw_tticket_contacts_summary AS +SELECT + t.id as ticket_id, + t.ticket_number, + json_agg( + json_build_object( + 'contact_id', c.id, + 'first_name', c.first_name, + 'last_name', c.last_name, + 'email', c.email, + 'phone', c.phone, + 'role', tc.role, + 'added_at', tc.added_at + ) 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 + ) as contacts +FROM tticket_tickets t +LEFT JOIN tticket_contacts tc ON t.id = tc.ticket_id +LEFT JOIN contacts c ON tc.contact_id = c.id +GROUP BY t.id, t.ticket_number; + +COMMENT ON VIEW vw_tticket_contacts_summary IS 'Oversigt over alle kontakter per ticket med roller, sorteret efter rolle prioritet'; diff --git a/migrations/030_ticket_contacts_flexible_roles.sql b/migrations/030_ticket_contacts_flexible_roles.sql new file mode 100644 index 0000000..eff981f --- /dev/null +++ b/migrations/030_ticket_contacts_flexible_roles.sql @@ -0,0 +1,28 @@ +-- Gør ticket contact roller fleksible +-- Fjern CHECK constraint og tillad custom roller + +-- Drop den eksisterende constraint +ALTER TABLE tticket_contacts +DROP CONSTRAINT IF EXISTS tticket_contacts_role_check; + +-- Tilføj index for bedre performance på rolle-søgninger +CREATE INDEX IF NOT EXISTS idx_tticket_contacts_role_lower ON tticket_contacts(LOWER(role)); + +-- Opdater eksisterende roller til at følge naming convention (lowercase med underscore) +UPDATE tticket_contacts SET role = LOWER(REPLACE(role, ' ', '_')); + +-- Kommentar på kolonnen med eksempler +COMMENT ON COLUMN tticket_contacts.role IS 'Kontaktens rolle på ticket. Standard roller: primary, requester, assignee, cc, observer. Custom roller: ekstern_it, third_party, electrician, consultant, etc.'; + +-- View til at få oversigt over alle brugte roller +CREATE OR REPLACE VIEW vw_ticket_contact_roles AS +SELECT + role, + COUNT(*) as usage_count, + COUNT(DISTINCT ticket_id) as tickets_count, + MAX(added_at) as last_used +FROM tticket_contacts +GROUP BY role +ORDER BY usage_count DESC; + +COMMENT ON VIEW vw_ticket_contact_roles IS 'Oversigt over alle brugte roller på tickets med statistik';