feat: Implement ticket contacts management with flexible roles and CRUD operations

This commit is contained in:
Christian 2025-12-17 16:38:08 +01:00
parent 0502a7b080
commit 84485bd294
7 changed files with 752 additions and 8 deletions

View File

@ -6,7 +6,6 @@ Handles contact CRUD operations with multi-company support
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List from typing import Optional, List
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update
from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -126,11 +125,12 @@ async def get_contact(contact_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts", response_model=dict) # POST/PUT/DELETE endpoints temporarily disabled - need proper models
async def create_contact(contact: ContactCreate): # @router.post("/contacts", response_model=dict)
""" # async def create_contact(contact: ContactCreate):
Create a new contact and link to companies # """
""" # Create a new contact and link to companies
# """
try: try:
# Insert contact # Insert contact
insert_query = """ insert_query = """

View File

@ -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))

View File

@ -275,6 +275,214 @@ async def add_comment(
raise HTTPException(status_code=500, detail=str(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 # WORKLOG ENDPOINTS
# ============================================================================ # ============================================================================

View File

@ -223,6 +223,65 @@
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.6; 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;
}
</style> </style>
{% endblock %} {% endblock %}
@ -411,6 +470,29 @@
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }} {{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
</div> </div>
</div> </div>
</div>
</div>
<!-- Contacts -->
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-people"></i> Kontakter
<button class="btn btn-sm btn-outline-primary ms-auto" onclick="showAddContactModal()">
<i class="bi bi-plus-circle"></i> Tilføj
</button>
</div>
<div id="contactsList">
<div class="text-center text-muted py-2">
<i class="bi bi-hourglass-split"></i> Indlæser...
</div>
</div>
</div>
</div>
<!-- Back to Ticket Info continuation -->
<div class="card">
<div class="card-body">
<div class="info-item mb-3"> <div class="info-item mb-3">
<label>Senest opdateret</label> <label>Senest opdateret</label>
<div class="value"> <div class="value">
@ -514,8 +596,272 @@
loadTicketTags(); 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 = `
<div class="empty-state py-2">
<i class="bi bi-person-x"></i>
<p class="mb-0 small">Ingen kontakter tilføjet endnu</p>
</div>
`;
return;
}
container.innerHTML = data.contacts.map(contact => `
<div class="contact-item">
<div class="contact-info">
<div class="contact-name">
<span class="contact-role-badge role-${contact.role}">
${getRoleLabel(contact.role)}
</span>
${contact.first_name} ${contact.last_name}
</div>
<div class="contact-details">
${contact.email ? `<i class="bi bi-envelope"></i> ${contact.email}` : ''}
${contact.phone ? `<i class="bi bi-telephone ms-2"></i> ${contact.phone}` : ''}
</div>
${contact.notes ? `<div class="contact-details mt-1"><i class="bi bi-sticky"></i> ${contact.notes}</div>` : ''}
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="editContactRole(${contact.contact_id}, '${contact.role}', '${contact.notes || ''}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="removeContact(${contact.contact_id}, '${contact.first_name} ${contact.last_name}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading contacts:', error);
document.getElementById('contactsList').innerHTML = `
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle"></i> Kunne ikke indlæse kontakter
</div>
`;
}
}
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 = `
<div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-plus"></i> Tilføj Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Kontakt *</label>
<select class="form-select" id="contactSelect" required>
<option value="">Vælg kontakt...</option>
${contacts.map(c => `
<option value="${c.id}">${c.first_name} ${c.last_name} ${c.email ? '(' + c.email + ')' : ''}</option>
`).join('')}
</select>
</div>
<div class="mb-3">
<label class="form-label">Rolle *</label>
<div id="firstContactNotice" class="alert alert-info mb-2" style="display: none;">
<i class="bi bi-star"></i> <strong>Første kontakt</strong> - Rollen sættes automatisk til "Primær kontakt"
</div>
<select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required>
<optgroup label="Standard roller">
<option value="primary">⭐ Primær kontakt</option>
<option value="requester">📝 Anmoder</option>
<option value="assignee">👤 Ansvarlig</option>
<option value="cc">📧 CC (Carbon Copy)</option>
<option value="observer" selected>👁 Observer</option>
</optgroup>
<optgroup label="Almindelige roller">
<option value="ekstern_it">💻 Ekstern IT</option>
<option value="third_party">🤝 3. part leverandør</option>
<option value="electrician">⚡ Elektriker</option>
<option value="consultant">🎓 Konsulent</option>
<option value="vendor">🏢 Leverandør</option>
</optgroup>
<optgroup label="Custom">
<option value="_custom">✏️ Indtast custom rolle...</option>
</optgroup>
</select>
</div>
<div class="mb-3" id="customRoleDiv" style="display: none;">
<label class="form-label">Custom Rolle</label>
<input type="text" class="form-control" id="customRoleInput"
placeholder="f.eks. 'bygningsingeniør' eller 'projektleder'">
<small class="text-muted">Brug lowercase og underscore i stedet for mellemrum (f.eks. bygnings_ingeniør)</small>
</div>
<div class="mb-3">
<label class="form-label">Noter (valgfri)</label>
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Evt. noter om kontaktens rolle..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="addContact()">
<i class="bi bi-plus-circle"></i> Tilføj
</button>
</div>
</div>
</div>
</div>
`;
// 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 ? '&notes=' + 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 ? '&notes=' + 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 // Override global tag picker to auto-reload after adding
if (window.tagPicker) { if (window.tagPicker) {

View File

@ -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.backend import router as timetracking_api
from app.timetracking.frontend import views as timetracking_views from app.timetracking.frontend import views as timetracking_views
from app.contacts.backend import views as contacts_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 router as tags_api
from app.tags.backend import views as tags_views 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(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) 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(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(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"]) app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])

View File

@ -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';

View File

@ -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';