From a36e3e716fba37b939c47f32f1c4e67aeffa263c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 12 May 2026 08:41:13 +0200 Subject: [PATCH] feat: Add Service Contract Report page with customer and contract selection - Implemented a new HTML page for generating service contract reports. - Added CSS styles for report layout and components. - Developed JavaScript functionality for loading customers and contracts, fetching report data, and rendering metrics and cases. - Included buttons for downloading reports in PDF and Excel formats. docs: Create Route Auth Audit for route access control - Generated an audit report detailing route access requirements. - Classified routes based on authentication needs and documented them in a markdown file. feat: Introduce buzzwords and mission projects tables in the database - Created `buzzwords` and `sag_buzzwords` tables for managing keywords related to SAG cases. - Established `mission_projects`, `mission_project_milestones`, and `mission_project_blockers` tables for project management. - Updated `sag_sager` table to link with mission projects and milestones, including necessary foreign key constraints. --- .env.example | 3 + app/auth/backend/views.py | 12 +- app/core/config.py | 3 + app/dashboard/backend/mission_router.py | 188 ++ app/dashboard/backend/mission_service.py | 700 ++++- app/dashboard/backend/router.py | 28 +- app/dashboard/backend/views.py | 49 +- .../frontend/mission_control_legacy.html | 1953 +++++++++++++ .../frontend/mission_control_v2.html | 2455 +++++++++++++++++ app/emails/backend/router.py | 296 +- app/emails/frontend/emails_v2.html | 1287 +++++++++ app/emails/frontend/views.py | 20 +- app/modules/orders/templates/list.html | 107 +- app/modules/sag/backend/router.py | 243 +- app/modules/sag/frontend/views.py | 48 + app/modules/sag/templates/create.html | 32 +- app/modules/sag/templates/detail.html | 240 +- app/modules/sag/templates/detail_v3.html | 876 +++++- app/services/email_processor_service.py | 26 +- app/services/email_service.py | 326 ++- app/services/email_workflow_service.py | 59 +- app/services/vtiger_service.py | 512 +++- app/settings/frontend/settings.html | 2 +- app/shared/frontend/base.html | 55 +- app/timetracking/backend/models.py | 66 + app/timetracking/backend/router.py | 494 +++- app/timetracking/frontend/employee_log.html | 320 +++ .../frontend/service_contract_report.html | 431 +++ app/timetracking/frontend/views.py | 12 + docker-compose.yml | 5 + docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md | 125 + main.py | 4 + migrations/188_sag_buzzwords.sql | 41 + migrations/189_mission_projects.sql | 93 + requirements.txt | 2 + static/js/bottom-bar.js | 37 +- static/js/bug-report.js | 19 +- 37 files changed, 10961 insertions(+), 208 deletions(-) create mode 100644 app/dashboard/frontend/mission_control_legacy.html create mode 100644 app/dashboard/frontend/mission_control_v2.html create mode 100644 app/emails/frontend/emails_v2.html create mode 100644 app/timetracking/frontend/employee_log.html create mode 100644 app/timetracking/frontend/service_contract_report.html create mode 100644 docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md create mode 100644 migrations/188_sag_buzzwords.sql create mode 100644 migrations/189_mission_projects.sql diff --git a/.env.example b/.env.example index 2134eab..2d7b0c2 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,7 @@ IMAP_USERNAME=your_email@gmail.com IMAP_PASSWORD=your_app_password IMAP_USE_SSL=true IMAP_FOLDER=INBOX +IMAP_TEST_FOLDER=BMC_TEST # Shared test inbox for all mail scenarios IMAP_READ_ONLY=true # Safety: READ-ONLY mode # Microsoft Graph API (Alternative to IMAP - for Office365/Outlook) @@ -152,7 +153,9 @@ EMAIL_AI_CONFIDENCE_THRESHOLD=0.7 EMAIL_REQUIRE_MANUAL_APPROVAL=true EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false EMAIL_MAX_FETCH_PER_RUN=50 +EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE=true EMAIL_PROCESS_INTERVAL_MINUTES=5 EMAIL_WORKFLOWS_ENABLED=true +EMAIL_WORKFLOW_AUTORUN_ENABLED=false EMAIL_MAX_UPLOAD_SIZE_MB=50 ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip \ No newline at end of file diff --git a/app/auth/backend/views.py b/app/auth/backend/views.py index baf580b..0a3af3c 100644 --- a/app/auth/backend/views.py +++ b/app/auth/backend/views.py @@ -16,7 +16,11 @@ async def login_page(request: Request): """ return templates.TemplateResponse( "auth/frontend/login.html", - {"request": request} + { + "request": request, + "hide_top_nav": True, + "hide_bottom_bar": True, + } ) @@ -27,5 +31,9 @@ async def two_factor_setup_page(request: Request): """ return templates.TemplateResponse( "auth/frontend/2fa_setup.html", - {"request": request} + { + "request": request, + "hide_top_nav": True, + "hide_bottom_bar": True, + } ) diff --git a/app/core/config.py b/app/core/config.py index b42e113..9752e4c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -106,6 +106,7 @@ class Settings(BaseSettings): IMAP_PASSWORD: str = "" IMAP_USE_SSL: bool = True IMAP_FOLDER: str = "INBOX" + IMAP_TEST_FOLDER: str = "" IMAP_READ_ONLY: bool = True # Microsoft Graph API (alternative to IMAP) @@ -125,8 +126,10 @@ class Settings(BaseSettings): EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False EMAIL_MAX_FETCH_PER_RUN: int = 50 + EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE: bool = True EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 EMAIL_WORKFLOWS_ENABLED: bool = True + EMAIL_WORKFLOW_AUTORUN_ENABLED: bool = False EMAIL_MAX_UPLOAD_SIZE_MB: int = 50 # Max file size for email uploads ALLOWED_EXTENSIONS: List[str] = ["pdf", "jpg", "jpeg", "png", "gif", "doc", "docx", "xls", "xlsx", "zip"] # Allowed file extensions for uploads diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py index 81e9c1e..f4b9248 100644 --- a/app/dashboard/backend/mission_router.py +++ b/app/dashboard/backend/mission_router.py @@ -65,6 +65,62 @@ class MissionTemperatureWebhook(BaseModel): payload: Dict[str, Any] = Field(default_factory=dict) +class MissionProjectCreatePayload(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + status: Optional[str] = "planned" + score: Optional[int] = 0 + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + payload: Dict[str, Any] = Field(default_factory=dict) + + +class MissionProjectUpdatePayload(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + score: Optional[int] = None + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + + +class MissionProjectMilestonePayload(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + status: Optional[str] = "active" + target_date: Optional[datetime] = None + + +class MissionProjectMilestoneUpdatePayload(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + target_date: Optional[datetime] = None + + +class MissionProjectBlockerPayload(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + status: Optional[str] = "open" + severity: Optional[str] = "medium" + resolved_at: Optional[datetime] = None + + +class MissionProjectBlockerUpdatePayload(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + severity: Optional[str] = None + resolved_at: Optional[datetime] = None + + +class MissionProjectLinkCasePayload(BaseModel): + sag_id: int + project_milestone_id: Optional[int] = None + is_project_blocker: Optional[bool] = False + project_task_type: Optional[str] = None + + def _first_query_param(request: Request, *names: str) -> Optional[str]: for name in names: value = request.query_params.get(name) @@ -295,6 +351,138 @@ async def get_mission_state(): return MissionService.get_state() +@router.get("/mission/projects") +async def get_mission_projects(limit: int = Query(120, ge=1, le=500)): + return { + "projects": MissionService.get_projects(limit=limit), + "summary": MissionService.get_projects_state_payload(limit=limit).get("summary", {}), + } + + +@router.get("/mission/projects/workload") +async def get_mission_projects_workload(limit: int = Query(120, ge=1, le=500)): + return {"workload": MissionService.get_project_workload(limit=limit)} + + +@router.get("/mission/projects/{project_id}") +async def get_mission_project_detail(project_id: int): + project = MissionService.get_project_detail(project_id) + if not project: + raise HTTPException(status_code=404, detail="Projekt ikke fundet") + return project + + +@router.post("/mission/projects") +async def create_mission_project(request: Request, payload: MissionProjectCreatePayload): + user_payload = _require_authenticated_user(request) + actor_user_id = user_payload.get("sub") or user_payload.get("user_id") + try: + actor_user_id = int(actor_user_id) if actor_user_id is not None else None + except (TypeError, ValueError): + actor_user_id = None + + project = MissionService.create_project(payload.model_dump(mode="json"), actor_user_id=actor_user_id) + if not project: + raise HTTPException(status_code=400, detail="Kunne ikke oprette projekt") + + await mission_ws_manager.broadcast("project_created", project) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return project + + +@router.patch("/mission/projects/{project_id}") +async def update_mission_project(project_id: int, request: Request, payload: MissionProjectUpdatePayload): + _require_authenticated_user(request) + project = MissionService.update_project(project_id, payload.model_dump(mode="json", exclude_none=True)) + if not project: + raise HTTPException(status_code=404, detail="Projekt ikke fundet") + + event_name = "project_status_changed" if "status" in payload.model_dump(exclude_none=True) else "project_updated" + await mission_ws_manager.broadcast(event_name, project) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return project + + +@router.post("/mission/projects/{project_id}/milestones") +async def create_mission_project_milestone(project_id: int, request: Request, payload: MissionProjectMilestonePayload): + _require_authenticated_user(request) + milestone = MissionService.add_project_milestone(project_id, payload.model_dump(mode="json")) + if not milestone: + raise HTTPException(status_code=400, detail="Kunne ikke oprette milepael") + + await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone}) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return milestone + + +@router.patch("/mission/projects/{project_id}/milestones/{milestone_id}") +async def update_mission_project_milestone( + project_id: int, + milestone_id: int, + request: Request, + payload: MissionProjectMilestoneUpdatePayload, +): + _require_authenticated_user(request) + milestone = MissionService.update_project_milestone( + project_id, + milestone_id, + payload.model_dump(mode="json", exclude_none=True), + ) + if not milestone: + raise HTTPException(status_code=404, detail="Milepael ikke fundet") + + await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone}) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return milestone + + +@router.post("/mission/projects/{project_id}/blockers") +async def create_mission_project_blocker(project_id: int, request: Request, payload: MissionProjectBlockerPayload): + _require_authenticated_user(request) + blocker = MissionService.add_project_blocker(project_id, payload.model_dump(mode="json")) + if not blocker: + raise HTTPException(status_code=400, detail="Kunne ikke oprette blocker") + + await mission_ws_manager.broadcast("project_blocked", {"project_id": project_id, "blocker": blocker}) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return blocker + + +@router.patch("/mission/projects/{project_id}/blockers/{blocker_id}") +async def update_mission_project_blocker( + project_id: int, + blocker_id: int, + request: Request, + payload: MissionProjectBlockerUpdatePayload, +): + _require_authenticated_user(request) + blocker = MissionService.update_project_blocker( + project_id, + blocker_id, + payload.model_dump(mode="json", exclude_none=True), + ) + if not blocker: + raise HTTPException(status_code=404, detail="Blocker ikke fundet") + + blocker_status = str(blocker.get("status") or "").lower() + event_name = "project_unblocked" if blocker_status in {"resolved", "cancelled"} else "project_blocked" + await mission_ws_manager.broadcast(event_name, {"project_id": project_id, "blocker": blocker}) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return blocker + + +@router.post("/mission/projects/{project_id}/link-case") +async def link_case_to_mission_project(project_id: int, request: Request, payload: MissionProjectLinkCasePayload): + _require_authenticated_user(request) + linked_case = MissionService.link_case_to_project(project_id, payload.model_dump(mode="json")) + if not linked_case: + raise HTTPException(status_code=404, detail="Sag eller projekt ikke fundet") + + await mission_ws_manager.broadcast("project_task_assigned", {"project_id": project_id, "case": linked_case}) + await mission_ws_manager.broadcast("mission_state", MissionService.get_state()) + return linked_case + + @router.get("/mission/camera/mjpeg") async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)): feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip() diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py index 0b016cd..d1fe874 100644 --- a/app/dashboard/backend/mission_service.py +++ b/app/dashboard/backend/mission_service.py @@ -326,6 +326,694 @@ class MissionService: result.append(item) return result + @staticmethod + def _project_risk_level_from_score(score: int) -> str: + if score <= 29: + return "low" + if score <= 69: + return "medium" + if score <= 119: + return "high" + return "critical" + + @staticmethod + def _compute_project_risk(project_row: Dict[str, Any]) -> Dict[str, Any]: + score = int(project_row.get("score") or 0) + factors: list[str] = [] + + overdue_tasks = int(project_row.get("overdue_tasks") or 0) + open_blockers = int(project_row.get("open_blockers") or 0) + overdue_milestones = int(project_row.get("overdue_milestones") or 0) + + if overdue_tasks > 0: + score += min(overdue_tasks * 8, 40) + factors.append("overdue_tasks") + if overdue_milestones > 0: + score += min(overdue_milestones * 10, 40) + factors.append("overdue_milestones") + if open_blockers > 0: + score += min(open_blockers * 15, 60) + factors.append("open_blockers") + + return { + "risk_score": score, + "risk_level": MissionService._project_risk_level_from_score(score), + "risk_factors": factors, + } + + @staticmethod + def get_project_workload(limit: int = 100) -> list[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects"): + return [] + + rows = execute_query( + """ + SELECT + p.id, + p.name, + COUNT(*) FILTER ( + WHERE s.id IS NOT NULL + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS open_tasks, + COUNT(*) FILTER ( + WHERE s.id IS NOT NULL + AND s.deadline IS NOT NULL + AND s.deadline::date < CURRENT_DATE + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS overdue_tasks, + COUNT(*) FILTER ( + WHERE s.id IS NOT NULL + AND s.ansvarlig_bruger_id IS NULL + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS unassigned_tasks + FROM mission_projects p + LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL + GROUP BY p.id, p.name + ORDER BY overdue_tasks DESC, unassigned_tasks DESC, open_tasks DESC, p.id DESC + LIMIT %s + """, + (limit,), + ) or [] + + return [dict(row) for row in rows] + + @staticmethod + def _get_projects_from_cases(limit: int = 120) -> list[Dict[str, Any]]: + if not MissionService._table_exists("sag_sager"): + return [] + + case_rows = execute_query( + """ + SELECT + s.id, + s.titel AS name, + s.beskrivelse AS description, + LOWER(COALESCE(s.status, 'active')) AS status, + 0 AS score, + s.start_date AS started_at, + s.deadline AS ended_at, + s.created_at AS updated_at, + 0 AS active_milestones, + 0 AS overdue_milestones, + 0 AS open_blockers, + 1 AS open_tasks, + CASE WHEN s.deadline IS NOT NULL AND s.deadline::date = CURRENT_DATE THEN 1 ELSE 0 END AS due_today_tasks, + CASE + WHEN s.deadline IS NOT NULL + AND s.deadline::date < CURRENT_DATE + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + THEN 1 + ELSE 0 + END AS overdue_tasks + FROM sag_sager s + WHERE s.deleted_at IS NULL + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + AND ( + LOWER(COALESCE(s.template_key, '')) IN ('projekt', 'project') + OR LOWER(COALESCE(s.project_task_type, '')) = 'project' + OR s.project_id IS NOT NULL + ) + ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC + LIMIT %s + """, + (limit,), + ) or [] + + fallback: list[Dict[str, Any]] = [] + for row in case_rows: + item = dict(row) + item.update(MissionService._compute_project_risk(item)) + fallback.append(item) + return fallback + + @staticmethod + def get_projects(limit: int = 120) -> list[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects"): + return MissionService._get_projects_from_cases(limit) + + rows = execute_query( + """ + SELECT + p.id, + p.name, + p.description, + p.status, + p.score, + p.started_at, + p.ended_at, + p.updated_at, + COUNT(DISTINCT m.id) FILTER ( + WHERE m.status NOT IN ('completed', 'cancelled') + ) AS active_milestones, + COUNT(DISTINCT m.id) FILTER ( + WHERE m.target_date IS NOT NULL + AND m.target_date < CURRENT_DATE + AND m.status NOT IN ('completed', 'cancelled') + ) AS overdue_milestones, + COUNT(DISTINCT b.id) FILTER ( + WHERE b.status NOT IN ('resolved', 'cancelled') + ) AS open_blockers, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.id IS NOT NULL + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS open_tasks, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.id IS NOT NULL + AND s.deadline IS NOT NULL + AND s.deadline::date = CURRENT_DATE + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS due_today_tasks, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.id IS NOT NULL + AND s.deadline IS NOT NULL + AND s.deadline::date < CURRENT_DATE + AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') + ) AS overdue_tasks + FROM mission_projects p + LEFT JOIN mission_project_milestones m ON m.project_id = p.id + LEFT JOIN mission_project_blockers b ON b.project_id = p.id + LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL + GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at + ORDER BY p.updated_at DESC, p.id DESC + LIMIT %s + """, + (limit,), + ) or [] + + result: list[Dict[str, Any]] = [] + for row in rows: + item = dict(row) + item.update(MissionService._compute_project_risk(item)) + result.append(item) + + # Important fallback: migration may have created mission_projects but with zero rows. + if not result: + return MissionService._get_projects_from_cases(limit) + return result + + @staticmethod + def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects"): + return None + + rows = MissionService.get_projects(limit=200) + project = next((item for item in rows if int(item.get("id") or 0) == int(project_id)), None) + if not project: + return None + + milestones = execute_query( + """ + SELECT id, project_id, title, description, status, target_date, created_at, updated_at + FROM mission_project_milestones + WHERE project_id = %s + ORDER BY target_date ASC NULLS LAST, id DESC + """, + (project_id,), + ) or [] + + blockers = execute_query( + """ + SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at + FROM mission_project_blockers + WHERE project_id = %s + ORDER BY + CASE severity + WHEN 'critical' THEN 4 + WHEN 'high' THEN 3 + WHEN 'medium' THEN 2 + WHEN 'low' THEN 1 + ELSE 0 + END DESC, + id DESC + """, + (project_id,), + ) or [] + + tasks = execute_query( + """ + SELECT + s.id, + s.titel, + s.status, + s.priority, + s.deadline, + s.ansvarlig_bruger_id, + s.project_milestone_id, + s.is_project_blocker, + s.project_task_type, + s.created_at, + COALESCE(ts.open_todo_count, 0) AS open_todo_count, + COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles + FROM sag_sager s + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER ( + WHERE t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ) AS open_todo_count, + ARRAY_REMOVE( + ARRAY_AGG( + CASE + WHEN t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + THEN t.title + END + ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC + ), + NULL + ) AS open_todo_titles + FROM sag_todo_steps t + WHERE t.sag_id = s.id + ) ts ON TRUE + WHERE s.deleted_at IS NULL + AND s.project_id = %s + ORDER BY + s.deadline ASC NULLS LAST, + s.created_at DESC + LIMIT 200 + """, + (project_id,), + ) or [] + + # Fallback for case-backed projects: fetch directly related/under cases from relation table. + # This is used when a project is a case of type project/projekt and tasks are linked as case relations. + if not tasks and MissionService._table_exists("sag_relationer"): + tasks = execute_query( + """ + WITH related AS ( + SELECT + sr.målsag_id AS task_id, + sr.relationstype AS relation_type + FROM sag_relationer sr + WHERE sr.deleted_at IS NULL + AND sr.kilde_sag_id = %s + + UNION ALL + + SELECT + sr.kilde_sag_id AS task_id, + sr.relationstype AS relation_type + FROM sag_relationer sr + WHERE sr.deleted_at IS NULL + AND sr.målsag_id = %s + ) + SELECT + s.id, + s.titel, + s.status, + s.priority, + s.deadline, + s.ansvarlig_bruger_id, + s.project_milestone_id, + s.is_project_blocker, + COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type, + s.created_at, + COALESCE(ts.open_todo_count, 0) AS open_todo_count, + COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles + FROM related r + JOIN sag_sager s ON s.id = r.task_id + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER ( + WHERE t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + ) AS open_todo_count, + ARRAY_REMOVE( + ARRAY_AGG( + CASE + WHEN t.deleted_at IS NULL + AND COALESCE(t.is_done, FALSE) = FALSE + THEN t.title + END + ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC + ), + NULL + ) AS open_todo_titles + FROM sag_todo_steps t + WHERE t.sag_id = s.id + ) ts ON TRUE + WHERE s.deleted_at IS NULL + AND s.id <> %s + ORDER BY + s.deadline ASC NULLS LAST, + s.created_at DESC + LIMIT 200 + """, + (project_id, project_id, project_id), + ) or [] + + return { + **project, + "milestones": [dict(row) for row in milestones], + "blockers": [dict(row) for row in blockers], + "tasks": [dict(row) for row in tasks], + } + + @staticmethod + def get_projects_state_payload(limit: int = 120) -> Dict[str, Any]: + projects = MissionService.get_projects(limit) + workload = MissionService.get_project_workload(limit) + + details: Dict[str, Any] = {} + for project in projects: + project_id = int(project.get("id") or 0) + if project_id <= 0: + continue + detail = MissionService.get_project_detail(project_id) + if detail: + details[str(project_id)] = detail + + total = len(projects) + active = len([p for p in projects if str(p.get("status") or "").lower() == "active"]) + high_risk = len([p for p in projects if p.get("risk_level") in {"high", "critical"}]) + blocked = len([p for p in projects if int(p.get("open_blockers") or 0) > 0]) + due_today = sum(int(p.get("due_today_tasks") or 0) for p in projects) + + return { + "summary": { + "total": total, + "active": active, + "high_risk": high_risk, + "blocked": blocked, + "due_today": due_today, + }, + "projects": projects, + "workload": workload, + "details": details, + } + + @staticmethod + def create_project(payload: Dict[str, Any], actor_user_id: Optional[int] = None) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects"): + return None + + name = str(payload.get("name") or "").strip() + if not name: + return None + + description = str(payload.get("description") or "").strip() or None + status = str(payload.get("status") or "planned").strip().lower() + if status not in {"planned", "active", "paused", "completed", "cancelled"}: + status = "planned" + + try: + score = int(payload.get("score") or 0) + except (TypeError, ValueError): + score = 0 + + started_at = payload.get("started_at") + ended_at = payload.get("ended_at") + + rows = execute_query( + """ + INSERT INTO mission_projects ( + name, + description, + status, + score, + started_at, + ended_at, + created_by, + payload, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW()) + RETURNING id + """, + ( + name, + description, + status, + score, + started_at, + ended_at, + actor_user_id, + json.dumps(payload.get("payload") or {}), + ), + ) or [] + + if not rows: + return None + return MissionService.get_project_detail(int(rows[0].get("id"))) + + @staticmethod + def update_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects"): + return None + + updates: list[str] = [] + values: list[Any] = [] + + if "name" in payload: + name = str(payload.get("name") or "").strip() + if name: + updates.append("name = %s") + values.append(name) + + if "description" in payload: + updates.append("description = %s") + values.append(str(payload.get("description") or "").strip() or None) + + if "status" in payload: + status = str(payload.get("status") or "").strip().lower() + if status in {"planned", "active", "paused", "completed", "cancelled"}: + updates.append("status = %s") + values.append(status) + + if "score" in payload: + try: + updates.append("score = %s") + values.append(int(payload.get("score") or 0)) + except (TypeError, ValueError): + pass + + if "started_at" in payload: + updates.append("started_at = %s") + values.append(payload.get("started_at")) + + if "ended_at" in payload: + updates.append("ended_at = %s") + values.append(payload.get("ended_at")) + + if not updates: + return MissionService.get_project_detail(project_id) + + updates.append("updated_at = NOW()") + values.append(project_id) + + execute_query( + f""" + UPDATE mission_projects + SET {', '.join(updates)} + WHERE id = %s + """, + tuple(values), + ) + + return MissionService.get_project_detail(project_id) + + @staticmethod + def add_project_milestone(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_project_milestones"): + return None + + title = str(payload.get("title") or "").strip() + if not title: + return None + + description = str(payload.get("description") or "").strip() or None + status = str(payload.get("status") or "active").strip().lower() + if status not in {"active", "completed", "cancelled"}: + status = "active" + target_date = payload.get("target_date") + + rows = execute_query( + """ + INSERT INTO mission_project_milestones (project_id, title, description, status, target_date, updated_at) + VALUES (%s, %s, %s, %s, %s, NOW()) + RETURNING id, project_id, title, description, status, target_date, created_at, updated_at + """, + (project_id, title, description, status, target_date), + ) or [] + + return dict(rows[0]) if rows else None + + @staticmethod + def update_project_milestone(project_id: int, milestone_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_project_milestones"): + return None + + updates: list[str] = [] + values: list[Any] = [] + + if "title" in payload: + title = str(payload.get("title") or "").strip() + if title: + updates.append("title = %s") + values.append(title) + + if "description" in payload: + updates.append("description = %s") + values.append(str(payload.get("description") or "").strip() or None) + + if "status" in payload: + status = str(payload.get("status") or "").strip().lower() + if status in {"active", "completed", "cancelled"}: + updates.append("status = %s") + values.append(status) + + if "target_date" in payload: + updates.append("target_date = %s") + values.append(payload.get("target_date")) + + if not updates: + return execute_query_single( + """ + SELECT id, project_id, title, description, status, target_date, created_at, updated_at + FROM mission_project_milestones + WHERE id = %s AND project_id = %s + """, + (milestone_id, project_id), + ) + + updates.append("updated_at = NOW()") + values.extend([milestone_id, project_id]) + rows = execute_query( + f""" + UPDATE mission_project_milestones + SET {', '.join(updates)} + WHERE id = %s AND project_id = %s + RETURNING id, project_id, title, description, status, target_date, created_at, updated_at + """, + tuple(values), + ) or [] + + return dict(rows[0]) if rows else None + + @staticmethod + def add_project_blocker(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_project_blockers"): + return None + + title = str(payload.get("title") or "").strip() + if not title: + return None + + description = str(payload.get("description") or "").strip() or None + status = str(payload.get("status") or "open").strip().lower() + if status not in {"open", "in_progress", "resolved", "cancelled"}: + status = "open" + + severity = str(payload.get("severity") or "medium").strip().lower() + if severity not in {"low", "medium", "high", "critical"}: + severity = "medium" + + resolved_at = payload.get("resolved_at") + rows = execute_query( + """ + INSERT INTO mission_project_blockers (project_id, title, description, status, severity, resolved_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at + """, + (project_id, title, description, status, severity, resolved_at), + ) or [] + + return dict(rows[0]) if rows else None + + @staticmethod + def update_project_blocker(project_id: int, blocker_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_project_blockers"): + return None + + updates: list[str] = [] + values: list[Any] = [] + + if "title" in payload: + title = str(payload.get("title") or "").strip() + if title: + updates.append("title = %s") + values.append(title) + + if "description" in payload: + updates.append("description = %s") + values.append(str(payload.get("description") or "").strip() or None) + + if "status" in payload: + status = str(payload.get("status") or "").strip().lower() + if status in {"open", "in_progress", "resolved", "cancelled"}: + updates.append("status = %s") + values.append(status) + if status == "resolved" and "resolved_at" not in payload: + updates.append("resolved_at = NOW()") + + if "severity" in payload: + severity = str(payload.get("severity") or "").strip().lower() + if severity in {"low", "medium", "high", "critical"}: + updates.append("severity = %s") + values.append(severity) + + if "resolved_at" in payload: + updates.append("resolved_at = %s") + values.append(payload.get("resolved_at")) + + if not updates: + return execute_query_single( + """ + SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at + FROM mission_project_blockers + WHERE id = %s AND project_id = %s + """, + (blocker_id, project_id), + ) + + updates.append("updated_at = NOW()") + values.extend([blocker_id, project_id]) + rows = execute_query( + f""" + UPDATE mission_project_blockers + SET {', '.join(updates)} + WHERE id = %s AND project_id = %s + RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at + """, + tuple(values), + ) or [] + + return dict(rows[0]) if rows else None + + @staticmethod + def link_case_to_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not MissionService._table_exists("mission_projects") or not MissionService._table_exists("sag_sager"): + return None + + sag_id = payload.get("sag_id") + if sag_id is None: + return None + + project_exists = execute_query_single("SELECT id FROM mission_projects WHERE id = %s", (project_id,)) + if not project_exists: + return None + + milestone_id = payload.get("project_milestone_id") + is_project_blocker = bool(payload.get("is_project_blocker") or False) + project_task_type = str(payload.get("project_task_type") or "").strip() or None + + rows = execute_query( + """ + UPDATE sag_sager + SET + project_id = %s, + project_milestone_id = %s, + is_project_blocker = %s, + project_task_type = %s + WHERE id = %s + AND deleted_at IS NULL + RETURNING id, titel, status, project_id, project_milestone_id, is_project_blocker, project_task_type + """, + (project_id, milestone_id, is_project_blocker, project_task_type, sag_id), + ) or [] + + return dict(rows[0]) if rows else None + @staticmethod def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]: rows = execute_query( @@ -495,7 +1183,16 @@ class MissionService: created_at ASC ) AS case_list FROM active_cases - GROUP BY assignee_key, assignee_name + GROUP BY + COALESCE( + CASE + WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text) + WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text) + ELSE 'unassigned' + END, + 'unassigned' + ), + COALESCE(assignee_name, group_name, 'Ufordelt') ) SELECT assignee_key, @@ -603,6 +1300,7 @@ class MissionService: "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []), + "projects": MissionService._safe("projects", lambda: MissionService.get_projects_state_payload(120), {"summary": {}, "projects": [], "workload": []}), "day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []), "day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []), "assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []), diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py index e804a5d..67ed47e 100644 --- a/app/dashboard/backend/router.py +++ b/app/dashboard/backend/router.py @@ -198,11 +198,35 @@ async def search_sag(q: str): CAST(s.id AS TEXT) ILIKE %s OR s.titel ILIKE %s OR s.beskrivelse ILIKE %s OR - c.name ILIKE %s + c.name ILIKE %s OR + EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND t.name ILIKE %s + ) OR + EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND st.tag_navn ILIKE %s + ) OR + EXISTS ( + SELECT 1 + FROM sag_buzzwords sb + JOIN buzzwords b ON b.id = sb.buzzword_id + WHERE sb.sag_id = s.id + AND sb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.word ILIKE %s + ) ) ORDER BY s.created_at DESC LIMIT 20 - """, (search_term, search_term, search_term, search_term)) + """, (search_term, search_term, search_term, search_term, search_term, search_term, search_term)) return sager or [] except Exception as e: diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py index 7924dd6..7e7365e 100644 --- a/app/dashboard/backend/views.py +++ b/app/dashboard/backend/views.py @@ -108,7 +108,14 @@ def _sanitize_mission_next(value: str) -> str: if not value: return "/dashboard/mission-control" candidate = value.strip() - if candidate == "/dashboard/mission-control": + if candidate in { + "/dashboard/mission-control", + "/dashboard/mission-control/", + "/dashboard/mission-control/projects", + "/dashboard/mission-control/projects/", + "/dashboard/mission-control.old", + "/dashboard/mission-control.old/", + }: return candidate if candidate.startswith("/api/v1/mission/"): return candidate @@ -479,9 +486,12 @@ async def clear_default_dashboard_get_fallback(): @router.get("/dashboard/mission-control", response_class=HTMLResponse) async def mission_control_dashboard(request: Request): return templates.TemplateResponse( - "dashboard/frontend/mission_control.html", + "dashboard/frontend/mission_control_v2.html", { "request": request, + "hide_top_nav": True, + "mission_control_version": "v2", + "mission_initial_view": "project", } ) @@ -490,3 +500,38 @@ async def mission_control_dashboard(request: Request): async def mission_control_dashboard_trailing_slash(request: Request): return await mission_control_dashboard(request) + +@router.get("/dashboard/mission-control/projects", response_class=HTMLResponse) +async def mission_control_projects_dashboard(request: Request): + return templates.TemplateResponse( + "dashboard/frontend/mission_control_v2.html", + { + "request": request, + "hide_top_nav": True, + "mission_control_version": "v2", + "mission_initial_view": "project", + "mission_project_only": True, + } + ) + + +@router.get("/dashboard/mission-control/projects/", response_class=HTMLResponse) +async def mission_control_projects_dashboard_trailing_slash(request: Request): + return await mission_control_projects_dashboard(request) + + +@router.get("/dashboard/mission-control.old", response_class=HTMLResponse) +async def mission_control_dashboard_legacy(request: Request): + return templates.TemplateResponse( + "dashboard/frontend/mission_control_legacy.html", + { + "request": request, + "mission_control_version": "v1", + } + ) + + +@router.get("/dashboard/mission-control.old/", response_class=HTMLResponse) +async def mission_control_dashboard_legacy_trailing_slash(request: Request): + return await mission_control_dashboard_legacy(request) + diff --git a/app/dashboard/frontend/mission_control_legacy.html b/app/dashboard/frontend/mission_control_legacy.html new file mode 100644 index 0000000..866745e --- /dev/null +++ b/app/dashboard/frontend/mission_control_legacy.html @@ -0,0 +1,1953 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Mission Control - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+
Mission Control Legacy
+
Forbinder...
+
+
Auto reset: 10s inaktivitet
+
+ +
+ + +
+ +
+ + + + + +
+
+ +
+
+
+
+

Driftsstatus

+
Ingen aktive driftsalarmer
+ +
+ +
+
Ingen aktive opkald
+
Mission overvager opkald og opdaterer live.
+
+ +
+
Temperatur sensorer
+
+
+
+ +
+
+
Kamera (startvisning)
+ Ikke aktiv +
+ +
Feed er ikke aktiveret endnu.
+
+
+
+ +
+
+

Vigtige sager

+
+
Sag
+
Type
+
Status
+
Deadline
+
+
+ + +
+
+ +
+
+

Dagen

+
Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.
+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+

Aktive opkald

+
+
+
+

Deadlines pr. medarbejder

+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+
+
+
+ +
+
+
+

Kamera spotlight

+ Ikke aktiv +
+
+ Spotlight varighed + + + +
+ +
Feed er ikke aktiveret endnu.
+
+
+
+ +
+ + +{% endblock %} diff --git a/app/dashboard/frontend/mission_control_v2.html b/app/dashboard/frontend/mission_control_v2.html new file mode 100644 index 0000000..8ec16a2 --- /dev/null +++ b/app/dashboard/frontend/mission_control_v2.html @@ -0,0 +1,2455 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Mission Control - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{% set project_only = mission_project_only | default(false) %} +
+
+
+
+
{% if project_only %}Projekt View{% else %}Mission Control{% endif %}
+
{% if project_only %}Projektoversigt opdateres live{% else %}Forbinder...{% endif %}
+
+
Auto reset: 10s inaktivitet
+
+ + {% if not project_only %} +
+ + + +
+ {% endif %} + +
+ {% if not project_only %} + + + + {% endif %} + + {% if not project_only %} + + + {% endif %} +
+
+ +
+
+
+
+

Driftsstatus

+
Ingen aktive driftsalarmer
+ +
+ +
+
Ingen aktive opkald
+
Mission overvager opkald og opdaterer live.
+
+ +
+
Temperatur sensorer
+
+
+
+ +
+
+
Kamera (startvisning)
+ Ikke aktiv +
+ +
Feed er ikke aktiveret endnu.
+
+
+
+ +
+
+

Vigtige sager

+
+
+
Sag
+
Type
+
Status
+
Deadline
+
+
+ + +
+
+ +
+
+

Projekt view

+
Viser projekter fra projects payload med risiko, status, workload og deadlines.
+
+
+
Projekt
+
Risiko
+
Status
+
Workload
+
Deadline
+
+
+
+ + +
+
+ +
+
+

Dagen

+
Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.
+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+

Aktive opkald

+
+
+
+

Deadlines pr. medarbejder

+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+
+
+
+ +
+
+
+

Kamera spotlight

+ Ikke aktiv +
+
+ Spotlight varighed + + + +
+ +
Feed er ikke aktiveret endnu.
+
+
+
+ +
+ + +{% endblock %} diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 1ff4cbf..d6aec6a 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -5,11 +5,15 @@ API endpoints for email viewing, classification, and rule management import logging from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any from pydantic import BaseModel from datetime import datetime, date import unicodedata +import html +import re +from html.parser import HTMLParser +from app.core.config import settings from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.services.email_processor_service import EmailProcessorService from app.services.email_workflow_service import email_workflow_service @@ -126,6 +130,86 @@ def _is_supplier_case_type(case_type: Optional[str]) -> bool: return "indk" in value or "leverand" in value or "supplier" in value +class _SafeDescriptionHtmlSanitizer(HTMLParser): + """Allow-list HTML sanitizer for case descriptions created from email content.""" + + _ALLOWED_TAGS = { + "b", "strong", "i", "em", "u", + "p", "br", "hr", + "ul", "ol", "li", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", + "a", + } + _VOID_TAGS = {"br", "hr"} + _ALLOWED_ATTRS = { + "a": {"href", "title", "target", "rel"}, + "th": {"colspan", "rowspan"}, + "td": {"colspan", "rowspan"}, + } + + def __init__(self): + super().__init__(convert_charrefs=True) + self._parts: List[str] = [] + + def handle_starttag(self, tag, attrs): + tag = (tag or "").lower() + if tag not in self._ALLOWED_TAGS: + return + + attr_allow = self._ALLOWED_ATTRS.get(tag, set()) + safe_attrs = [] + for key, value in attrs or []: + key_l = str(key or "").lower() + if key_l not in attr_allow: + continue + safe_value = str(value or "").strip() + if key_l == "href": + href_l = safe_value.lower() + if href_l.startswith(("javascript:", "data:", "vbscript:")): + continue + safe_attrs.append(f'{key_l}="{html.escape(safe_value, quote=True)}"') + + attrs_html = (" " + " ".join(safe_attrs)) if safe_attrs else "" + self._parts.append(f"<{tag}{attrs_html}>") + + def handle_endtag(self, tag): + tag = (tag or "").lower() + if tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS: + return + self._parts.append(f"") + + def handle_data(self, data): + self._parts.append(html.escape(data or "")) + + def handle_entityref(self, name): + self._parts.append(f"&{name};") + + def handle_charref(self, name): + self._parts.append(f"&#{name};") + + def get_html(self) -> str: + return "".join(self._parts).strip() + + +def _sanitize_case_description_html(value: Optional[str]) -> str: + raw = str(value or "").strip() + if not raw: + return "" + + # Fast path: plain text stays plain text. + if "<" not in raw and ">" not in raw: + return raw + + sanitizer = _SafeDescriptionHtmlSanitizer() + try: + sanitizer.feed(raw) + sanitizer.close() + return sanitizer.get_html() + except Exception: + # Fallback to escaped text if parsing fails. + return html.escape(raw) + + def _extract_domain_from_email(email: Optional[str]) -> str: sender = str(email or "").strip().lower() if "@" not in sender: @@ -435,6 +519,124 @@ class RewriteEmailTextResponse(BaseModel): rewritten_text: str model: Optional[str] = None endpoint: Optional[str] = None + + +def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: + """Evaluate which workflows would run for an email without executing them.""" + classification = str(email_data.get('classification') or '').strip().lower() + confidence = float(email_data.get('confidence_score') or 0.0) + sender = str(email_data.get('sender_email') or '') + subject = str(email_data.get('subject') or '') + + workflows = execute_query( + """ + SELECT id, name, description, classification_trigger, sender_pattern, subject_pattern, + confidence_threshold, workflow_steps, priority, enabled, stop_on_match + FROM email_workflows + WHERE enabled = true + ORDER BY priority ASC + """ + ) + + candidates = [] + matching = [] + + for wf in workflows: + trigger = str(wf.get('classification_trigger') or '').strip().lower() + min_conf = float(wf.get('confidence_threshold') or 0.0) + sender_pattern = wf.get('sender_pattern') + subject_pattern = wf.get('subject_pattern') + + reasons = [] + matches = True + + if trigger != classification: + matches = False + reasons.append(f"classification_mismatch ({trigger} != {classification or 'none'})") + + if min_conf > confidence: + matches = False + reasons.append(f"confidence_too_low ({confidence:.2f} < {min_conf:.2f})") + + sender_ok = True + if sender_pattern: + try: + sender_ok = bool(re.search(str(sender_pattern), sender, re.IGNORECASE)) + except re.error as e: + sender_ok = False + reasons.append(f"invalid_sender_pattern ({e})") + if not sender_ok and "invalid_sender_pattern" not in " ".join(reasons): + matches = False + reasons.append("sender_pattern_no_match") + + subject_ok = True + if subject_pattern: + try: + subject_ok = bool(re.search(str(subject_pattern), subject, re.IGNORECASE)) + except re.error as e: + subject_ok = False + reasons.append(f"invalid_subject_pattern ({e})") + if not subject_ok and "invalid_subject_pattern" not in " ".join(reasons): + matches = False + reasons.append("subject_pattern_no_match") + + steps = wf.get('workflow_steps') + steps_total = len(steps) if isinstance(steps, list) else 0 + row = { + 'id': wf.get('id'), + 'name': wf.get('name'), + 'classification_trigger': wf.get('classification_trigger'), + 'confidence_threshold': min_conf, + 'priority': wf.get('priority'), + 'stop_on_match': bool(wf.get('stop_on_match')), + 'sender_pattern': sender_pattern, + 'subject_pattern': subject_pattern, + 'steps_total': steps_total, + 'matches': bool(matches), + 'reasons': reasons, + } + candidates.append(row) + if matches: + matching.append(row) + + system_matches = [] + if classification == 'bankruptcy': + system_matches.append({ + 'code': 'system_bankruptcy_analysis', + 'name': 'System: Bankruptcy Analysis', + 'matches': True, + 'reason': 'classification == bankruptcy', + }) + + has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data) + hard_skip = {'newsletter', 'spam'} + should_try_helpdesk = ( + classification not in hard_skip + and ( + classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS + or has_hint + ) + ) + system_matches.append({ + 'code': 'system_helpdesk_routing', + 'name': 'System: Helpdesk SAG routing', + 'matches': bool(should_try_helpdesk), + 'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list', + }) + + return { + 'email': { + 'id': email_data.get('id'), + 'classification': classification, + 'confidence_score': confidence, + 'sender_email': sender, + 'subject': subject, + }, + 'system_matches': system_matches, + 'matching_workflows': matching, + 'workflow_candidates': candidates, + 'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)), + } context: Optional[str] = None @@ -787,6 +989,7 @@ async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, async def list_emails( status: Optional[str] = Query(None), classification: Optional[str] = Query(None), + folder: Optional[str] = Query(None), q: Optional[str] = Query(None), limit: int = Query(50, le=500), offset: int = Query(0, ge=0) @@ -804,6 +1007,10 @@ async def list_emails( where_clauses.append("em.classification = %s") params.append(classification) + if folder: + where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)") + params.append(folder) + if q: where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)") search_term = f"%{q}%" @@ -875,8 +1082,16 @@ async def get_email(email_id: int, request: Request): linked_case_id = email_data.get("linked_case_id") can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id) - if not bool(email_data.get("is_read")) and can_mark_read: - update_query = "UPDATE email_messages SET is_read = true WHERE id = %s" + folder_value = str(email_data.get("folder") or "").strip().lower() + status_value = str(email_data.get("status") or "").strip().lower() + is_outgoing = folder_value.startswith("sent") or status_value == "sent" + + if is_outgoing and not bool(email_data.get("is_read")): + update_query = "UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s" + execute_update(update_query, (email_id,)) + email_data["is_read"] = True + elif not bool(email_data.get("is_read")) and can_mark_read: + update_query = "UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s" execute_update(update_query, (email_id,)) email_data["is_read"] = True @@ -897,12 +1112,19 @@ async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate, """ try: row = execute_query_single( - "SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL", + "SELECT id, linked_case_id, is_read, folder, status FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,), ) if not row: raise HTTPException(status_code=404, detail="Email not found") + folder_value = str(row.get("folder") or "").strip().lower() + status_value = str(row.get("status") or "").strip().lower() + is_outgoing = folder_value.startswith("sent") or status_value == "sent" + + if is_outgoing and payload.is_read is False: + raise HTTPException(status_code=400, detail="Udgaaende emails kan ikke markeres som ulaest") + user_id = getattr(request.state, "user_id", None) if payload.is_read: can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id")) @@ -1126,7 +1348,8 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques _upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto") titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() - beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' + beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' + beskrivelse = _sanitize_case_description_html(beskrivelse_raw) template_key = requested_case_type[:50] priority = (payload.priority or 'normal').strip().lower() @@ -1942,17 +2165,33 @@ async def reprocess_email(email_id: int): @router.post("/emails/process") -async def process_emails(): - """Manually trigger email processing""" +async def process_emails( + limit: Optional[int] = Query(default=None, ge=1, le=500), + folder: Optional[str] = Query(default=None, description="Optional mailbox folder override for this run"), +): + """Manually trigger email processing. Supports one-off folder testing via query params.""" try: + clean_folder = str(folder or '').strip() or None + if clean_folder and not settings.EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE: + raise HTTPException(status_code=403, detail="Folder override is disabled by configuration") + processor = EmailProcessorService() - stats = await processor.process_inbox() + stats = await processor.process_inbox( + limit_override=limit, + folder_override=clean_folder, + force_run=True, + ) return { "success": True, "message": "Email processing completed", - "stats": stats + "stats": stats, + "folder": clean_folder or settings.IMAP_TEST_FOLDER or settings.IMAP_FOLDER, + "limit": limit or settings.EMAIL_MAX_FETCH_PER_RUN, } + + except HTTPException: + raise except Exception as e: logger.error(f"❌ Email processing failed: {e}") @@ -2616,6 +2855,7 @@ async def execute_workflows_for_email(email_id: int): # Get email data query = """ SELECT id, message_id, subject, sender_email, sender_name, body_text, + body_html, in_reply_to, email_references, thread_key, classification, confidence_score, status FROM email_messages WHERE id = %s AND deleted_at IS NULL @@ -2639,6 +2879,44 @@ async def execute_workflows_for_email(email_id: int): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/emails/{email_id}/workflow-preview") +async def preview_workflows_for_email(email_id: int): + """Preview which workflows would match an email without executing them.""" + try: + query = """ + SELECT id, message_id, subject, sender_email, sender_name, body_text, + body_html, in_reply_to, email_references, thread_key, + classification, confidence_score, status + FROM email_messages + WHERE id = %s AND deleted_at IS NULL + """ + email_result = execute_query(query, (email_id,)) + + if not email_result: + raise HTTPException(status_code=404, detail="Email not found") + + email_data = email_result[0] + return _compute_workflow_preview(email_data) + + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error previewing workflows: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/emails/{email_id}/auto-run-workflows") +async def auto_run_workflows_for_email(email_id: int): + """Feature-flagged auto-run endpoint for production-ready rollout.""" + if not bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)): + raise HTTPException( + status_code=403, + detail="Auto-run er ikke aktiveret endnu (EMAIL_WORKFLOW_AUTORUN_ENABLED=false)", + ) + + return await execute_workflows_for_email(email_id) + + @router.get("/workflow-executions", response_model=List[WorkflowExecution]) async def list_workflow_executions( workflow_id: Optional[int] = Query(None), diff --git a/app/emails/frontend/emails_v2.html b/app/emails/frontend/emails_v2.html new file mode 100644 index 0000000..2758ac3 --- /dev/null +++ b/app/emails/frontend/emails_v2.html @@ -0,0 +1,1287 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Email v2 - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Email v2

+
+ Gå til v1 + v2 +
+
+ + + +
+ +
+ +
Klar
+
+ +
+
+

Detalje

+
+ + +
+
+ +
Vælg en email for at se info
+ +
Vælg en email fra listen
+ +
Ingen email valgt
+
+ +
+
+

Handlinger

+ Sammenlign v1 +
+ +
Vælg en email for handlinger
+ +
Ingen email valgt
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/emails/frontend/views.py b/app/emails/frontend/views.py index 818ed09..ed90868 100644 --- a/app/emails/frontend/views.py +++ b/app/emails/frontend/views.py @@ -20,5 +20,23 @@ async def emails_page(request: Request): """Email management UI - 3-column modern email interface""" return templates.TemplateResponse( "emails/frontend/emails.html", - {"request": request} + {"request": request, "email_ui_version": "v1"} + ) + + +@router.get("/emails/v1", response_class=HTMLResponse) +async def emails_page_v1(request: Request): + """Email management UI v1 (legacy/stable).""" + return templates.TemplateResponse( + "emails/frontend/emails.html", + {"request": request, "email_ui_version": "v1"} + ) + + +@router.get("/emails/v2", response_class=HTMLResponse) +async def emails_page_v2(request: Request): + """Email management UI v2 (simplified workflow).""" + return templates.TemplateResponse( + "emails/frontend/emails_v2.html", + {"request": request, "email_ui_version": "v2"} ) diff --git a/app/modules/orders/templates/list.html b/app/modules/orders/templates/list.html index 24da15c..7fd3b2b 100644 --- a/app/modules/orders/templates/list.html +++ b/app/modules/orders/templates/list.html @@ -61,30 +61,91 @@ font-size: 0.85rem; font-weight: 600; } + .ordre-top-panel { + background: linear-gradient(145deg, rgba(var(--accent-rgb, 15, 76, 117), 0.08), rgba(var(--accent-rgb, 15, 76, 117), 0.02)); + border: 1px solid rgba(var(--accent-rgb, 15, 76, 117), 0.16); + border-radius: 14px; + padding: 1rem 1.1rem; + margin-bottom: 1rem; + } + .ordre-top-title { + margin: 0; + color: var(--text-primary); + font-weight: 700; + font-size: 1.35rem; + } + .ordre-top-subtitle { + margin: 0.2rem 0 0; + color: var(--text-secondary); + font-size: 0.94rem; + } + .ordre-top-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + align-items: center; + } + .ordre-filter-wrap { + min-width: 190px; + } + .ordre-btn { + height: 38px; + border-radius: 10px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + white-space: nowrap; + } + .table-icon-btn { + width: 31px; + height: 31px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + } + @media (max-width: 991.98px) { + .ordre-top-actions { + justify-content: flex-start; + } + .ordre-filter-wrap { + min-width: 100%; + } + } {% endblock %} {% block content %}
-
-
-

Ordre

-
Oversigt over alle ordre
-
-
- Opret ny ordre - - Valgte: 0 - - - +
+
+
+

Ordre

+

Samlet overblik, konsolidering og sync-status for alle ordrekladder

+
+
+
+ Opret ny ordre +
+ +
+ Valgte: 0 + + + +
+
@@ -227,21 +288,21 @@ - ${syncStatus === 'posted' ? ` - ` : ''}
- - diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 12240a6..1130065 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -324,6 +324,14 @@ class DirectPrintOverrideRequest(BaseModel): hardware_id: Optional[int] = None +class SagBuzzwordCreateRequest(BaseModel): + buzzword: str = Field(..., min_length=1, max_length=120) + + +class SagBuzzwordSelectionRequest(BaseModel): + selected_text: str = Field(..., min_length=1, max_length=2000) + + def _normalize_email_list(values: List[str], field_name: str) -> List[str]: cleaned: List[str] = [] for value in values or []: @@ -344,6 +352,101 @@ def _normalize_message_id_token(value: Optional[str]) -> Optional[str]: return normalized or None +def _assert_sag_exists(sag_id: int) -> None: + exists = execute_query_single( + "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (sag_id,), + ) + if not exists: + raise HTTPException(status_code=404, detail="Case not found") + + +def _normalize_buzzword_text(value: Optional[str]) -> str: + raw = str(value or "") + normalized = " ".join(raw.strip().lower().split()) + if not normalized: + raise HTTPException(status_code=400, detail="buzzword is required") + if len(normalized) > 120: + raise HTTPException(status_code=400, detail="buzzword is too long") + return normalized + + +def _ensure_buzzword(normalized_word: str) -> dict: + existing = execute_query_single( + "SELECT id, word FROM buzzwords WHERE word = %s AND deleted_at IS NULL", + (normalized_word,), + ) + if existing: + return existing + + revived = execute_query( + """ + UPDATE buzzwords + SET deleted_at = NULL, created_at = NOW() + WHERE id = ( + SELECT id FROM buzzwords + WHERE word = %s AND deleted_at IS NOT NULL + ORDER BY deleted_at DESC NULLS LAST, id DESC + LIMIT 1 + ) + RETURNING id, word + """, + (normalized_word,), + ) + if revived: + return revived[0] + + inserted = execute_query( + "INSERT INTO buzzwords (word) VALUES (%s) RETURNING id, word", + (normalized_word,), + ) + if not inserted: + raise HTTPException(status_code=500, detail="Failed to create buzzword") + return inserted[0] + + +def _ensure_sag_buzzword_link(sag_id: int, buzzword_id: int) -> tuple[dict, str]: + active = execute_query_single( + """ + SELECT id, sag_id, buzzword_id, created_at + FROM sag_buzzwords + WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL + """, + (sag_id, buzzword_id), + ) + if active: + return active, "existing" + + revived = execute_query( + """ + UPDATE sag_buzzwords + SET deleted_at = NULL, created_at = NOW() + WHERE id = ( + SELECT id FROM sag_buzzwords + WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NOT NULL + ORDER BY deleted_at DESC NULLS LAST, id DESC + LIMIT 1 + ) + RETURNING id, sag_id, buzzword_id, created_at + """, + (sag_id, buzzword_id), + ) + if revived: + return revived[0], "reactivated" + + created = execute_query( + """ + INSERT INTO sag_buzzwords (sag_id, buzzword_id) + VALUES (%s, %s) + RETURNING id, sag_id, buzzword_id, created_at + """, + (sag_id, buzzword_id), + ) + if not created: + raise HTTPException(status_code=500, detail="Failed to link buzzword") + return created[0], "created" + + def _derive_thread_key_for_outbound( payload_thread_key: Optional[str], in_reply_to_header: Optional[str], @@ -1709,6 +1812,124 @@ async def delete_tag(sag_id: int, tag_id: int): raise HTTPException(status_code=500, detail="Failed to delete tag") +# ============================================================================ +# BUZZWORDS - Case Buzzwords (Free Text) +# ============================================================================ + +@router.get("/sag/buzzwords/all") +async def get_all_buzzwords(limit: int = Query(200, ge=1, le=500)): + """Return active buzzwords for autocomplete.""" + try: + rows = execute_query( + """ + SELECT id, word, created_at + FROM buzzwords + WHERE deleted_at IS NULL + ORDER BY word ASC + LIMIT %s + """, + (limit,), + ) or [] + return rows + except Exception as e: + logger.error("❌ Error loading buzzwords: %s", e) + raise HTTPException(status_code=500, detail="Failed to load buzzwords") + + +@router.get("/sag/{sag_id}/buzzwords") +async def get_case_buzzwords(sag_id: int): + """Get buzzwords linked to a case.""" + try: + _assert_sag_exists(sag_id) + rows = execute_query( + """ + SELECT + sb.id AS relation_id, + sb.sag_id, + b.id AS buzzword_id, + b.word, + sb.created_at + FROM sag_buzzwords sb + JOIN buzzwords b ON b.id = sb.buzzword_id + WHERE sb.sag_id = %s + AND sb.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY b.word ASC + """, + (sag_id,), + ) or [] + return rows + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error loading case buzzwords: %s", e) + raise HTTPException(status_code=500, detail="Failed to load case buzzwords") + + +@router.post("/sag/{sag_id}/buzzwords") +async def add_case_buzzword(sag_id: int, payload: SagBuzzwordCreateRequest): + """Create or link a buzzword to a case using normalized free text.""" + try: + _assert_sag_exists(sag_id) + normalized_word = _normalize_buzzword_text(payload.buzzword) + buzzword = _ensure_buzzword(normalized_word) + relation, link_status = _ensure_sag_buzzword_link(sag_id, int(buzzword["id"])) + + return { + "status": link_status, + "relation_id": relation.get("id"), + "sag_id": sag_id, + "buzzword_id": buzzword.get("id"), + "word": buzzword.get("word"), + "created_at": relation.get("created_at"), + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error adding case buzzword: %s", e) + raise HTTPException(status_code=500, detail="Failed to add case buzzword") + + +@router.post("/sag/{sag_id}/buzzwords/from-selection") +async def add_case_buzzword_from_selection(sag_id: int, payload: SagBuzzwordSelectionRequest): + """Create a buzzword from selected text and link it to a case.""" + return await add_case_buzzword(sag_id, SagBuzzwordCreateRequest(buzzword=payload.selected_text)) + + +@router.delete("/sag/{sag_id}/buzzwords/{buzzword_id}") +async def delete_case_buzzword(sag_id: int, buzzword_id: int): + """Soft-delete buzzword relation from a case.""" + try: + _assert_sag_exists(sag_id) + relation = execute_query_single( + """ + SELECT id + FROM sag_buzzwords + WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL + """, + (sag_id, buzzword_id), + ) + if not relation: + raise HTTPException(status_code=404, detail="Buzzword relation not found") + + execute_query( + "UPDATE sag_buzzwords SET deleted_at = NOW() WHERE id = %s", + (relation["id"],), + ) + + return { + "status": "deleted", + "sag_id": sag_id, + "buzzword_id": buzzword_id, + "relation_id": relation["id"], + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting case buzzword: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete case buzzword") + + # ============================================================================ # CUSTOMERS - Case Customers (Many-to-Many) # ============================================================================ @@ -3105,6 +3326,8 @@ async def add_kommentar(sag_id: int, data: dict, request: Request): raise HTTPException(status_code=404, detail="Case not found") er_system_besked = bool(data.get("er_system_besked", False)) + er_intern = bool(data.get("er_intern", False)) + has_internal_flag = table_has_column("sag_kommentarer", "er_intern") if er_system_besked: forfatter = str(data.get("forfatter") or "System").strip() or "System" @@ -3133,12 +3356,20 @@ async def add_kommentar(sag_id: int, data: dict, request: Request): else: forfatter = "Bruger" - query = """ - INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) - VALUES (%s, %s, %s, %s) - RETURNING * - """ - result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked)) + if has_internal_flag: + query = """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern) + VALUES (%s, %s, %s, %s, %s) + RETURNING * + """ + result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked, er_intern)) + else: + query = """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + RETURNING * + """ + result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked)) if result: logger.info("✅ Comment added to case %s by %s", sag_id, forfatter) diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index 0eba19d..45ad0a6 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -491,6 +491,52 @@ async def sag_detaljer(request: Request, sag_id: int): if not tags: tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" tags = execute_query(tags_query_legacy, (sag_id,)) + + buzzwords = [] + try: + buzzwords = execute_query( + """ + SELECT + sb.id AS relation_id, + sb.sag_id, + b.id AS buzzword_id, + b.word, + sb.created_at + FROM sag_buzzwords sb + JOIN buzzwords b ON b.id = sb.buzzword_id + WHERE sb.sag_id = %s + AND sb.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY b.word ASC + """, + (sag_id,), + ) or [] + except Exception as e: + logger.warning("⚠️ Could not load buzzwords for case %s: %s", sag_id, e) + buzzwords = [] + + buzzwords = [] + try: + buzzwords = execute_query( + """ + SELECT + sb.id AS relation_id, + sb.sag_id, + b.id AS buzzword_id, + b.word, + sb.created_at + FROM sag_buzzwords sb + JOIN buzzwords b ON b.id = sb.buzzword_id + WHERE sb.sag_id = %s + AND sb.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY b.word ASC + """, + (sag_id,), + ) or [] + except Exception as e: + logger.warning("⚠️ Could not load buzzwords for case %s: %s", sag_id, e) + buzzwords = [] # Fetch relations relationer_query = """ @@ -759,6 +805,8 @@ async def sag_detaljer(request: Request, sag_id: int): "prepaid_cards": prepaid_cards, "fixed_price_agreements": fixed_price_agreements, "tags": tags, + "buzzwords": buzzwords, + "buzzwords": buzzwords, "relationer": relationer, "relation_tree": relation_tree, diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 659542f..0679b1c 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -455,6 +455,9 @@ const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout; const resultsDiv = document.getElementById(resultsId); + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + clearTimeout(timeoutVar); if (query.length < 2) { @@ -469,7 +472,10 @@ resultsDiv.innerHTML = '
Søger...
'; const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts'; - const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`); + const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`, { + headers: authHeaders, + credentials: 'include' + }); if (!response.ok) { const errorText = await response.text(); resultsDiv.innerHTML = `
Fejl ved søgning: ${errorText}
`; @@ -492,7 +498,9 @@ } else { resultsDiv.innerHTML = data.map(item => { const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`; - const meta = item.email || (type === 'customer' ? 'CVR: ' + (item.cvr_nummer || '-') : '-'); + const meta = type === 'customer' + ? (item.email || ('CVR: ' + (item.cvr_nummer || '-'))) + : `${item.user_company || 'Ingen firma'} · Tlf: ${item.mobile || item.phone || item.user_company_phone || '-'}`; // Handle escaping for JS function call const safeName = name.replace(/'/g, "\\'"); @@ -937,9 +945,15 @@ btn.innerHTML = 'Opretter...'; try { + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + const customerQuery = document.getElementById('customerSearch').value.trim(); if (!selectedCustomer && customerQuery.length >= 2) { - const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`); + const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`, { + headers: authHeaders, + credentials: 'include' + }); const matches = await res.json(); if (Array.isArray(matches) && matches.length > 0) { throw new Error('Firma findes allerede. Vælg det fra listen.'); @@ -949,7 +963,10 @@ const contactQuery = document.getElementById('contactSearch').value.trim(); if (Object.keys(selectedContacts).length === 0 && contactQuery.length >= 2) { - const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`); + const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`, { + headers: authHeaders, + credentials: 'include' + }); const matches = await res.json(); if (Array.isArray(matches) && matches.length > 0) { throw new Error('Kontakt findes allerede. Vælg den fra listen.'); @@ -992,6 +1009,13 @@ fetch(`/api/v1/sag/${result.id}/contacts`, { method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contact_id: parseInt(cid), + role: 'Kontakt', + is_primary: false + }) + }) ); await Promise.all(contactPromises); diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 6296e1e..63d5e01 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -1241,6 +1241,10 @@ display: none; } + .card[data-module="buzzwords"].module-empty-compact .card-body { + display: block; + } + .card[data-module].module-empty-compact .card-header, .card[data-module].module-empty-compact .module-header { margin-bottom: 0; @@ -4045,6 +4049,7 @@ let customerSearchMode = 'link'; const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; const initialCaseTagsSnapshot = {{ (tags or [])|tojson }}; + const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }}; async function markCaseAsRecentlyOpened() { try { @@ -4141,6 +4146,7 @@ 'contacts': 'Kontakter', 'customers': 'Kunder', 'tags': 'Tags', + 'buzzwords': 'Buzzwords', 'wiki': 'Wiki', 'todo-steps': 'Todo-opgaver', 'time': 'Tid', @@ -4214,6 +4220,7 @@ loadTodoSteps(); loadCaseTagsModule(); loadCaseTagSuggestions(); + loadCaseBuzzwordsModule(); // Keep suggestions fresh while user works on the case. setInterval(loadCaseTagSuggestions, 30000); @@ -4550,8 +4557,8 @@ } async function callMainContactFromModal() { - const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson if hovedkontakt else "''" }}; - const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson if hovedkontakt else "''" }}; + const number = {{ ((hovedkontakt.mobile or hovedkontakt.phone or '') if hovedkontakt else '')|tojson }}; + const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }}; if (!number) { alert('Ingen telefon eller mobil på hovedkontakt'); return; @@ -5481,21 +5488,24 @@ return; } - suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => { + suggestionsContainer.innerHTML = `
${suggestions.slice(0, 8).map((item) => { const tag = item.tag || {}; const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : ''; + const hint = matched ? `Match: ${escapeHtml(matched)}` : 'Tilfoej tag'; return ` -
-
- - ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')} - - ${matched ? `
Match: ${escapeHtml(matched)}
` : ''} -
- -
+ `; - }).join(''); + }).join('')}
`; } catch (error) { console.error('Error loading tag suggestions:', error); suggestionsContainer.innerHTML = '
Fejl ved forslag
'; @@ -5570,7 +5580,149 @@ await loadCaseTagSuggestions(); } + async function createCaseBuzzwordFromText(rawText) { + const text = String(rawText || '').trim(); + if (!text) { + throw new Error('Ingen tekst at oprette buzzword fra'); + } + + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ buzzword: text }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke oprette buzzword'); + } + + await loadCaseBuzzwordsModule(); + } + + async function loadCaseBuzzwordsModule() { + const moduleContainer = document.getElementById('case-buzzwords-module'); + if (!moduleContainer) return; + + try { + let items = []; + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { credentials: 'include' }); + if (response.ok) { + const payload = await response.json(); + items = Array.isArray(payload) ? payload : []; + } + + if ((!Array.isArray(items) || items.length === 0) && Array.isArray(initialCaseBuzzwordsSnapshot) && initialCaseBuzzwordsSnapshot.length > 0) { + items = initialCaseBuzzwordsSnapshot; + } + + if (!Array.isArray(items) || items.length === 0) { + moduleContainer.innerHTML = '
Ingen buzzwords paa sagen endnu
'; + setModuleContentState('buzzwords', false); + return; + } + + moduleContainer.innerHTML = items.map((item) => ` + + ${escapeHtml(item.word || '')} + + + `).join(''); + + setModuleContentState('buzzwords', true); + } catch (error) { + console.error('Error loading case buzzwords:', error); + moduleContainer.innerHTML = '
Fejl ved hentning af buzzwords
'; + setModuleContentState('buzzwords', true); + } + } + + async function createCaseBuzzwordFromInput() { + const input = document.getElementById('case-buzzword-input'); + if (!input) return; + + try { + await createCaseBuzzwordFromText(input.value); + input.value = ''; + if (typeof showNotification === 'function') { + showNotification('Buzzword tilfoejet', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } + } + } + + function getCurrentBuzzwordSelectionText() { + const liveSelection = String(window.getSelection ? window.getSelection().toString() : '').trim(); + if (liveSelection) { + return liveSelection; + } + + const descTextarea = document.getElementById('beskrivelse-textarea'); + if (descTextarea) { + const start = Number(descTextarea.selectionStart); + const end = Number(descTextarea.selectionEnd); + if (Number.isFinite(start) && Number.isFinite(end) && end > start) { + return String(descTextarea.value || '').slice(start, end).trim(); + } + } + + return ''; + } + + async function createCaseBuzzwordFromCurrentSelection() { + const selectedText = getCurrentBuzzwordSelectionText(); + if (!selectedText) { + if (typeof showNotification === 'function') { + showNotification('Marker tekst foerst, og tryk derefter Opret markering', 'warning'); + } + return; + } + + try { + await createCaseBuzzwordFromText(selectedText); + if (typeof showNotification === 'function') { + showNotification('Buzzword oprettet fra markering', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } + } + } + + async function removeCaseBuzzwordAndSync(buzzwordId) { + const numericBuzzwordId = Number(buzzwordId || 0); + if (!numericBuzzwordId) return; + + try { + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords/${numericBuzzwordId}`, { + method: 'DELETE', + credentials: 'include' + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke fjerne buzzword'); + } + + await loadCaseBuzzwordsModule(); + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke fjerne buzzword', 'error'); + } + } + } + window.syncCaseTagsUi = syncCaseTagsUi; + window.createCaseBuzzwordFromInput = createCaseBuzzwordFromInput; + window.createCaseBuzzwordFromCurrentSelection = createCaseBuzzwordFromCurrentSelection; + window.removeCaseBuzzwordAndSync = removeCaseBuzzwordAndSync; let todoUserId = null; @@ -6221,9 +6373,9 @@
Ingen tags paa sagen endnu
{% endif %}
-
-
Forslag (brand/type)
-
+
+
Forslag
+
Indlaeser forslag...
@@ -6263,6 +6415,46 @@
+
+
+
Buzzwords
+
+ + + +
+
+
+
+ {% if buzzwords and buzzwords|length > 0 %} + {% for item in buzzwords %} + + {{ item.word }} + + + {% endfor %} + {% else %} +
Ingen buzzwords paa sagen endnu
+ {% endif %} +
+
+
+
Kontakter
@@ -12143,9 +12335,9 @@ document.body.setAttribute('data-case-view', viewName); const viewDefaults = { - 'Pipeline': ['pipeline', 'relations', 'sales', 'time'], - 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'], - 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar'] + 'Pipeline': ['pipeline', 'relations', 'sales', 'time', 'buzzwords'], + 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags', 'buzzwords'], + 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'buzzwords', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar'] }; const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey) @@ -12158,12 +12350,14 @@ const standardModuleSet = new Set(standardModules); standardModuleSet.add('tags'); standardModuleSet.add('time'); + standardModuleSet.add('buzzwords'); document.querySelectorAll('[data-module]').forEach((el) => { const moduleName = el.getAttribute('data-module'); const hasContent = moduleHasContent(el); const isTimeModule = moduleName === 'time'; - const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule; + const isBuzzwordsModule = moduleName === 'buzzwords'; + const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'buzzwords' && !isTimeModule; const pref = modulePrefs[moduleName]; const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); @@ -12199,6 +12393,12 @@ return; } + if (isBuzzwordsModule) { + setVisibility(true); + el.classList.remove('module-empty-compact'); + return; + } + // HVIS specifik præference deaktiverer den - Skjul den! Uanset content. if (pref === false) { setVisibility(false); diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index a2bfcf9..e36a328 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -3886,6 +3886,9 @@ + @@ -3926,6 +3929,61 @@
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+
+
{% if comments %} {% for comment in comments %} @@ -3958,17 +4016,6 @@
Ingen kommentarer matcher de valgte filtre.
- -
-
-
- - -
-
-
@@ -4035,7 +4082,7 @@ await logCaseSystemComment(`💬 SMS sendt til ${to}: ${preview}`); } catch (err) { console.warn('Kunne ikke logge sendt SMS i kommentarer', err); - } + alert(error?.message || 'Kunne ikke gemme kommentar og handlinger. Prøv igen.'); }); } @@ -4660,6 +4707,7 @@ let customerSearchMode = 'link'; const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; const initialCaseTagsSnapshot = {{ (tags or [])|tojson }}; + const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }}; async function markCaseAsRecentlyOpened() { try { @@ -4756,6 +4804,7 @@ 'contacts': 'Kontakter', 'customers': 'Kunder', 'tags': 'Tags', + 'buzzwords': 'Buzzwords', 'wiki': 'Wiki', 'todo-steps': 'Todo-opgaver', 'time': 'Tid', @@ -4818,6 +4867,7 @@ loadTodoSteps(); loadCaseTagsModule(); loadCaseTagSuggestions(); + loadCaseBuzzwordsModule(); // Keep suggestions fresh while user works on the case. setInterval(loadCaseTagSuggestions, 30000); @@ -4837,6 +4887,16 @@ todoForm.addEventListener('submit', createTodoStep); } + const buzzwordInput = document.getElementById('case-buzzword-input'); + if (buzzwordInput) { + buzzwordInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + createCaseBuzzwordFromInput(); + } + }); + } + ['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => { const el = document.getElementById(id); if (el) { @@ -5169,8 +5229,8 @@ } async function callMainContactFromModal() { - const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson if hovedkontakt else "''" }}; - const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson if hovedkontakt else "''" }}; + const number = {{ ((hovedkontakt.mobile or hovedkontakt.phone or '') if hovedkontakt else '')|tojson }}; + const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }}; if (!number) { alert('Ingen telefon eller mobil på hovedkontakt'); return; @@ -6102,21 +6162,24 @@ return; } - suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => { + suggestionsContainer.innerHTML = `
${suggestions.slice(0, 8).map((item) => { const tag = item.tag || {}; const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : ''; + const hint = matched ? `Match: ${escapeHtml(matched)}` : 'Tilfoej tag'; return ` -
-
- - ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')} - - ${matched ? `
Match: ${escapeHtml(matched)}
` : ''} -
- -
+ `; - }).join(''); + }).join('')}
`; } catch (error) { console.error('Error loading tag suggestions:', error); suggestionsContainer.innerHTML = '
Fejl ved forslag
'; @@ -6252,6 +6315,211 @@ }); } + function getSelectionInsideElement(element) { + if (!element || !window.getSelection) return ''; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return ''; + const selectedText = String(selection.toString() || '').trim(); + if (!selectedText) return ''; + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer; + if (!element.contains(container)) return ''; + return selectedText; + } + + async function createCaseBuzzwordFromText(rawText) { + const text = String(rawText || '').trim(); + if (!text) { + throw new Error('Ingen tekst at oprette buzzword fra'); + } + + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + const headers = { 'Content-Type': 'application/json' }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify({ buzzword: text }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke oprette buzzword'); + } + + const payload = await response.json().catch(() => ({})); + await loadCaseBuzzwordsModule(); + return payload; + } + + async function loadCaseBuzzwordsModule() { + const moduleContainer = document.getElementById('case-buzzwords-module'); + if (!moduleContainer) return; + + try { + let items = []; + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords`, { headers, credentials: 'include' }); + if (response.ok) { + const payload = await response.json(); + items = Array.isArray(payload) ? payload : []; + } + + if ((!Array.isArray(items) || items.length === 0) && Array.isArray(initialCaseBuzzwordsSnapshot) && initialCaseBuzzwordsSnapshot.length > 0) { + items = initialCaseBuzzwordsSnapshot; + } + + if (!Array.isArray(items) || items.length === 0) { + moduleContainer.innerHTML = '
Ingen buzzwords paa sagen endnu
'; + setModuleContentState('buzzwords', false); + return; + } + + moduleContainer.innerHTML = items.map((item) => ` + + ${escapeHtml(item.word || '')} + + + `).join(''); + + setModuleContentState('buzzwords', true); + } catch (error) { + console.error('Error loading case buzzwords:', error); + moduleContainer.innerHTML = '
Fejl ved hentning af buzzwords
'; + setModuleContentState('buzzwords', true); + } + } + + async function createCaseBuzzwordFromInput() { + const input = document.getElementById('case-buzzword-input'); + if (!input) return; + + try { + await createCaseBuzzwordFromText(input.value); + input.value = ''; + if (typeof showNotification === 'function') { + showNotification('Buzzword tilfoejet', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } else { + alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`); + } + } + } + + function getCurrentBuzzwordSelectionText() { + const liveSelection = String(window.getSelection ? window.getSelection().toString() : '').trim(); + if (liveSelection) { + return liveSelection; + } + + const descTextarea = document.getElementById('beskrivelse-textarea'); + if (descTextarea) { + const start = Number(descTextarea.selectionStart); + const end = Number(descTextarea.selectionEnd); + if (Number.isFinite(start) && Number.isFinite(end) && end > start) { + return String(descTextarea.value || '').slice(start, end).trim(); + } + } + + return ''; + } + + async function createCaseBuzzwordFromCurrentSelection() { + const selectedText = getCurrentBuzzwordSelectionText(); + if (!selectedText) { + if (typeof showNotification === 'function') { + showNotification('Marker tekst foerst, og tryk derefter Opret markering', 'warning'); + } else { + alert('Marker tekst foerst, og tryk derefter Opret markering'); + } + return; + } + + try { + await createCaseBuzzwordFromText(selectedText); + if (typeof showNotification === 'function') { + showNotification('Buzzword oprettet fra markering', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } else { + alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`); + } + } + } + + async function removeCaseBuzzwordAndSync(buzzwordId) { + const numericBuzzwordId = Number(buzzwordId || 0); + if (!numericBuzzwordId) return; + + try { + const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const response = await fetch(`/api/v1/sag/${caseId}/buzzwords/${numericBuzzwordId}`, { + method: 'DELETE', + headers, + credentials: 'include' + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Kunne ikke fjerne buzzword'); + } + + await loadCaseBuzzwordsModule(); + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke fjerne buzzword', 'error'); + } else { + alert(`Fejl: ${error.message || 'Kunne ikke fjerne buzzword'}`); + } + } + } + + async function createCaseBuzzwordFromCommentButton(triggerButton) { + const commentItem = triggerButton?.closest('.comment-item'); + const commentBody = commentItem?.querySelector('.comment-body'); + const selectedText = getSelectionInsideElement(commentBody); + + if (!selectedText) { + if (typeof showNotification === 'function') { + showNotification('Marker tekst i kommentaren foerst', 'warning'); + } else { + alert('Marker tekst i kommentaren foerst'); + } + return; + } + + try { + await createCaseBuzzwordFromText(selectedText); + if (typeof showNotification === 'function') { + showNotification('Buzzword oprettet fra kommentar-markering', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } + } + } + + window.createCaseBuzzwordFromInput = createCaseBuzzwordFromInput; + window.createCaseBuzzwordFromCurrentSelection = createCaseBuzzwordFromCurrentSelection; + window.createCaseBuzzwordFromText = createCaseBuzzwordFromText; + window.removeCaseBuzzwordAndSync = removeCaseBuzzwordAndSync; + window.createCaseBuzzwordFromCommentButton = createCaseBuzzwordFromCommentButton; + window.loadCaseBuzzwordsModule = loadCaseBuzzwordsModule; + window.openTemplateSelectorFromTagClick = openTemplateSelectorFromTagClick; window.syncCaseTagsUi = syncCaseTagsUi; @@ -6913,9 +7181,9 @@
Ingen tags paa sagen endnu
{% endif %} -
-
Forslag (brand/type)
-
+
+
Forslag
+
Indlaeser forslag...
@@ -6955,6 +7223,138 @@
+
+
+
Buzzwords
+
+ + + +
+
+
+
+ {% if buzzwords and buzzwords|length > 0 %} + {% for item in buzzwords %} + + {{ item.word }} + + + {% endfor %} + {% else %} +
Ingen buzzwords paa sagen endnu
+ {% endif %} +
+
+
+ + +
Kontakter
@@ -9093,6 +9493,16 @@ const rawText = body.dataset.commentRaw || body.textContent || ''; if (!item.classList.contains('comment-system')) { body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '
'); + const existingActions = item.querySelector('.comment-actions'); + if (existingActions) { + existingActions.remove(); + } + const actions = document.createElement('div'); + actions.className = 'comment-actions'; + actions.innerHTML = ` + + `; + item.appendChild(actions); return; } @@ -9123,6 +9533,7 @@ + `; item.appendChild(actions); const quickInlineBtn = actions.querySelector('.js-quick-inline-reply'); @@ -9138,6 +9549,7 @@ + `; item.appendChild(actions); const replyBtn = actions.querySelector('.js-reply-fallback'); @@ -9173,8 +9585,20 @@ async function submitComment(event) { event.preventDefault(); const form = event.target; - const content = form.indhold.value; - const btn = form.querySelector('button'); + const content = (form.indhold.value || '').trim(); + if (!content) { + alert('Skriv en kommentar først.'); + return; + } + const selectedActions = getSelectedCommentActions(); + if (selectedActions.length) { + await submitCommentWithActions(selectedActions, { + triggerButton: form.querySelector('button[type="submit"]') + }); + return; + } + + const btn = form.querySelector('button[type="submit"]'); const originalText = btn.innerHTML; btn.innerHTML = ' Sender...'; @@ -9203,6 +9627,327 @@ btn.disabled = false; } } + + function getSelectedCommentActions() { + const actions = []; + if (document.getElementById('commentActionSolution')?.checked) actions.push('solution'); + if (document.getElementById('commentActionReplyCustomer')?.checked) actions.push('reply_customer'); + if (document.getElementById('commentActionLogTime')?.checked) actions.push('log_time'); + return actions; + } + + function refreshReplyCustomerHoverText() { + const replyLabel = document.getElementById('commentActionReplyCustomerLabel'); + const replyCheckbox = document.getElementById('commentActionReplyCustomer'); + if (!replyLabel && !replyCheckbox) return; + + let recipient = ''; + if (typeof getDefaultCaseRecipient === 'function') { + recipient = String(getDefaultCaseRecipient() || '').trim(); + } + + const hoverText = recipient + ? `Svar kunden (sendes til: ${recipient})` + : 'Svar kunden (ingen standard modtager fundet endnu)'; + + if (replyLabel) { + replyLabel.title = hoverText; + } + if (replyCheckbox) { + replyCheckbox.title = hoverText; + } + } + + function clearSelectedCommentActions() { + ['commentActionSolution', 'commentActionReplyCustomer', 'commentActionLogTime'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.checked = false; + }); + toggleCommentQuickTimeRow(); + } + + function toggleCommentQuickTimeRow() { + const row = document.getElementById('commentQuickTimeRow'); + const timeChecked = !!document.getElementById('commentActionLogTime')?.checked; + if (!row) return; + row.classList.toggle('d-none', !timeChecked); + + if (timeChecked) { + const dateInput = document.getElementById('commentQuickTimeDate'); + if (dateInput && !dateInput.value) { + dateInput.value = new Date().toISOString().slice(0, 10); + } + } + toggleCommentQuickTimeStartField(); + } + + function toggleCommentQuickTimeStartField() { + const includeStart = !!document.getElementById('commentQuickTimeIncludeStart')?.checked; + const startInput = document.getElementById('commentQuickTimeStart'); + if (!startInput) return; + + startInput.disabled = !includeStart; + startInput.classList.toggle('d-none', !includeStart); + + if (!includeStart) { + startInput.value = ''; + return; + } + + if (!startInput.value) { + const now = new Date(); + const roundedMinutes = Math.ceil(now.getMinutes() / 5) * 5; + if (roundedMinutes >= 60) { + now.setHours(now.getHours() + 1); + now.setMinutes(0); + } else { + now.setMinutes(roundedMinutes); + } + startInput.value = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + } + } + + function injectCreatedComment(createdComment, content, fallbackInternal) { + const commentsContainer = document.getElementById('comments-container'); + if (!commentsContainer || !createdComment) return; + + const item = document.createElement('div'); + const isInternal = createdComment.er_intern !== undefined + ? !!createdComment.er_intern + : !!fallbackInternal; + item.className = `comment-item ${isInternal ? 'comment-internal' : 'comment-external'}`; + item.dataset.category = isInternal ? 'internal' : 'external'; + item.dataset.createdAt = String(Date.now() / 1000); + + const author = createdComment.forfatter || 'Bruger'; + const initials = String(author).slice(0, 2).toUpperCase(); + const createdAt = createdComment.created_at + ? new Date(createdComment.created_at).toLocaleString('da-DK') + : new Date().toLocaleString('da-DK'); + + item.innerHTML = ` +
+ ${escapeHtml(initials)} + ${escapeHtml(author)} + ${isInternal ? 'Intern' : 'Ekstern'} + ${escapeHtml(createdAt)} +
+
${escapeHtml(content).replace(/\n/g, '
')}
+ `; + + const emptyText = commentsContainer.querySelector('p.text-center.text-muted'); + if (emptyText) { + emptyText.remove(); + } + commentsContainer.prepend(item); + + const totalBadge = document.getElementById('commentsTotalCountBadge'); + if (totalBadge) { + const current = Number(totalBadge.textContent || 0); + totalBadge.textContent = String(current + 1); + } + + if (typeof applyCommentFilters === 'function') { + applyCommentFilters(); + } + } + + async function registerSolutionFromComment(content) { + const firstLine = content.split('\n').find((line) => line.trim()) || ''; + const title = firstLine.slice(0, 120) || 'Løsning fra kommentar'; + + const response = await fetch('/api/v1/sag/{{ case.id }}/solution', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sag_id: {{ case.id }}, + title, + solution_type: 'Support', + result: 'Løst', + description: content, + created_by_user_id: 1 + }) + }); + + if (!response.ok) { + throw new Error('Kunne ikke registrere løsning'); + } + } + + async function sendCustomerReplyFromComment(content) { + const recipient = String((typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '') || '').trim(); + if (!recipient) { + throw new Error('Ingen standard modtager fundet til Svar kunden'); + } + + const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN'; + const subject = `(Sag:${caseIds}) - "${title}"`; + + const response = await fetch(`/api/v1/sag/${caseIds}/emails/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: [recipient], + cc: [], + bcc: [], + subject, + body_text: content, + thread_email_id: null, + thread_key: null + }) + }); + + if (!response.ok) { + let message = 'Kunne ikke sende kundemail'; + try { + const payload = await response.json(); + if (payload?.detail) message = payload.detail; + } catch (_) { + } + throw new Error(message); + } + } + + async function registerQuickTimeFromComment(content) { + const dateValue = document.getElementById('commentQuickTimeDate')?.value || new Date().toISOString().slice(0, 10); + const includeStart = !!document.getElementById('commentQuickTimeIncludeStart')?.checked; + const startValue = includeStart ? (document.getElementById('commentQuickTimeStart')?.value || '') : ''; + const minutes = Number(document.getElementById('commentQuickTimeMinutes')?.value || 0); + const workType = document.getElementById('commentQuickTimeType')?.value || 'support'; + + if (!minutes || minutes <= 0) { + throw new Error('Ugyldigt minuttal i hurtig tidsregistrering'); + } + + let startIso = null; + let endIso = null; + if (startValue) { + const startDate = new Date(`${dateValue}T${startValue}:00`); + if (!Number.isNaN(startDate.getTime())) { + const endDate = new Date(startDate.getTime() + (minutes * 60000)); + startIso = startDate.toISOString(); + endIso = endDate.toISOString(); + } + } + + const response = await fetch('/api/v1/timetracking/time/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sag_id: {{ case.id }}, + worked_date: dateValue, + timer: minutes / 60, + faktisk_tid_min: minutes, + start_tid: startIso, + slut_tid: endIso, + description: content, + work_type: workType, + billing_method: 'invoice' + }) + }); + + if (!response.ok) { + throw new Error('Kunne ikke registrere tid'); + } + } + + async function applyCommentActionPrefill(action, content) { + if (action === 'solution') { + await registerSolutionFromComment(content); + return; + } + + if (action === 'reply_customer') { + await sendCustomerReplyFromComment(content); + return; + } + + if (action === 'log_time') { + await registerQuickTimeFromComment(content); + } + } + + async function submitCommentWithActions(actions, options = {}) { + const form = document.getElementById('comment-form'); + if (!form) return; + + const textarea = form.querySelector('textarea[name="indhold"]'); + const content = (textarea?.value || '').trim(); + if (!content) { + alert('Skriv en kommentar først.'); + return; + } + + const normalizedActions = Array.from(new Set((actions || []).filter(Boolean))); + if (!normalizedActions.length) { + alert('Vælg mindst én handling.'); + return; + } + + const triggerButton = options.triggerButton || document.getElementById('commentSendWithActionsBtn'); + const originalLabel = triggerButton?.innerHTML || ''; + if (triggerButton) { + triggerButton.disabled = true; + triggerButton.innerHTML = 'Gemmer...'; + } + + try { + const markInternal = normalizedActions.includes('solution') || normalizedActions.includes('log_time'); + const response = await fetch('/api/v1/sag/{{ case.id }}/kommentarer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ indhold: content, er_intern: markInternal }) + }); + + if (!response.ok) { + throw new Error('Kunne ikke gemme kommentar'); + } + + const createdComment = await response.json(); + injectCreatedComment(createdComment, content, markInternal); + + for (const action of normalizedActions) { + await applyCommentActionPrefill(action, content); + } + + if (textarea) { + textarea.value = ''; + } + clearSelectedCommentActions(); + alert('Kommentar sendt og valgte handlinger registreret.'); + } catch (error) { + console.error('submitCommentWithActions error:', error); + alert('Kunne ikke gemme kommentar og handlinger. Prøv igen.'); + } finally { + if (triggerButton) { + triggerButton.disabled = false; + triggerButton.innerHTML = originalLabel; + } + } + } + + async function submitCommentWithSelectedActions(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const actions = getSelectedCommentActions(); + if (!actions.length) { + alert('Vælg mindst én handling.'); + return; + } + await submitCommentWithActions(actions, { + triggerButton: document.getElementById('commentSendWithActionsBtn') + }); + } + + async function submitCommentWithAction(action) { + const form = document.getElementById('comment-form'); + const actionBtn = form?.querySelector(`button[onclick="submitCommentWithAction('${action}')"]`); + await submitCommentWithActions([action], { + triggerButton: actionBtn || form?.querySelector('button[type="submit"]') + }); + } // Keep newest comments visible at top document.addEventListener('DOMContentLoaded', function() { @@ -9210,6 +9955,25 @@ processCommentBodies(); bindCommentFilterControls(); applyCommentFilters(); + refreshReplyCustomerHoverText(); + toggleCommentQuickTimeRow(); + toggleCommentQuickTimeStartField(); + const replyLabel = document.getElementById('commentActionReplyCustomerLabel'); + const replyCheckbox = document.getElementById('commentActionReplyCustomer'); + const timeCheckbox = document.getElementById('commentActionLogTime'); + const includeStartCheckbox = document.getElementById('commentQuickTimeIncludeStart'); + if (replyLabel) { + replyLabel.addEventListener('mouseenter', refreshReplyCustomerHoverText); + } + if (replyCheckbox) { + replyCheckbox.addEventListener('mouseenter', refreshReplyCustomerHoverText); + } + if (timeCheckbox) { + timeCheckbox.addEventListener('change', toggleCommentQuickTimeRow); + } + if (includeStartCheckbox) { + includeStartCheckbox.addEventListener('change', toggleCommentQuickTimeStartField); + } const container = document.getElementById('comments-container'); if(container) { container.scrollTop = 0; @@ -13682,9 +14446,9 @@ document.body.setAttribute('data-case-view', viewName); const viewDefaults = { - 'Pipeline': ['pipeline', 'relations', 'sales', 'time'], - 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'], - 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar', 'shipping'] + 'Pipeline': ['pipeline', 'relations', 'sales', 'time', 'buzzwords'], + 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags', 'buzzwords'], + 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'buzzwords', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar', 'shipping'] }; const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey) @@ -13702,8 +14466,9 @@ const moduleName = el.getAttribute('data-module'); const hasContent = moduleHasContent(el); const isTimeModule = moduleName === 'time'; + const isBuzzwordsModule = moduleName === 'buzzwords'; const isShippingModule = moduleName === 'shipping'; - const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'tags' && !isTimeModule; + const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'tags' && moduleName !== 'buzzwords' && !isTimeModule; const pref = modulePrefs[moduleName]; const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); @@ -13739,6 +14504,12 @@ return; } + if (isBuzzwordsModule) { + setVisibility(true); + el.classList.remove('module-empty-compact'); + return; + } + // Shipping should always be visible when feature exists on the page. if (isShippingModule) { setVisibility(true); @@ -13921,6 +14692,9 @@ return acc; }, {}); modulePrefs.time = true; + if (typeof modulePrefs.buzzwords === 'undefined') { + modulePrefs.buzzwords = true; + } updateCaseTabCountBadges(); } catch (e) { console.error('Module prefs load failed', e); @@ -17591,6 +18365,40 @@ } }; + window.createBuzzwordFromDescriptionSelection = async function () { + const textarea = document.getElementById('beskrivelse-textarea'); + if (!textarea) return; + + const start = Number(textarea.selectionStart); + const end = Number(textarea.selectionEnd); + const selectedText = start < end ? String(textarea.value || '').slice(start, end).trim() : ''; + + if (!selectedText) { + if (typeof showNotification === 'function') { + showNotification('Marker tekst i beskrivelsen foerst', 'warning'); + } else { + alert('Marker tekst i beskrivelsen foerst'); + } + return; + } + + try { + if (typeof window.createCaseBuzzwordFromText !== 'function') { + throw new Error('Buzzword-funktion ikke initialiseret'); + } + await window.createCaseBuzzwordFromText(selectedText); + if (typeof showNotification === 'function') { + showNotification('Buzzword oprettet fra markering', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification(error.message || 'Kunne ikke oprette buzzword', 'error'); + } else { + alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`); + } + } + }; + window.loadBeskrivelsHistory = async function () { if (_historyLoaded) return; const list = document.getElementById('beskrivelse-history-list'); diff --git a/app/services/email_processor_service.py b/app/services/email_processor_service.py index e83dbac..319929d 100644 --- a/app/services/email_processor_service.py +++ b/app/services/email_processor_service.py @@ -33,15 +33,23 @@ class EmailProcessorService: self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS self.ai_enabled = settings.EMAIL_AI_ENABLED - async def process_inbox(self) -> Dict: + async def process_inbox( + self, + limit_override: Optional[int] = None, + folder_override: Optional[str] = None, + force_run: bool = False, + ) -> Dict: """ Main entry point: Process all new emails from inbox Returns: Processing statistics """ - if not self.enabled: + if not self.enabled and not force_run: logger.info("⏭️ Email processing disabled (EMAIL_TO_TICKET_ENABLED=false)") return {'status': 'disabled'} + + if force_run and not self.enabled: + logger.info("🧪 Manual force run enabled (EMAIL_TO_TICKET_ENABLED=false)") logger.info("🔄 Starting email processing cycle...") @@ -56,8 +64,18 @@ class EmailProcessorService: try: # Step 1: Fetch new emails - limit = settings.EMAIL_MAX_FETCH_PER_RUN - new_emails = await self.email_service.fetch_new_emails(limit=limit) + limit = int(limit_override) if limit_override else settings.EMAIL_MAX_FETCH_PER_RUN + if limit < 1: + limit = settings.EMAIL_MAX_FETCH_PER_RUN + + active_folder = str(folder_override or '').strip() or None + if active_folder: + logger.info("🧪 Processing inbox with folder override: %s (limit=%s)", active_folder, limit) + + new_emails = await self.email_service.fetch_new_emails_from_folder( + limit=limit, + folder_override=active_folder, + ) stats['fetched'] = len(new_emails) if not new_emails: diff --git a/app/services/email_service.py b/app/services/email_service.py index 0697306..58c3e2a 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -20,6 +20,7 @@ import asyncio import base64 import re from uuid import uuid4 +from urllib.parse import quote # Try to import aiosmtplib, but don't fail if not available try: @@ -33,7 +34,7 @@ from aiohttp import ClientSession, BasicAuth import msal from app.core.config import settings -from app.core.database import execute_query, execute_insert +from app.core.database import execute_query, execute_insert, execute_update logger = logging.getLogger(__name__) @@ -244,17 +245,61 @@ class EmailService: Fetch new emails from configured source (IMAP or Graph API) Returns list of parsed email dictionaries """ + return await self.fetch_new_emails_from_folder(limit=limit, folder_override=None) + + async def fetch_new_emails_from_folder(self, limit: int = 50, folder_override: Optional[str] = None) -> List[Dict]: + """ + Fetch new emails from configured source with optional folder override. + Used by manual test runs to process one folder/mail type at a time. + """ + selected_folder = self._resolve_folder(folder_override) if self.use_graph and self.graph_config['client_id']: - logger.info("📥 Fetching emails via Microsoft Graph API") - return await self._fetch_via_graph(limit) + logger.info("📥 Fetching emails via Microsoft Graph API (folder=%s)", selected_folder) + return await self._fetch_via_graph(limit, folder=selected_folder) elif self.imap_config['username']: - logger.info("📥 Fetching emails via IMAP") - return await self._fetch_via_imap(limit) + logger.info("📥 Fetching emails via IMAP (folder=%s)", selected_folder) + return await self._fetch_via_imap(limit, folder=selected_folder) else: logger.warning("⚠️ No email source configured (IMAP or Graph API)") return [] + + def _resolve_folder(self, folder_override: Optional[str]) -> str: + """Resolve active folder with safe fallback order.""" + override = str(folder_override or '').strip() + if override: + return override + + test_folder = str(getattr(settings, 'IMAP_TEST_FOLDER', '') or '').strip() + if test_folder: + return test_folder + + return self.imap_config['folder'] + + def _sync_existing_email_folder(self, message_id: Optional[str], folder: Optional[str]) -> None: + """Ensure existing email rows follow the latest source folder label.""" + normalized_message_id = str(message_id or '').strip() + normalized_folder = str(folder or '').strip() + if not normalized_message_id or not normalized_folder: + return + + try: + updated = execute_update( + """ + UPDATE email_messages + SET folder = %s, + updated_at = CURRENT_TIMESTAMP + WHERE deleted_at IS NULL + AND message_id = %s + AND COALESCE(folder, '') <> %s + """, + (normalized_folder, normalized_message_id, normalized_folder), + ) + if updated: + logger.info("📁 Updated folder for existing email %s -> %s", normalized_message_id, normalized_folder) + except Exception as e: + logger.warning("⚠️ Could not sync folder for message_id=%s: %s", normalized_message_id, e) - async def _fetch_via_imap(self, limit: int) -> List[Dict]: + async def _fetch_via_imap(self, limit: int, folder: Optional[str] = None) -> List[Dict]: """Fetch emails using IMAP protocol (READ-ONLY mode)""" emails = [] @@ -269,12 +314,12 @@ class EmailService: mail.login(self.imap_config['username'], self.imap_config['password']) # Select folder in READ-ONLY mode (critical for safety) - folder = self.imap_config['folder'] + selected_folder = self._resolve_folder(folder) readonly = self.imap_config['readonly'] - mail.select(folder, readonly=readonly) + mail.select(selected_folder, readonly=readonly) if readonly: - logger.info(f"🔒 Connected to {folder} in READ-ONLY mode (emails will NOT be marked as read)") + logger.info(f"🔒 Connected to {selected_folder} in READ-ONLY mode (emails will NOT be marked as read)") # Search for all emails status, messages = mail.search(None, 'ALL') @@ -286,7 +331,7 @@ class EmailService: email_ids = messages[0].split() total_emails = len(email_ids) - logger.info(f"📊 Found {total_emails} emails in {folder}") + logger.info(f"📊 Found {total_emails} emails in {selected_folder}") # Get most recent emails (reverse order, limit) email_ids_to_fetch = email_ids[-limit:] if len(email_ids) > limit else email_ids @@ -306,7 +351,7 @@ class EmailService: msg = email.message_from_bytes(raw_email) # Extract fields - parsed_email = self._parse_email(msg, email_id.decode()) + parsed_email = self._parse_email(msg, email_id.decode(), folder=selected_folder) # Check if already exists in database if not self._email_exists(parsed_email['message_id']): @@ -314,6 +359,7 @@ class EmailService: logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}") else: logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}") + self._sync_existing_email_folder(parsed_email.get('message_id'), selected_folder) except Exception as e: logger.error(f"❌ Error parsing email {email_id}: {e}") @@ -332,7 +378,7 @@ class EmailService: logger.error(f"❌ Unexpected error fetching via IMAP: {e}") return [] - async def _fetch_via_graph(self, limit: int) -> List[Dict]: + async def _fetch_via_graph(self, limit: int, folder: Optional[str] = None) -> List[Dict]: """Fetch emails using Microsoft Graph API (OAuth2)""" emails = [] @@ -346,42 +392,56 @@ class EmailService: # Build Graph API request user_email = self.graph_config['user_email'] - folder = self.imap_config['folder'] # Use same folder name - - # Graph API endpoint for messages - url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{folder}/messages" - params = { - '$top': limit, - '$orderby': 'receivedDateTime desc', - '$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders' - } - - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } + selected_folder = self._resolve_folder(folder) async with ClientSession() as session: + folder_id, folder_log_name = await self._resolve_graph_folder_id( + session=session, + user_email=user_email, + access_token=access_token, + requested_folder=selected_folder, + ) + encoded_folder = quote(folder_id, safe='') + logger.info( + "🗂️ Graph folder resolved: requested='%s' -> id='%s' (%s)", + selected_folder, + folder_id, + folder_log_name, + ) + + # Graph API endpoint for messages + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_folder}/messages" + params = { + '$top': limit, + '$orderby': 'receivedDateTime desc', + '$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders' + } + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + async with session.get(url, params=params, headers=headers) as response: if response.status != 200: error_text = await response.text() logger.error(f"❌ Graph API error: {response.status} - {error_text}") return [] - + data = await response.json() messages = data.get('value', []) - - logger.info(f"📊 Found {len(messages)} emails via Graph API") - + + logger.info(f"📊 Found {len(messages)} emails via Graph API in {selected_folder}") + for msg in messages: try: - parsed_email = self._parse_graph_message(msg) - + parsed_email = self._parse_graph_message(msg, folder=selected_folder) + # Fetch attachments if email has them if msg.get('hasAttachments', False): attachments = await self._fetch_graph_attachments( - user_email, - msg['id'], + user_email, + msg['id'], access_token, session ) @@ -389,20 +449,21 @@ class EmailService: parsed_email['attachment_count'] = len(attachments) else: parsed_email['attachments'] = [] - + # Check if already exists if not self._email_exists(parsed_email['message_id']): emails.append(parsed_email) logger.info(f"✅ New email: {parsed_email['subject'][:50]}... from {parsed_email['sender_email']}") else: logger.debug(f"⏭️ Email already exists: {parsed_email['message_id']}") + self._sync_existing_email_folder(parsed_email.get('message_id'), selected_folder) # Re-save attachment bytes for existing emails (fills content_data for old emails) if parsed_email.get('attachments'): await self._resave_attachment_content( parsed_email['message_id'], parsed_email['attachments'] ) - + except Exception as e: logger.error(f"❌ Error parsing Graph message: {e}") continue @@ -413,6 +474,191 @@ class EmailService: except Exception as e: logger.error(f"❌ Unexpected error fetching via Graph API: {e}") return [] + + async def _resolve_graph_folder_id( + self, + session: ClientSession, + user_email: str, + access_token: str, + requested_folder: str, + ) -> Tuple[str, str]: + """Resolve IMAP-like folder path (e.g. INBOX.BMC_TEST) to Graph folder id.""" + normalized = str(requested_folder or '').strip() + if not normalized: + return "Inbox", "default" + + well_known = { + 'inbox': 'Inbox', + 'drafts': 'Drafts', + 'sent': 'SentItems', + 'sentitems': 'SentItems', + 'junk': 'JunkEmail', + 'junkemail': 'JunkEmail', + 'deleteditems': 'DeletedItems', + 'archive': 'Archive', + } + + direct = well_known.get(normalized.lower()) + if direct: + return direct, "well-known" + + path = re.split(r"[./\\]+", normalized) + path = [part.strip() for part in path if part and part.strip()] + starts_with_inbox = bool(path and path[0].lower() == 'inbox') + if starts_with_inbox: + path = path[1:] + + # If no child path remains, Inbox is the target. + if not path: + return "Inbox", "well-known" + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + if starts_with_inbox: + current_folder_id = "Inbox" + for segment in path: + child_id = await self._find_graph_child_folder_id( + session=session, + user_email=user_email, + access_token=access_token, + parent_folder_id=current_folder_id, + segment_name=segment, + headers=headers, + ) + if not child_id: + logger.warning( + "⚠️ Graph folder segment not found: '%s' under '%s' (requested '%s'). Trying global folder lookup.", + segment, + current_folder_id, + normalized, + ) + break + current_folder_id = child_id + else: + return current_folder_id, "resolved-path" + + # Fallback strategy: search globally by displayName candidates. + candidates: List[str] = [normalized] + if normalized.lower().startswith('inbox.'): + candidates.append(normalized[6:]) + if path: + candidates.append(path[-1]) + + fallback_id = await self._find_graph_folder_id_by_name( + session=session, + user_email=user_email, + headers=headers, + candidate_names=candidates, + ) + if fallback_id: + return fallback_id, "resolved-search" + + logger.warning( + "⚠️ Could not resolve Graph folder '%s'; falling back to Inbox to avoid malformed-id errors.", + normalized, + ) + return "Inbox", "fallback-inbox" + + async def _find_graph_folder_id_by_name( + self, + session: ClientSession, + user_email: str, + headers: Dict[str, str], + candidate_names: List[str], + ) -> Optional[str]: + """Search all reachable mail folders and return first id matching a candidate displayName.""" + normalized_candidates = { + str(name).strip().lower() + for name in candidate_names + if str(name).strip() + } + if not normalized_candidates: + return None + + queue: List[str] = ["https://graph.microsoft.com/v1.0/users/{}/mailFolders?$top=200&$select=id,displayName,childFolderCount".format(user_email)] + + while queue: + url = queue.pop(0) + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + logger.warning( + "⚠️ Graph folder lookup failed: %s - %s", + response.status, + error_text, + ) + return None + + payload = await response.json() + folders = payload.get('value', []) + for folder in folders: + display_name = str(folder.get('displayName') or '').strip() + if display_name.lower() in normalized_candidates: + logger.info( + "🧭 Graph global folder lookup matched '%s' (id=%s)", + display_name, + folder.get('id'), + ) + return folder.get('id') + + if int(folder.get('childFolderCount') or 0) > 0 and folder.get('id'): + encoded_id = quote(str(folder['id']), safe='') + queue.append( + f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_id}/childFolders?$top=200&$select=id,displayName,childFolderCount" + ) + + next_link = payload.get('@odata.nextLink') + if next_link: + queue.append(next_link) + + return None + + async def _find_graph_child_folder_id( + self, + session: ClientSession, + user_email: str, + access_token: str, + parent_folder_id: str, + segment_name: str, + headers: Dict[str, str], + ) -> Optional[str]: + """Find child folder id by displayName under a specific Graph parent folder.""" + expected = segment_name.strip().lower() + if not expected: + return None + + encoded_parent = quote(parent_folder_id, safe='') + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{encoded_parent}/childFolders" + params = { + '$top': 200, + '$select': 'id,displayName' + } + + while url: + async with session.get(url, params=params, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + logger.warning( + "⚠️ Graph childFolders lookup failed for parent '%s': %s - %s", + parent_folder_id, + response.status, + error_text, + ) + return None + + payload = await response.json() + for folder in payload.get('value', []): + display_name = str(folder.get('displayName') or '').strip() + if display_name.lower() == expected: + return folder.get('id') + + url = payload.get('@odata.nextLink') + params = None + + return None async def _get_graph_access_token(self) -> Optional[str]: """Get OAuth2 access token for Microsoft Graph API using MSAL""" @@ -441,7 +687,7 @@ class EmailService: logger.error(f"❌ Error getting Graph access token: {e}") return None - def _parse_email(self, msg: email.message.Message, email_id: str) -> Dict: + def _parse_email(self, msg: email.message.Message, email_id: str, folder: Optional[str] = None) -> Dict: """Parse IMAP email message into dictionary""" # Decode subject @@ -545,13 +791,13 @@ class EmailService: 'body_text': body_text, 'body_html': body_html, 'received_date': received_date, - 'folder': self.imap_config['folder'], + 'folder': self._resolve_folder(folder), 'has_attachments': len(attachments) > 0, 'attachment_count': len(attachments), 'attachments': attachments } - def _parse_graph_message(self, msg: Dict) -> Dict: + def _parse_graph_message(self, msg: Dict, folder: Optional[str] = None) -> Dict: """Parse Microsoft Graph API message into dictionary""" # Extract sender @@ -607,7 +853,7 @@ class EmailService: 'body_text': body_text, 'body_html': body_html, 'received_date': received_date, - 'folder': self.imap_config['folder'], + 'folder': self._resolve_folder(folder), 'has_attachments': msg.get('hasAttachments', False), 'attachment_count': 0 # Will be updated after fetching attachments } diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py index b583da4..3270694 100644 --- a/app/services/email_workflow_service.py +++ b/app/services/email_workflow_service.py @@ -235,10 +235,10 @@ class EmailWorkflowService: def has_helpdesk_routing_hint(self, email_data: Dict) -> bool: """Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers). - NOTE: A bare thread_key (Graph conversationId) is NOT a routing hint - because every Graph email has one, including newsletters and spam. - Only actual reply indicators (In-Reply-To, References), explicit - SAG tags, or BMCid markers count as hints.""" + NOTE: A bare thread_key (Graph conversationId) is NOT automatically a + routing hint because every Graph email has one, including newsletters + and spam. However, if the thread_key already maps to an existing SAG + via prior linked emails, we treat it as a valid hint.""" if self._extract_bmc_id(email_data): return True @@ -251,6 +251,10 @@ class EmailWorkflowService: if self._extract_reference_message_ids(email_data.get('email_references')): return True + provider_thread_key = self._normalize_message_id(email_data.get('thread_key')) + if provider_thread_key and self._find_sag_id_from_thread_key(provider_thread_key): + return True + return False def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]: @@ -364,15 +368,16 @@ class EmailWorkflowService: try: rows = execute_query( """ - SELECT se.sag_id - FROM sag_emails se - JOIN email_messages em ON em.id = se.email_id + SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id + FROM email_messages em + LEFT JOIN sag_emails se ON se.email_id = em.id WHERE em.deleted_at IS NULL + AND COALESCE(se.sag_id, em.linked_case_id) IS NOT NULL AND ( LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s ) - ORDER BY se.created_at DESC + ORDER BY em.received_date DESC, em.id DESC LIMIT 1 """, (thread_key, thread_key) @@ -389,18 +394,42 @@ class EmailWorkflowService: placeholders = ','.join(['%s'] * len(thread_message_ids)) rows = execute_query( f""" - SELECT se.sag_id - FROM sag_emails se - JOIN email_messages em ON em.id = se.email_id + SELECT COALESCE(se.sag_id, em.linked_case_id) AS sag_id + FROM email_messages em + LEFT JOIN sag_emails se ON se.email_id = em.id WHERE em.deleted_at IS NULL + AND COALESCE(se.sag_id, em.linked_case_id) IS NOT NULL AND LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders}) - ORDER BY se.created_at DESC + ORDER BY em.received_date DESC, em.id DESC LIMIT 1 """, tuple(thread_message_ids) ) return rows[0]['sag_id'] if rows else None + def _find_sag_id_from_legacy_description_message_id(self, email_data: Dict) -> Optional[int]: + """Fallback for old auto-created cases that only stored Message-ID inside description.""" + thread_message_ids = self._extract_thread_message_ids(email_data) + if not thread_message_ids: + return None + + for message_id in thread_message_ids: + rows = execute_query( + """ + SELECT id + FROM sag_sager + WHERE deleted_at IS NULL + AND LOWER(REGEXP_REPLACE(COALESCE(beskrivelse, ''), '[<>\\s]', '', 'g')) LIKE %s + ORDER BY updated_at DESC NULLS LAST, id DESC + LIMIT 1 + """, + (f"%message-id:{message_id}%",), + ) + if rows: + return rows[0]['id'] + + return None + # Sender domains that should never trigger customer-domain SAG creation. # Includes own sending domain and common automated senders. _IGNORED_SENDER_DOMAINS = { @@ -1061,6 +1090,7 @@ class EmailWorkflowService: sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key) sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data) sag_id_from_tag = self._extract_sag_id(email_data) + sag_id_from_legacy = self._find_sag_id_from_legacy_description_message_id(email_data) scan_token_route = self._resolve_scan_token_route(email_id, email_data) if scan_token_route and scan_token_route.get('sag_id'): @@ -1136,6 +1166,11 @@ class EmailWorkflowService: routing_source = 'sag_tag' logger.info("🏷️ Matched email %s to SAG-%s via SAG tag", email_id, sag_id) + if sag_id_from_legacy and not sag_id: + sag_id = sag_id_from_legacy + routing_source = 'legacy_message_id' + logger.info("🧷 Matched email %s to SAG-%s via legacy Message-ID marker", email_id, sag_id) + # 1) Existing SAG via subject/headers if sag_id: return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source) diff --git a/app/services/vtiger_service.py b/app/services/vtiger_service.py index a5c2146..2402283 100644 --- a/app/services/vtiger_service.py +++ b/app/services/vtiger_service.py @@ -4,8 +4,11 @@ Handles subscription and sales order data retrieval """ import logging import json +import re +import html as html_lib import aiohttp -from typing import List, Dict, Optional +from decimal import Decimal +from typing import List, Dict, Optional, Any from app.core.config import settings logger = logging.getLogger(__name__) @@ -36,6 +39,68 @@ class VTigerService: if not self.api_key: raise ValueError("VTIGER_API_KEY not configured") return aiohttp.BasicAuth(self.username, self.api_key) + + @staticmethod + def _sanitize_vtiger_id(raw_value: Optional[str]) -> Optional[str]: + """Allow-list known vTiger id characters before embedding in query strings.""" + if not raw_value: + return None + candidate = str(raw_value).strip() + if re.match(r"^[A-Za-z0-9_\-x]+$", candidate): + return candidate + return None + + @staticmethod + def _extract_timelog_hours(timelog_data: Dict[str, Any]) -> Decimal: + """Extract hours from common vTiger timelog fields and normalize to decimal hours.""" + raw_value = None + field_used = None + + for key in ("time_spent", "duration", "total_hours", "hours"): + value = timelog_data.get(key) + if value not in (None, ""): + raw_value = value + field_used = key + break + + if raw_value in (None, ""): + return Decimal("0") + + if field_used == "duration": + try: + seconds = Decimal(str(raw_value)) + return (seconds / Decimal(3600)).quantize(Decimal("0.01")) + except Exception: + pass + + raw_str = str(raw_value).strip().lower() + + time_match = re.match(r"^(\d+):(\d+)(?::(\d+))?$", raw_str) + if time_match: + hours = Decimal(int(time_match.group(1))) + minutes = Decimal(int(time_match.group(2))) / Decimal(60) + seconds = Decimal(int(time_match.group(3) or 0)) / Decimal(3600) + return (hours + minutes + seconds).quantize(Decimal("0.01")) + + if "h" in raw_str or "m" in raw_str or "s" in raw_str: + total_hours = Decimal("0") + hours_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*h", raw_str) + mins_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*m", raw_str) + secs_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*s", raw_str) + + if hours_match: + total_hours += Decimal(hours_match.group(1).replace(",", ".")) + if mins_match: + total_hours += Decimal(mins_match.group(1).replace(",", ".")) / Decimal(60) + if secs_match: + total_hours += Decimal(secs_match.group(1).replace(",", ".")) / Decimal(3600) + + return total_hours.quantize(Decimal("0.01")) + + try: + return Decimal(raw_str.replace(",", ".")).quantize(Decimal("0.01")) + except Exception: + return Decimal("0") async def query(self, query_string: str) -> List[Dict]: """ @@ -506,7 +571,7 @@ class VTigerService: logger.error(f"❌ Error updating subscription: {e}") raise - async def get_service_contracts(self, account_id: Optional[str] = None) -> List[Dict]: + async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]: """ Fetch service contracts from vTiger @@ -517,15 +582,26 @@ class VTigerService: List of service contract records with account_id included """ try: - if account_id: - query = ( - "SELECT * FROM ServiceContracts " - f"WHERE sc_related_to='{account_id}' AND contract_status='Active';" - ) - logger.info(f"🔍 Fetching active service contracts for account {account_id}") + query_filters = [] + safe_account_id = self._sanitize_vtiger_id(account_id) + + if account_id and not safe_account_id: + logger.warning("⚠️ Rejected unsafe account_id for service contract query") + return [] + + if safe_account_id: + query_filters.append(f"sc_related_to='{safe_account_id}'") + + if active_only: + query_filters.append("contract_status='Active'") + + where_clause = f" WHERE {' AND '.join(query_filters)}" if query_filters else "" + query = f"SELECT * FROM ServiceContracts{where_clause};" + + if safe_account_id: + logger.info(f"🔍 Fetching service contracts for account {safe_account_id} (active_only={active_only})") else: - query = "SELECT * FROM ServiceContracts WHERE contract_status='Active';" - logger.info(f"🔍 Fetching all active service contracts") + logger.info(f"🔍 Fetching all service contracts (active_only={active_only})") contracts = await self.query(query) logger.info(f"✅ Found {len(contracts)} service contracts") @@ -634,6 +710,422 @@ class VTigerService: logger.error(f"❌ Error fetching contract timelogs: {e}") return [] + async def get_service_contract_customers(self) -> List[Dict[str, str]]: + """Fetch account list that has at least one service contract.""" + contracts = await self.get_service_contracts(active_only=False) + account_ids = sorted({ + c.get("account_id") + for c in contracts + if c.get("account_id") + }) + + if not account_ids: + return [] + + customers: Dict[str, Dict[str, str]] = {} + chunk_size = 20 + for i in range(0, len(account_ids), chunk_size): + chunk = account_ids[i:i + chunk_size] + ids = "', '".join(chunk) + query = ( + "SELECT id, accountname FROM Accounts " + f"WHERE id IN ('{ids}');" + ) + rows = await self.query(query) + for row in rows: + account_id = row.get("id") + if account_id: + customers[account_id] = { + "account_id": account_id, + "account_name": row.get("accountname") or account_id, + } + + # Fallback names for accounts not returned by Accounts query + for account_id in account_ids: + if account_id not in customers: + customers[account_id] = { + "account_id": account_id, + "account_name": account_id, + } + + return sorted(customers.values(), key=lambda x: x.get("account_name", "").lower()) + + @staticmethod + def _is_closed_case(status: Optional[str]) -> bool: + if not status: + return False + normalized = status.strip().lower() + return normalized in {"closed", "resolved", "done", "completed", "lukket"} + + @staticmethod + def _first_non_empty(record: Dict[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + value = record.get(key) + if value not in (None, ""): + text = str(value).strip() + if text: + return text + return "" + + @staticmethod + def _html_to_text(value: Optional[str]) -> str: + """Convert HTML snippets from CRM fields into readable plain text.""" + if value in (None, ""): + return "" + + text = str(value) + text = re.sub(r"(?i)", "\n", text) + text = re.sub(r"(?is)<(script|style).*?>.*?", " ", text) + text = re.sub(r"(?is)<[^>]+>", " ", text) + text = html_lib.unescape(text) + text = text.replace("\xa0", " ") + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + @staticmethod + def _extract_vtiger_record_id(raw_id: Optional[str]) -> Optional[str]: + if not raw_id: + return None + text = str(raw_id).strip() + if not text: + return None + if re.match(r"^\d+x\d+$", text): + return text.split("x", 1)[1] + if text.isdigit(): + return text + return None + + def _build_vtiger_record_url(self, module: str, raw_id: Optional[str]) -> str: + if not self.base_url: + return "" + record_id = self._extract_vtiger_record_id(raw_id) + if not record_id: + return "" + base = str(self.base_url).rstrip("/") + if module == "Cases": + return f"{base}/view/detail?module=Cases&id={record_id}&viewtype=summary" + if module == "Timelog": + return f"{base}/view/detail?module=Timelog&id={record_id}&viewtype=summary" + return f"{base}/index.php?module={module}&view=Detail&record={record_id}" + + @staticmethod + def _to_initials(value: Optional[str]) -> str: + if not value: + return "--" + text = str(value).strip() + if not text: + return "--" + + # Raw vTiger entity IDs are not human initials. + if re.match(r"^\d+x\d+$", text): + return "--" + + tokens = [token for token in re.split(r"[^A-Za-z0-9]+", text) if token] + if not tokens: + return "--" + + if len(tokens) == 1: + return tokens[0][:2].upper() + + return f"{tokens[0][0]}{tokens[1][0]}".upper() + + async def _resolve_user_initials(self, user_refs: List[str]) -> Dict[str, str]: + initials_by_ref: Dict[str, str] = {} + safe_ids = [] + for raw_ref in user_refs: + safe_ref = self._sanitize_vtiger_id(raw_ref) + if safe_ref: + safe_ids.append(safe_ref) + + if not safe_ids: + return initials_by_ref + + unique_ids = sorted(set(safe_ids)) + chunk_size = 20 + for index in range(0, len(unique_ids), chunk_size): + chunk = unique_ids[index:index + chunk_size] + id_list = "', '".join(chunk) + rows = await self.query( + "SELECT id, first_name, last_name, user_name FROM Users " + f"WHERE id IN ('{id_list}');" + ) + for row in rows: + user_id = row.get("id") + if not user_id: + continue + full_name = " ".join( + part for part in [row.get("first_name"), row.get("last_name")] if part + ).strip() + source = full_name or row.get("user_name") or user_id + initials_by_ref[user_id] = self._to_initials(source) + + # Some timelog owners are groups, not user records. + group_rows = await self.query( + "SELECT id, groupname FROM Groups " + f"WHERE id IN ('{id_list}');" + ) + for row in group_rows: + group_id = row.get("id") + if not group_id or group_id in initials_by_ref: + continue + initials_by_ref[group_id] = self._to_initials(row.get("groupname") or group_id) + + return initials_by_ref + + async def _resolve_contact_names(self, contact_refs: List[str]) -> Dict[str, str]: + names_by_ref: Dict[str, str] = {} + safe_ids = [] + + for raw_ref in contact_refs: + if not raw_ref: + continue + text = str(raw_ref).strip() + safe_ref = self._sanitize_vtiger_id(text) + if safe_ref and re.match(r"^\d+x\d+$", safe_ref): + safe_ids.append(safe_ref) + elif text: + names_by_ref[text] = text + + if not safe_ids: + return names_by_ref + + unique_ids = sorted(set(safe_ids)) + chunk_size = 20 + for index in range(0, len(unique_ids), chunk_size): + chunk = unique_ids[index:index + chunk_size] + id_list = "', '".join(chunk) + rows = await self.query( + "SELECT id, firstname, lastname, salutationtype FROM Contacts " + f"WHERE id IN ('{id_list}');" + ) + for row in rows: + contact_id = row.get("id") + if not contact_id: + continue + parts = [ + str(row.get("salutationtype") or "").strip(), + str(row.get("firstname") or "").strip(), + str(row.get("lastname") or "").strip(), + ] + full_name = " ".join(part for part in parts if part) + names_by_ref[contact_id] = full_name or contact_id + + return names_by_ref + + async def get_service_contract_report_data(self, account_id: str, contract_id: str) -> Dict[str, Any]: + """Build report payload for selected customer + service contract.""" + safe_account_id = self._sanitize_vtiger_id(account_id) + safe_contract_id = self._sanitize_vtiger_id(contract_id) + if not safe_account_id or not safe_contract_id: + raise ValueError("Invalid account_id or contract_id") + + contract_rows = await self.query( + f"SELECT * FROM ServiceContracts WHERE id='{safe_contract_id}' LIMIT 1;" + ) + if not contract_rows: + raise ValueError("Service contract was not found") + + contract = contract_rows[0] + contract_account_id = ( + contract.get("account_id") + or contract.get("accountid") + or contract.get("cf_service_contracts_account") + or contract.get("sc_related_to") + or "" + ) + + # If contract is linked to a different customer, return clear validation error. + if contract_account_id and contract_account_id != safe_account_id: + raise ValueError("Service contract does not belong to selected customer") + + account = await self.get_account_by_id(safe_account_id) + account_name = (account or {}).get("accountname") or safe_account_id + + cases = await self.get_service_contract_cases(safe_contract_id) + timelogs = await self.get_service_contract_timelogs(safe_contract_id) + + contact_ref_candidates = [ + str( + case.get("contact_id") + or case.get("contactid") + or case.get("parent_contact_id") + or case.get("contactname") + or case.get("cf_contact_person") + or "" + ) + for case in cases + if ( + case.get("contact_id") + or case.get("contactid") + or case.get("parent_contact_id") + or case.get("contactname") + or case.get("cf_contact_person") + ) + ] + contact_name_map = await self._resolve_contact_names(contact_ref_candidates) + + user_ref_candidates = [ + str( + log.get("assigned_user_id") + or log.get("modifiedby") + or log.get("created_user_id") + or "" + ) + for log in timelogs + if ( + log.get("assigned_user_id") + or log.get("modifiedby") + or log.get("created_user_id") + ) + ] + user_initials_map = await self._resolve_user_initials(user_ref_candidates) + + case_map: Dict[str, Dict[str, Any]] = {} + report_cases: List[Dict[str, Any]] = [] + + for case in cases: + case_id = case.get("id") + if not case_id: + continue + + case_payload = { + "id": case_id, + "cc_number": self._first_non_empty( + case, + ("ticket_no", "case_no", "caseno", "ticketid", "cf_case_number") + ) or case_id, + "title": self._first_non_empty( + case, + ("ticket_title", "tickettitle", "subject", "title") + ) or f"Case {case_id}", + "description": self._html_to_text( + self._first_non_empty( + case, + ("description", "ticketdescription", "solution", "comments", "comment") + ) + ) or "Ingen beskrivelse", + "contact_person": contact_name_map.get( + str( + case.get("contact_id") + or case.get("contactid") + or case.get("parent_contact_id") + or case.get("contactname") + or case.get("cf_contact_person") + or "" + ), + self._first_non_empty( + case, + ("contactname", "contact_id", "contactid", "cf_contact_person") + ) or "-" + ), + "vtiger_url": self._build_vtiger_record_url("Cases", case_id), + "status": case.get("ticketstatus") or case.get("status"), + "priority": case.get("ticketpriorities") or case.get("priority"), + "total_hours": Decimal("0"), + "timelog_count": 0, + "timelogs": [], + } + case_map[case_id] = case_payload + report_cases.append(case_payload) + + unmatched_case_id = "UNMAPPED" + for log in timelogs: + related_case_id = ( + log.get("relatedto") + or log.get("case_id") + or log.get("ticket_id") + or log.get("parent_id") + ) + raw_user = ( + log.get("assigned_user_id") + or log.get("modifiedby") + or log.get("created_user_id") + or "" + ) + hours = self._extract_timelog_hours(log) + timelog_payload = { + "id": log.get("id") or "", + "related_case_id": related_case_id, + "worked_date": log.get("date_start") or log.get("createdtime") or log.get("modifiedtime"), + "user_name": raw_user, + "employee_initials": user_initials_map.get(str(raw_user), self._to_initials(str(raw_user))), + "description": self._html_to_text( + self._first_non_empty( + log, + ( + "description", + "subject", + "commentcontent", + "comments", + "details", + "note", + "notes", + "cf_timelog_description", + ), + ) + ) or "Ingen beskrivelse", + "status": log.get("status"), + "billable": log.get("billable"), + "hours": hours, + "vtiger_url": self._build_vtiger_record_url("Timelog", log.get("id")), + } + + if related_case_id in case_map: + target_case = case_map[related_case_id] + else: + if unmatched_case_id not in case_map: + unmatched_case = { + "id": unmatched_case_id, + "cc_number": "-", + "title": "Timelogs uden relateret case", + "description": "Ingen relateret sag fra servicekontrakten.", + "contact_person": "-", + "vtiger_url": "", + "status": "Unknown", + "priority": None, + "total_hours": Decimal("0"), + "timelog_count": 0, + "timelogs": [], + } + case_map[unmatched_case_id] = unmatched_case + report_cases.append(unmatched_case) + target_case = case_map[unmatched_case_id] + + target_case["timelogs"].append(timelog_payload) + target_case["timelog_count"] += 1 + target_case["total_hours"] = (target_case["total_hours"] + hours).quantize(Decimal("0.01")) + + total_timelogs = sum(int(case_item["timelog_count"]) for case_item in report_cases) + total_hours = sum((case_item["total_hours"] for case_item in report_cases), Decimal("0")).quantize(Decimal("0.01")) + closed_cases = sum( + 1 for case_item in report_cases + if self._is_closed_case(case_item.get("status")) + ) + open_cases = max(0, len(report_cases) - closed_cases) + + return { + "customer": { + "account_id": safe_account_id, + "account_name": account_name, + }, + "contract": { + "id": safe_contract_id, + "contract_number": contract.get("contract_number") or contract.get("contract_no") or "", + "subject": contract.get("subject") or "", + "contract_status": contract.get("contract_status"), + "vtiger_url": self._build_vtiger_record_url("ServiceContracts", safe_contract_id), + }, + "cases": report_cases, + "summary": { + "total_cases": len(report_cases), + "open_cases": open_cases, + "closed_cases": closed_cases, + "total_timelogs": total_timelogs, + "total_hours": total_hours, + }, + } + # Singleton instance _vtiger_service = None diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index 336a72a..f31814d 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -1492,7 +1492,7 @@ async def scan_document(file_path: str): - {% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control'] %} + {% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control', '/dashboard/mission-control.old'] %} {% endif %} diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 27ca4e5..cb2b0c7 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -783,13 +783,18 @@ CRM @@ -830,14 +834,10 @@ Salg -
+{% include "shared/frontend/bug_report_modal.html" %} + {% block content_wrapper %}
{% block content %}{% endblock %} @@ -1265,6 +1269,7 @@ window.addEventListener('unhandledrejection', function(event) { + +{% endblock %} diff --git a/app/timetracking/frontend/service_contract_report.html b/app/timetracking/frontend/service_contract_report.html new file mode 100644 index 0000000..e94dc31 --- /dev/null +++ b/app/timetracking/frontend/service_contract_report.html @@ -0,0 +1,431 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Servicekontrakt Rapport - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Servicekontrakt Rapport

+

Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.

+
+ +
+
+
+ + +
+
+ + +
+
+ + + +
+
+
Start med at vaelge en kunde.
+
+ + + +
+
+
+
+

-

+
-
+ +
+
+ +
+
+ +
+
+
+ + +{% endblock %} diff --git a/app/timetracking/frontend/views.py b/app/timetracking/frontend/views.py index a20ed61..ea7a321 100644 --- a/app/timetracking/frontend/views.py +++ b/app/timetracking/frontend/views.py @@ -56,3 +56,15 @@ async def timetracking_orders(request: Request): async def service_contract_wizard(request: Request): """Service Contract Migration Wizard""" return templates.TemplateResponse("timetracking/frontend/service_contract_wizard.html", {"request": request}) + + +@router.get("/timetracking/service-contract-report", response_class=HTMLResponse, name="service_contract_report") +async def service_contract_report(request: Request): + """Service contract case/timelog report""" + return templates.TemplateResponse("timetracking/frontend/service_contract_report.html", {"request": request}) + + +@router.get("/timetracking/employee-log", response_class=HTMLResponse, name="timetracking_employee_log") +async def timetracking_employee_log(request: Request): + """Employee time log with day/week/month gap overview""" + return templates.TemplateResponse("timetracking/frontend/employee_log.html", {"request": request}) diff --git a/docker-compose.yml b/docker-compose.yml index 95903b3..5cda13e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,11 @@ services: - FEDEX_BASE_URL=${FEDEX_BASE_URL} - FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS} restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" extra_hosts: - "ollama-host:172.16.31.195" healthcheck: diff --git a/docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md b/docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md new file mode 100644 index 0000000..87f76c8 --- /dev/null +++ b/docs/audits/ROUTE_AUTH_AUDIT_2026-05-09.md @@ -0,0 +1,125 @@ +# Route Auth Audit (2026-05-09) + +Generated from frontend/auth/dashboard view decorators and classified against global middleware rules in main.py. + +## Summary + +- Total routes found: 104 +- Requires login: 93 +- Mission PIN protected: 6 +- Public: 5 + +## Rules used + +- Public: /health, /login, /api/v1/auth/login, /mission/pin* +- Mission PIN protected: /dashboard/mission-control*, /api/v1/mission/* +- All other listed routes: Requires login + +## Route Matrix + +| File | Line | Method | Path | Expected auth rule | Note | +|---|---:|---|---|---|---| +| app/auth/backend/views.py | 12 | GET | /login | Public | Allowed in public_paths | +| app/auth/backend/views.py | 27 | GET | /2fa/setup | Requires login | Redirect to /login when unauthenticated | +| app/backups/frontend/views.py | 14 | GET | /backups | Requires login | Redirect to /login when unauthenticated | +| app/billing/frontend/views.py | 14 | GET | /billing/supplier-invoices | Requires login | Redirect to /login when unauthenticated | +| app/billing/frontend/views.py | 23 | GET | /billing/supplier-invoices2 | Requires login | Redirect to /login when unauthenticated | +| app/billing/frontend/views.py | 32 | GET | /billing/template-builder | Requires login | Redirect to /login when unauthenticated | +| app/billing/frontend/views.py | 41 | GET | /billing/templates | Requires login | Redirect to /login when unauthenticated | +| app/billing/frontend/views.py | 50 | GET | /billing/sync-dashboard | Requires login | Redirect to /login when unauthenticated | +| app/conversations/frontend/views.py | 11 | GET | /conversations/my | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 161 | GET | /mission/pin | Public | Mission PIN entry/verify/logout are public | +| app/dashboard/backend/views.py | 168 | GET | /mission/pin/ | Public | Mission PIN entry/verify/logout are public | +| app/dashboard/backend/views.py | 173 | POST | /mission/pin/verify | Public | Mission PIN entry/verify/logout are public | +| app/dashboard/backend/views.py | 195 | POST | /mission/pin/logout | Public | Mission PIN entry/verify/logout are public | +| app/dashboard/backend/views.py | 201 | GET | / | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 292 | GET | /dashboard/sales | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 413 | POST | /dashboard/default | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 452 | GET | /dashboard/default | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 457 | POST | /dashboard/default/clear | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 481 | GET | /dashboard/default/clear | Requires login | Redirect to /login when unauthenticated | +| app/dashboard/backend/views.py | 486 | GET | /dashboard/mission-control | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/dashboard/backend/views.py | 499 | GET | /dashboard/mission-control/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/dashboard/backend/views.py | 504 | GET | /dashboard/mission-control/projects | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/dashboard/backend/views.py | 518 | GET | /dashboard/mission-control/projects/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/dashboard/backend/views.py | 523 | GET | /dashboard/mission-control.old | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/dashboard/backend/views.py | 534 | GET | /dashboard/mission-control.old/ | Mission PIN protected | Redirect/401 to mission PIN flow when unauthenticated | +| app/economy/frontend/views.py | 9 | GET | /economy/time-queue | Requires login | Redirect to /login when unauthenticated | +| app/emails/frontend/views.py | 18 | GET | /emails | Requires login | Redirect to /login when unauthenticated | +| app/fixed_price/frontend/views.py | 16 | GET | /fixed-price-agreements | Requires login | Redirect to /login when unauthenticated | +| app/fixed_price/frontend/views.py | 48 | GET | /fixed-price-agreements/{agreement_id} | Requires login | Redirect to /login when unauthenticated | +| app/fixed_price/frontend/views.py | 117 | GET | /fixed-price-agreements/reports/dashboard | Requires login | Redirect to /login when unauthenticated | +| app/fixed_price/frontend/views.py | 196 | GET | /api/fixed-price-agreements/customers | Requires login | Redirect to /login when unauthenticated | +| app/modules/_template/frontend/views.py | 22 | GET | /template | Requires login | Redirect to /login when unauthenticated | +| app/modules/calendar/frontend/views.py | 15 | GET | /calendar | Requires login | Redirect to /login when unauthenticated | +| app/modules/fedex/frontend/views.py | 9 | GET | /support/fedex | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 219 | GET | /hardware | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 301 | GET | /hardware/customers | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 348 | GET | /hardware/new | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 360 | GET | /hardware/eset | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 423 | GET | /hardware/eset/test | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 431 | GET | /hardware/eset/import | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 439 | GET | /hardware/{hardware_id:int} | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 687 | GET | /hardware/{hardware_id:int}/edit | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 708 | POST | /hardware/{hardware_id:int}/location | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 754 | POST | /hardware/{hardware_id:int}/owner | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 829 | POST | /hardware/{hardware_id:int}/contacts/add | Requires login | Redirect to /login when unauthenticated | +| app/modules/hardware/frontend/views.py | 851 | POST | /hardware/{hardware_id:int}/contacts/{contact_id:int}/delete | Requires login | Redirect to /login when unauthenticated | +| app/modules/links/frontend/views.py | 12 | GET | /links | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 117 | GET | /app/locations | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 249 | GET | /app/locations/create | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 316 | GET | /app/locations/wizard | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 362 | GET | /app/locations/{id} | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 533 | GET | /app/locations/{id}/edit | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 605 | POST | /app/locations/{id}/edit | Requires login | Redirect to /login when unauthenticated | +| app/modules/locations/frontend/views.py | 661 | GET | /app/locations/map | Requires login | Redirect to /login when unauthenticated | +| app/modules/manual/frontend/views.py | 32 | GET | /manual | Requires login | Redirect to /login when unauthenticated | +| app/modules/manual/frontend/views.py | 106 | GET | /manual/admin | Requires login | Redirect to /login when unauthenticated | +| app/modules/manual/frontend/views.py | 123 | GET | /manual/{slug} | Requires login | Redirect to /login when unauthenticated | +| app/modules/orders/frontend/views.py | 12 | GET | /ordre/create/new | Requires login | Redirect to /login when unauthenticated | +| app/modules/orders/frontend/views.py | 18 | GET | /ordre/{draft_id} | Requires login | Redirect to /login when unauthenticated | +| app/modules/orders/frontend/views.py | 27 | GET | /ordre | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 161 | GET | /sag | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 414 | GET | /sag/new | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 424 | GET | /sag/{sag_id}/work-orders/print | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 437 | GET | /sag/{sag_id}/labels/hardware/print | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 449 | GET | /sag/varekob-salg | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 456 | GET | /sag/{sag_id} | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 833 | GET | /sag/{sag_id}/v3 | Requires login | Redirect to /login when unauthenticated | +| app/modules/sag/frontend/views.py | 1159 | GET | /sag/{sag_id}/edit | Requires login | Redirect to /login when unauthenticated | +| app/modules/solution/frontend/views.py | 22 | GET | /solution | Requires login | Redirect to /login when unauthenticated | +| app/modules/telefoni/frontend/views.py | 13 | GET | /telefoni | Requires login | Redirect to /login when unauthenticated | +| app/modules/test_module/frontend/views.py | 22 | GET | /test_module | Requires login | Redirect to /login when unauthenticated | +| app/modules/webshop/frontend/views.py | 18 | GET | /webshop | Requires login | Redirect to /login when unauthenticated | +| app/opportunities/frontend/views.py | 9 | GET | /opportunities | Requires login | Redirect to /login when unauthenticated | +| app/products/frontend/views.py | 12 | GET | /products | Requires login | Redirect to /login when unauthenticated | +| app/products/frontend/views.py | 19 | GET | /products/{product_id} | Requires login | Redirect to /login when unauthenticated | +| app/subscriptions/frontend/views.py | 14 | GET | /subscriptions | Requires login | Redirect to /login when unauthenticated | +| app/subscriptions/frontend/views.py | 22 | GET | /subscriptions/simply-imports | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 35 | GET | / | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 101 | GET | /mockups/1 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 106 | GET | /mockups/2 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 111 | GET | /mockups/3 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 117 | GET | /worklog/review | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 256 | POST | /worklog/{worklog_id}/approve | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 313 | POST | /worklog/{worklog_id}/reject | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 369 | GET | /tickets/new | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 719 | GET | /dashboard/technician | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 741 | GET | /dashboard/technician/v1 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 763 | GET | /dashboard/technician/v2 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 785 | GET | /dashboard/technician/v3 | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 807 | GET | /dashboard | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 878 | GET | /tickets | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 959 | GET | /archived | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 1052 | GET | /archived/{archived_ticket_id} | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 1113 | GET | /tickets/{ticket_id} | Requires login | Redirect to /login when unauthenticated | +| app/ticket/frontend/views.py | 1187 | GET | /{path:path} | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 19 | GET | /timetracking | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 25 | GET | /timetracking/wizard | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 31 | GET | /timetracking/wizard2 | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 37 | GET | /timetracking/registrations | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 43 | GET | /timetracking/customers | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 49 | GET | /timetracking/orders | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 55 | GET | /timetracking/service-contract-wizard | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 61 | GET | /timetracking/service-contract-report | Requires login | Redirect to /login when unauthenticated | +| app/timetracking/frontend/views.py | 67 | GET | /timetracking/employee-log | Requires login | Redirect to /login when unauthenticated | diff --git a/main.py b/main.py index bb9eb30..b45fdc6 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,8 @@ from app.customers.backend import bmc_office_router from app.alert_notes.backend import router as alert_notes_api from app.billing.backend import router as billing_api from app.billing.frontend import views as billing_views +from app.economy.backend import router as economy_api +from app.economy.frontend import views as economy_views from app.system.backend import router as system_api from app.system.backend import sync_router from app.dashboard.backend import views as dashboard_views @@ -415,6 +417,7 @@ app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office # app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) # Replaced by hardware module app.include_router(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"]) app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) +app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"]) app.include_router(mission_api.router, prefix="/api/v1", tags=["Mission"]) @@ -472,6 +475,7 @@ app.include_router(products_views.router, tags=["Frontend"]) app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(timetracking_views.router, tags=["Frontend"]) app.include_router(billing_views.router, tags=["Frontend"]) +app.include_router(economy_views.router, tags=["Frontend"]) app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"]) app.include_router(contacts_views.router, tags=["Frontend"]) app.include_router(tags_views.router, tags=["Frontend"]) diff --git a/migrations/188_sag_buzzwords.sql b/migrations/188_sag_buzzwords.sql new file mode 100644 index 0000000..2aa892c --- /dev/null +++ b/migrations/188_sag_buzzwords.sql @@ -0,0 +1,41 @@ +-- Migration: 188_sag_buzzwords +-- Description: Separate buzzwords module for SAG with free-text keywords and case links + +BEGIN; + +CREATE TABLE IF NOT EXISTS buzzwords ( + id SERIAL PRIMARY KEY, + word VARCHAR(120) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sag_buzzwords ( + id SERIAL PRIMARY KEY, + sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + buzzword_id INTEGER NOT NULL REFERENCES buzzwords(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_buzzwords_word_active + ON buzzwords (word) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_buzzwords_word_active + ON buzzwords (word) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_sag_buzzwords_active + ON sag_buzzwords (sag_id, buzzword_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_sag_buzzwords_sag_id_active + ON sag_buzzwords (sag_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_sag_buzzwords_buzzword_id_active + ON sag_buzzwords (buzzword_id) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/migrations/189_mission_projects.sql b/migrations/189_mission_projects.sql new file mode 100644 index 0000000..fc65c55 --- /dev/null +++ b/migrations/189_mission_projects.sql @@ -0,0 +1,93 @@ +-- Mission Control Project View foundation +-- Adds project domain tables and links from sag_sager. + +CREATE TABLE IF NOT EXISTS mission_projects ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'planned' + CHECK (status IN ('planned', 'active', 'paused', 'completed', 'cancelled')), + score INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_mission_projects_status ON mission_projects (status); +CREATE INDEX IF NOT EXISTS idx_mission_projects_score ON mission_projects (score DESC); +CREATE INDEX IF NOT EXISTS idx_mission_projects_updated_at ON mission_projects (updated_at DESC); + +CREATE TABLE IF NOT EXISTS mission_project_milestones ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES mission_projects(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'completed', 'cancelled')), + target_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_project_id ON mission_project_milestones (project_id); +CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_status ON mission_project_milestones (status); +CREATE INDEX IF NOT EXISTS idx_mission_project_milestones_target_date ON mission_project_milestones (target_date); + +CREATE TABLE IF NOT EXISTS mission_project_blockers ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES mission_projects(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'resolved', 'cancelled')), + severity VARCHAR(16) NOT NULL DEFAULT 'medium' + CHECK (severity IN ('low', 'medium', 'high', 'critical')), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_project_id ON mission_project_blockers (project_id); +CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_status ON mission_project_blockers (status); +CREATE INDEX IF NOT EXISTS idx_mission_project_blockers_severity ON mission_project_blockers (severity); + +ALTER TABLE sag_sager + ADD COLUMN IF NOT EXISTS project_id BIGINT, + ADD COLUMN IF NOT EXISTS project_milestone_id BIGINT, + ADD COLUMN IF NOT EXISTS is_project_blocker BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS project_task_type VARCHAR(32); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_sag_sager_project_id' + ) THEN + ALTER TABLE sag_sager + ADD CONSTRAINT fk_sag_sager_project_id + FOREIGN KEY (project_id) + REFERENCES mission_projects(id) + ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_sag_sager_project_milestone_id' + ) THEN + ALTER TABLE sag_sager + ADD CONSTRAINT fk_sag_sager_project_milestone_id + FOREIGN KEY (project_milestone_id) + REFERENCES mission_project_milestones(id) + ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_sag_sager_project_id ON sag_sager (project_id); +CREATE INDEX IF NOT EXISTS idx_sag_sager_project_milestone_id ON sag_sager (project_milestone_id); +CREATE INDEX IF NOT EXISTS idx_sag_sager_project_task_type ON sag_sager (project_task_type); +CREATE INDEX IF NOT EXISTS idx_sag_sager_is_project_blocker ON sag_sager (is_project_blocker); diff --git a/requirements.txt b/requirements.txt index 67f977f..8887a4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,5 @@ Pillow==11.0.0 brother_ql==0.9.4 pyzbar==0.1.9 pypdfium2==4.30.0 +reportlab==4.2.5 +openpyxl==3.1.5 diff --git a/static/js/bottom-bar.js b/static/js/bottom-bar.js index 90b1299..b34fec1 100644 --- a/static/js/bottom-bar.js +++ b/static/js/bottom-bar.js @@ -97,12 +97,31 @@ }); if (!response.ok) { - throw new Error('Could not load bottom bar state'); + const error = new Error('Could not load bottom bar state'); + error.status = response.status; + throw error; } return response.json(); } + function disableBottomBarAuthRetry(reason) { + stopPolling(); + if (wsReconnectTimer) { + window.clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.close(1000, 'auth-failed'); + } catch (_) { + // Ignore close failures + } + } + setVisibility(false); + console.info('Bottom bar disabled due to auth state:', reason || 'unauthorized'); + } + function applyState(data) { if (data && data.enabled) { latestSections = data.sections || {}; @@ -697,12 +716,19 @@ } function pollOnce() { + let shouldScheduleNextPoll = true; fetchBottomBarState().then(function (data) { applyState(data); }).catch(function (err) { console.warn('Bottom bar poll failed', err); + if (err && (err.status === 401 || err.status === 403)) { + shouldScheduleNextPoll = false; + disableBottomBarAuthRetry('poll-' + err.status); + } }).finally(function () { - pollTimer = window.setTimeout(pollOnce, 15000); + if (shouldScheduleNextPoll) { + pollTimer = window.setTimeout(pollOnce, 15000); + } }); } @@ -820,7 +846,12 @@ } }); - ws.addEventListener('close', function () { + ws.addEventListener('close', function (event) { + // 1008 = policy violation (used server-side for auth failure). + if (event && event.code === 1008) { + disableBottomBarAuthRetry('ws-1008'); + return; + } startPollingFallback(); scheduleWsReconnect(); }); diff --git a/static/js/bug-report.js b/static/js/bug-report.js index 97a744d..0f34711 100644 --- a/static/js/bug-report.js +++ b/static/js/bug-report.js @@ -311,7 +311,11 @@ bugModal.show(); } - function prepareScreenshotFromTrigger() { + function prepareScreenshotFromTrigger(forceNew) { + if (!forceNew && pendingScreenshotPromise) { + return pendingScreenshotPromise; + } + pendingScreenshotPromise = takeScreenshot(); return pendingScreenshotPromise; } @@ -434,11 +438,14 @@ const modalEl = document.getElementById('bugReportModal'); if (btn) { + const primeCapture = () => { + prepareScreenshotFromTrigger(true); + }; + + btn.addEventListener('pointerdown', primeCapture); btn.addEventListener('click', (e) => { e.preventDefault(); - if (!pendingScreenshotPromise) { - prepareScreenshotFromTrigger(); - } + prepareScreenshotFromTrigger(false); openBugReportModal(); }); } @@ -479,9 +486,7 @@ if (isTyping) return; if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) { e.preventDefault(); - if (!pendingScreenshotPromise) { - prepareScreenshotFromTrigger(); - } + prepareScreenshotFromTrigger(true); openBugReportModal(); } });