diff --git a/app/alert_notes/__init__.py b/app/alert_notes/__init__.py new file mode 100644 index 0000000..0c2111e --- /dev/null +++ b/app/alert_notes/__init__.py @@ -0,0 +1 @@ +"""Alert Notes Module""" diff --git a/app/alert_notes/backend/__init__.py b/app/alert_notes/backend/__init__.py new file mode 100644 index 0000000..5cb72df --- /dev/null +++ b/app/alert_notes/backend/__init__.py @@ -0,0 +1,4 @@ +"""Alert Notes Backend Module""" +from app.alert_notes.backend.router import router + +__all__ = ["router"] diff --git a/app/alert_notes/backend/router.py b/app/alert_notes/backend/router.py new file mode 100644 index 0000000..5bf445a --- /dev/null +++ b/app/alert_notes/backend/router.py @@ -0,0 +1,515 @@ +""" +Alert Notes Router +API endpoints for contextual customer/contact alert system +""" + +from fastapi import APIRouter, HTTPException, Depends, Query +from typing import List, Optional, Dict +import logging +from datetime import datetime + +from app.core.database import execute_query, execute_update +from app.core.auth_dependencies import require_permission, get_current_user +from app.alert_notes.backend.schemas import ( + AlertNoteCreate, AlertNoteUpdate, AlertNoteFull, AlertNoteCheck, + AlertNoteRestriction, AlertNoteAcknowledgement, EntityType, Severity +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _check_user_can_handle(alert_id: int, current_user: dict) -> bool: + """ + Check if current user is allowed to handle the entity based on restrictions. + Returns True if no restrictions exist OR user matches a restriction. + """ + # Superadmins bypass restrictions + if current_user.get("is_superadmin"): + return True + + # Get restrictions for this alert + restrictions = execute_query( + """ + SELECT restriction_type, restriction_id + FROM alert_note_restrictions + WHERE alert_note_id = %s + """, + (alert_id,) + ) + + # No restrictions = everyone can handle + if not restrictions: + return True + + user_id = current_user["id"] + + # Get user's group IDs + user_groups = execute_query( + "SELECT group_id FROM user_groups WHERE user_id = %s", + (user_id,) + ) + user_group_ids = [g["group_id"] for g in user_groups] + + # Check if user matches any restriction + for restriction in restrictions: + if restriction["restriction_type"] == "user" and restriction["restriction_id"] == user_id: + return True + if restriction["restriction_type"] == "group" and restriction["restriction_id"] in user_group_ids: + return True + + return False + + +def _get_entity_name(entity_type: str, entity_id: int) -> Optional[str]: + """Get the name of the entity (customer or contact)""" + if entity_type == "customer": + result = execute_query( + "SELECT name FROM customers WHERE id = %s", + (entity_id,) + ) + return result[0]["name"] if result else None + elif entity_type == "contact": + result = execute_query( + "SELECT first_name, last_name FROM contacts WHERE id = %s", + (entity_id,) + ) + if result: + return f"{result[0]['first_name']} {result[0]['last_name']}" + return None + + +def _get_alert_with_relations(alert_id: int, current_user: dict) -> Optional[Dict]: + """Get alert note with all its relations""" + # Get main alert + alerts = execute_query( + """ + SELECT an.*, u.full_name as created_by_user_name + FROM alert_notes an + LEFT JOIN users u ON an.created_by_user_id = u.user_id + WHERE an.id = %s + """, + (alert_id,) + ) + + if not alerts: + return None + + alert = dict(alerts[0]) + + # Get entity name + alert["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"]) + + # Get restrictions + restrictions = execute_query( + """ + SELECT anr.*, + CASE + WHEN anr.restriction_type = 'group' THEN g.name + WHEN anr.restriction_type = 'user' THEN u.full_name + END as restriction_name + FROM alert_note_restrictions anr + LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id + LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id + WHERE anr.alert_note_id = %s + """, + (alert_id,) + ) + alert["restrictions"] = restrictions + + # Get acknowledgements + acknowledgements = execute_query( + """ + SELECT ana.*, u.full_name as user_name + FROM alert_note_acknowledgements ana + LEFT JOIN users u ON ana.user_id = u.user_id + WHERE ana.alert_note_id = %s + ORDER BY ana.acknowledged_at DESC + """, + (alert_id,) + ) + alert["acknowledgements"] = acknowledgements + + return alert + + +@router.get("/alert-notes/check", response_model=AlertNoteCheck) +async def check_alerts( + entity_type: EntityType = Query(..., description="Entity type (customer/contact)"), + entity_id: int = Query(..., description="Entity ID"), + current_user: dict = Depends(get_current_user) +): + """ + Check if there are active alert notes for a specific entity. + Returns alerts that the current user is allowed to see based on restrictions. + """ + # Get active alerts for this entity + alerts = execute_query( + """ + SELECT an.*, u.full_name as created_by_user_name + FROM alert_notes an + LEFT JOIN users u ON an.created_by_user_id = u.user_id + WHERE an.entity_type = %s + AND an.entity_id = %s + AND an.active = TRUE + ORDER BY + CASE an.severity + WHEN 'critical' THEN 1 + WHEN 'warning' THEN 2 + WHEN 'info' THEN 3 + END, + an.created_at DESC + """, + (entity_type.value, entity_id) + ) + + if not alerts: + return AlertNoteCheck( + has_alerts=False, + alerts=[], + user_can_handle=True, + user_has_acknowledged=False + ) + + # Enrich alerts with relations + enriched_alerts = [] + for alert in alerts: + alert_dict = dict(alert) + alert_dict["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"]) + + # Get restrictions + restrictions = execute_query( + """ + SELECT anr.*, + CASE + WHEN anr.restriction_type = 'group' THEN g.name + WHEN anr.restriction_type = 'user' THEN u.full_name + END as restriction_name + FROM alert_note_restrictions anr + LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id + LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id + WHERE anr.alert_note_id = %s + """, + (alert["id"],) + ) + alert_dict["restrictions"] = restrictions + + # Get acknowledgements + acknowledgements = execute_query( + """ + SELECT ana.*, u.full_name as user_name + FROM alert_note_acknowledgements ana + LEFT JOIN users u ON ana.user_id = u.user_id + WHERE ana.alert_note_id = %s + ORDER BY ana.acknowledged_at DESC + """, + (alert["id"],) + ) + alert_dict["acknowledgements"] = acknowledgements + + enriched_alerts.append(alert_dict) + + # Check if user can handle based on restrictions + user_can_handle = all(_check_user_can_handle(a["id"], current_user) for a in alerts) + + # Check if user has acknowledged all alerts that require it + user_id = current_user["id"] + user_has_acknowledged = True + for alert in alerts: + if alert["requires_acknowledgement"]: + ack = execute_query( + "SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s", + (alert["id"], user_id) + ) + if not ack: + user_has_acknowledged = False + break + + return AlertNoteCheck( + has_alerts=True, + alerts=enriched_alerts, + user_can_handle=user_can_handle, + user_has_acknowledged=user_has_acknowledged + ) + + +@router.post("/alert-notes/{alert_id}/acknowledge") +async def acknowledge_alert( + alert_id: int, + current_user: dict = Depends(get_current_user) +): + """ + Mark an alert note as acknowledged by the current user. + """ + # Check if alert exists + alert = execute_query( + "SELECT id, active FROM alert_notes WHERE id = %s", + (alert_id,) + ) + + if not alert: + raise HTTPException(status_code=404, detail="Alert note not found") + + if not alert[0]["active"]: + raise HTTPException(status_code=400, detail="Alert note is not active") + + user_id = current_user["id"] + + # Check if already acknowledged + existing = execute_query( + "SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s", + (alert_id, user_id) + ) + + if existing: + return {"status": "already_acknowledged", "alert_id": alert_id} + + # Insert acknowledgement + execute_update( + """ + INSERT INTO alert_note_acknowledgements (alert_note_id, user_id) + VALUES (%s, %s) + """, + (alert_id, user_id) + ) + + logger.info(f"Alert {alert_id} acknowledged by user {user_id}") + + return {"status": "acknowledged", "alert_id": alert_id} + + +@router.get("/alert-notes", response_model=List[AlertNoteFull]) +async def list_alerts( + entity_type: Optional[EntityType] = Query(None), + entity_id: Optional[int] = Query(None), + severity: Optional[Severity] = Query(None), + active: Optional[bool] = Query(None), + limit: int = Query(default=50, ge=1, le=500), + offset: int = Query(default=0, ge=0), + current_user: dict = Depends(require_permission("alert_notes.view")) +): + """ + List alert notes with filtering (admin endpoint). + Requires alert_notes.view permission. + """ + conditions = [] + params = [] + + if entity_type: + conditions.append("an.entity_type = %s") + params.append(entity_type.value) + + if entity_id: + conditions.append("an.entity_id = %s") + params.append(entity_id) + + if severity: + conditions.append("an.severity = %s") + params.append(severity.value) + + if active is not None: + conditions.append("an.active = %s") + params.append(active) + + where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" + + query = f""" + SELECT an.*, u.full_name as created_by_user_name + FROM alert_notes an + LEFT JOIN users u ON an.created_by_user_id = u.user_id + {where_clause} + ORDER BY an.created_at DESC + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + + alerts = execute_query(query, tuple(params)) + + # Enrich with relations + enriched_alerts = [] + for alert in alerts: + alert_full = _get_alert_with_relations(alert["id"], current_user) + if alert_full: + enriched_alerts.append(alert_full) + + return enriched_alerts + + +@router.post("/alert-notes", response_model=AlertNoteFull) +async def create_alert( + alert: AlertNoteCreate, + current_user: dict = Depends(require_permission("alert_notes.create")) +): + """ + Create a new alert note. + Requires alert_notes.create permission. + """ + # Verify entity exists + entity_name = _get_entity_name(alert.entity_type.value, alert.entity_id) + if not entity_name: + raise HTTPException( + status_code=404, + detail=f"{alert.entity_type.value.capitalize()} with ID {alert.entity_id} not found" + ) + + # Insert alert note + result = execute_query( + """ + INSERT INTO alert_notes ( + entity_type, entity_id, title, message, severity, + requires_acknowledgement, active, created_by_user_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + alert.entity_type.value, alert.entity_id, alert.title, alert.message, + alert.severity.value, alert.requires_acknowledgement, alert.active, + current_user["id"] + ) + ) + + if not result or len(result) == 0: + raise HTTPException(status_code=500, detail="Failed to create alert note") + + alert_id = result[0]["id"] + + # Insert restrictions + for group_id in alert.restriction_group_ids: + execute_query( + """ + INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id) + VALUES (%s, 'group', %s) + """, + (alert_id, group_id) + ) + + for user_id in alert.restriction_user_ids: + execute_query( + """ + INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id) + VALUES (%s, 'user', %s) + """, + (alert_id, user_id) + ) + + logger.info(f"Alert note {alert_id} created for {alert.entity_type.value} {alert.entity_id} by user {current_user['id']}") + + # Return full alert with relations + alert_full = _get_alert_with_relations(alert_id, current_user) + return alert_full + + +@router.patch("/alert-notes/{alert_id}", response_model=AlertNoteFull) +async def update_alert( + alert_id: int, + alert_update: AlertNoteUpdate, + current_user: dict = Depends(require_permission("alert_notes.edit")) +): + """ + Update an existing alert note. + Requires alert_notes.edit permission. + """ + # Check if alert exists + existing = execute_query( + "SELECT id FROM alert_notes WHERE id = %s", + (alert_id,) + ) + + if not existing: + raise HTTPException(status_code=404, detail="Alert note not found") + + # Build update query + update_fields = [] + params = [] + + if alert_update.title is not None: + update_fields.append("title = %s") + params.append(alert_update.title) + + if alert_update.message is not None: + update_fields.append("message = %s") + params.append(alert_update.message) + + if alert_update.severity is not None: + update_fields.append("severity = %s") + params.append(alert_update.severity.value) + + if alert_update.requires_acknowledgement is not None: + update_fields.append("requires_acknowledgement = %s") + params.append(alert_update.requires_acknowledgement) + + if alert_update.active is not None: + update_fields.append("active = %s") + params.append(alert_update.active) + + if update_fields: + query = f"UPDATE alert_notes SET {', '.join(update_fields)} WHERE id = %s" + params.append(alert_id) + execute_update(query, tuple(params)) + + # Update restrictions if provided + if alert_update.restriction_group_ids is not None or alert_update.restriction_user_ids is not None: + # Delete existing restrictions + execute_update( + "DELETE FROM alert_note_restrictions WHERE alert_note_id = %s", + (alert_id,) + ) + + # Insert new group restrictions + if alert_update.restriction_group_ids is not None: + for group_id in alert_update.restriction_group_ids: + execute_update( + """ + INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id) + VALUES (%s, 'group', %s) + """, + (alert_id, group_id) + ) + + # Insert new user restrictions + if alert_update.restriction_user_ids is not None: + for user_id in alert_update.restriction_user_ids: + execute_update( + """ + INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id) + VALUES (%s, 'user', %s) + """, + (alert_id, user_id) + ) + + logger.info(f"Alert note {alert_id} updated by user {current_user['id']}") + + # Return updated alert + alert_full = _get_alert_with_relations(alert_id, current_user) + return alert_full + + +@router.delete("/alert-notes/{alert_id}") +async def delete_alert( + alert_id: int, + current_user: dict = Depends(require_permission("alert_notes.delete")) +): + """ + Soft delete an alert note (sets active = false). + Requires alert_notes.delete permission. + """ + # Check if alert exists + existing = execute_query( + "SELECT id, active FROM alert_notes WHERE id = %s", + (alert_id,) + ) + + if not existing: + raise HTTPException(status_code=404, detail="Alert note not found") + + # Soft delete + execute_update( + "UPDATE alert_notes SET active = FALSE WHERE id = %s", + (alert_id,) + ) + + logger.info(f"Alert note {alert_id} deactivated by user {current_user['id']}") + + return {"status": "deleted", "alert_id": alert_id} diff --git a/app/alert_notes/backend/schemas.py b/app/alert_notes/backend/schemas.py new file mode 100644 index 0000000..41de994 --- /dev/null +++ b/app/alert_notes/backend/schemas.py @@ -0,0 +1,99 @@ +""" +Alert Notes Pydantic Schemas +Data models for contextual customer/contact alerts +""" + +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class EntityType(str, Enum): + """Entity types that can have alert notes""" + customer = "customer" + contact = "contact" + + +class Severity(str, Enum): + """Alert severity levels""" + info = "info" + warning = "warning" + critical = "critical" + + +class RestrictionType(str, Enum): + """Types of restrictions for alert notes""" + group = "group" + user = "user" + + +class AlertNoteRestriction(BaseModel): + """Alert note restriction (who can handle the customer/contact)""" + id: Optional[int] = None + alert_note_id: int + restriction_type: RestrictionType + restriction_id: int # References groups.id or users.user_id + restriction_name: Optional[str] = None # Filled by JOIN in query + created_at: Optional[datetime] = None + + +class AlertNoteAcknowledgement(BaseModel): + """Alert note acknowledgement record""" + id: Optional[int] = None + alert_note_id: int + user_id: int + user_name: Optional[str] = None # Filled by JOIN + acknowledged_at: Optional[datetime] = None + + +class AlertNoteBase(BaseModel): + """Base schema for alert notes""" + entity_type: EntityType + entity_id: int + title: str = Field(..., min_length=1, max_length=255) + message: str = Field(..., min_length=1) + severity: Severity = Severity.info + requires_acknowledgement: bool = True + active: bool = True + + +class AlertNoteCreate(AlertNoteBase): + """Schema for creating an alert note""" + restriction_group_ids: List[int] = [] # List of group IDs + restriction_user_ids: List[int] = [] # List of user IDs + + +class AlertNoteUpdate(BaseModel): + """Schema for updating an alert note""" + title: Optional[str] = Field(None, min_length=1, max_length=255) + message: Optional[str] = Field(None, min_length=1) + severity: Optional[Severity] = None + requires_acknowledgement: Optional[bool] = None + active: Optional[bool] = None + restriction_group_ids: Optional[List[int]] = None + restriction_user_ids: Optional[List[int]] = None + + +class AlertNoteFull(AlertNoteBase): + """Full alert note schema with all relations""" + id: int + created_by_user_id: Optional[int] = None + created_by_user_name: Optional[str] = None # Filled by JOIN + created_at: datetime + updated_at: datetime + + # Related data + restrictions: List[AlertNoteRestriction] = [] + acknowledgements: List[AlertNoteAcknowledgement] = [] + + # Entity info (filled by JOIN) + entity_name: Optional[str] = None + + +class AlertNoteCheck(BaseModel): + """Response for checking alerts on an entity""" + has_alerts: bool + alerts: List[AlertNoteFull] + user_can_handle: bool # Whether current user is allowed per restrictions + user_has_acknowledged: bool = False diff --git a/app/alert_notes/frontend/alert_box.html b/app/alert_notes/frontend/alert_box.html new file mode 100644 index 0000000..b84214a --- /dev/null +++ b/app/alert_notes/frontend/alert_box.html @@ -0,0 +1,199 @@ + + + + +
+ + diff --git a/app/alert_notes/frontend/alert_check.js b/app/alert_notes/frontend/alert_check.js new file mode 100644 index 0000000..fe23a19 --- /dev/null +++ b/app/alert_notes/frontend/alert_check.js @@ -0,0 +1,131 @@ +/** + * Alert Notes JavaScript Module + * Handles loading and displaying alert notes for customers and contacts + */ + +/** + * Load and display alerts for an entity + * @param {string} entityType - 'customer' or 'contact' + * @param {number} entityId - The entity ID + * @param {string} mode - 'inline' (show in page) or 'modal' (show popup) + * @param {string} containerId - Optional container ID for inline mode (default: 'alert-notes-container') + */ +async function loadAndDisplayAlerts(entityType, entityId, mode = 'inline', containerId = 'alert-notes-container') { + try { + const response = await fetch(`/api/v1/alert-notes/check?entity_type=${entityType}&entity_id=${entityId}`, { + credentials: 'include' + }); + + if (!response.ok) { + console.error('Failed to fetch alerts:', response.status); + return; + } + + const data = await response.json(); + + if (!data.has_alerts) { + // No alerts - clear container if in inline mode + if (mode === 'inline') { + const container = document.getElementById(containerId); + if (container) { + container.innerHTML = ''; + } + } + return; + } + + // Store for later use + window.currentAlertData = data; + + if (mode === 'modal') { + // Show modal popup + showAlertModal(data.alerts); + } else { + // Show inline + displayAlertsInline(data.alerts, containerId, data.user_has_acknowledged); + } + + } catch (error) { + console.error('Error loading alerts:', error); + } +} + +/** + * Display alerts inline in a container + * @param {Array} alerts - Array of alert objects + * @param {string} containerId - Container element ID + * @param {boolean} userHasAcknowledged - Whether user has acknowledged all + */ +function displayAlertsInline(alerts, containerId, userHasAcknowledged) { + const container = document.getElementById(containerId); + if (!container) { + console.error('Alert container not found:', containerId); + return; + } + + // Clear existing content + container.innerHTML = ''; + + // Add each alert + alerts.forEach(alert => { + // Set user_has_acknowledged on individual alert if needed + alert.user_has_acknowledged = userHasAcknowledged; + + // Render using the renderAlertBox function from alert_box.html + const alertHtml = renderAlertBox(alert); + container.innerHTML += alertHtml; + }); +} + +/** + * Acknowledge a single alert + * @param {number} alertId - The alert ID + * @param {HTMLElement} buttonElement - The button that was clicked + */ +async function acknowledgeAlert(alertId, buttonElement) { + try { + const response = await fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }); + + const data = await response.json(); + + if (data.status === 'acknowledged' || data.status === 'already_acknowledged') { + // Remove the alert box with fade animation + const alertBox = buttonElement.closest('.alert-note-box'); + if (alertBox) { + alertBox.style.opacity = '0'; + alertBox.style.transform = 'translateX(-20px)'; + alertBox.style.transition = 'all 0.3s'; + setTimeout(() => alertBox.remove(), 300); + } + } + } catch (error) { + console.error('Error acknowledging alert:', error); + alert('Kunne ikke markere som læst. Prøv igen.'); + } +} + +/** + * Initialize alert checking on page load + * Call this from your page's DOMContentLoaded or similar + * @param {string} entityType - 'customer' or 'contact' + * @param {number} entityId - The entity ID + * @param {Object} options - Optional settings {mode: 'inline'|'modal', containerId: 'element-id'} + */ +function initAlertNotes(entityType, entityId, options = {}) { + const mode = options.mode || 'inline'; + const containerId = options.containerId || 'alert-notes-container'; + + loadAndDisplayAlerts(entityType, entityId, mode, containerId); +} + +// Make functions globally available +window.loadAndDisplayAlerts = loadAndDisplayAlerts; +window.displayAlertsInline = displayAlertsInline; +window.acknowledgeAlert = acknowledgeAlert; +window.initAlertNotes = initAlertNotes; diff --git a/app/alert_notes/frontend/alert_form_modal.html b/app/alert_notes/frontend/alert_form_modal.html new file mode 100644 index 0000000..70b9602 --- /dev/null +++ b/app/alert_notes/frontend/alert_form_modal.html @@ -0,0 +1,551 @@ + + + + + + diff --git a/app/alert_notes/frontend/alert_modal.html b/app/alert_notes/frontend/alert_modal.html new file mode 100644 index 0000000..bf3ac8c --- /dev/null +++ b/app/alert_notes/frontend/alert_modal.html @@ -0,0 +1,198 @@ + + + + + + diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index 223cecf..f61d04a 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -134,6 +134,9 @@
+ @@ -144,6 +147,9 @@
+ +
+
@@ -784,6 +790,12 @@ {% endblock %} {% block extra_js %} + + +{% include "alert_notes/frontend/alert_box.html" %} +{% include "alert_notes/frontend/alert_modal.html" %} +{% include "alert_notes/frontend/alert_form_modal.html" %} + +{% include "alert_notes/frontend/alert_box.html" %} +{% include "alert_notes/frontend/alert_modal.html" %} +{% include "alert_notes/frontend/alert_form_modal.html" %} + diff --git a/templates/alert_notes/frontend/alert_form_modal.html b/templates/alert_notes/frontend/alert_form_modal.html new file mode 100644 index 0000000..70b9602 --- /dev/null +++ b/templates/alert_notes/frontend/alert_form_modal.html @@ -0,0 +1,551 @@ + + + + + + diff --git a/templates/alert_notes/frontend/alert_modal.html b/templates/alert_notes/frontend/alert_modal.html new file mode 100644 index 0000000..bf3ac8c --- /dev/null +++ b/templates/alert_notes/frontend/alert_modal.html @@ -0,0 +1,198 @@ + + + + + +