diff --git a/MDfile/RELEASE_NOTES_v2.8.0.md b/MDfile/RELEASE_NOTES_v2.8.0.md new file mode 100644 index 0000000..fad375e --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.0.md @@ -0,0 +1,18 @@ +# BMC Hub v2.8.0 + +## Ny sag + +- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler. +- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse. +- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder. +- Brand- og type-tagforslag ud fra sagens titel og beskrivelse. + +## Interne beskeder + +- Nyt, mere overskueligt beskedflow mellem medarbejdere. +- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer. + +## Database + +- Kør `migrations/238_case_create_templates.sql`. +- Kør `migrations/239_internal_phone_messages.sql`. diff --git a/app/modules/bottom_bar/backend/router.py b/app/modules/bottom_bar/backend/router.py index 6886d5b..46f7ae6 100644 --- a/app/modules/bottom_bar/backend/router.py +++ b/app/modules/bottom_bar/backend/router.py @@ -1,8 +1,8 @@ -from typing import Optional +from typing import Optional, Literal import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.core.auth_service import AuthService from app.core.auth_dependencies import get_current_user @@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel): message: str recipient_user_id: Optional[int] = None requires_manual_ack: bool = False + message_kind: Literal['message', 'phone'] = 'message' + contact_id: Optional[int] = Field(default=None, gt=0) + caller_name: str = Field(default='', max_length=200) + callback_phone: str = Field(default='', max_length=80) class BottomBarMessageReadPayload(BaseModel): @@ -566,13 +570,17 @@ async def send_bottom_bar_message( if recipient_user_id == int(current_user_id): raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv") + if payload.contact_id is not None: + if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)): + raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke') + row = execute_query_single( """ - INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack) - VALUES (%s, %s, %s, %s) + INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at """, - (int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)), + (int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()), ) or {} return { @@ -582,6 +590,10 @@ async def send_bottom_bar_message( "from": _resolve_current_user_display_name(current_user), "to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}", "text": row.get("message_text") or message_text, + "message_kind": payload.message_kind, + "contact_id": payload.contact_id, + "caller_name": payload.caller_name.strip(), + "callback_phone": payload.callback_phone.strip(), "requires_manual_ack": bool(row.get("requires_manual_ack")), "created_at": row.get("created_at"), "is_own": True, diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index 3907743..8d774e0 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None: sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE, message_text TEXT NOT NULL, + message_kind VARCHAR(16) NOT NULL DEFAULT 'message', + contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + caller_name VARCHAR(200) NOT NULL DEFAULT '', + callback_phone VARCHAR(80) NOT NULL DEFAULT '', requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, read_at TIMESTAMP NULL @@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None: logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically") else: columns = set(_table_columns("bottom_bar_messages")) + if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns): + execute_query("""ALTER TABLE bottom_bar_messages + ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message', + ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""") if "requires_manual_ack" not in columns: execute_query( """ @@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s m.sender_user_id, m.recipient_user_id, m.message_text, + m.message_kind, m.contact_id, m.caller_name, m.callback_phone, + NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name, m.requires_manual_ack, m.created_at, receipt.read_at, @@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s FROM bottom_bar_messages m JOIN users sender ON sender.user_id = m.sender_user_id LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id + LEFT JOIN contacts contact ON contact.id = m.contact_id LEFT JOIN bottom_bar_message_receipts receipt ON receipt.message_id = m.id - AND receipt.user_id = %s + AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END WHERE m.sender_user_id = %s OR m.recipient_user_id = %s OR (m.recipient_user_id IS NULL AND EXISTS ( @@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s ORDER BY m.created_at DESC, m.id DESC LIMIT %s """, - (int(user_id), int(user_id), int(user_id), int(user_id), safe_limit), + (int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit), ) or [] unread_row = execute_query_single( @@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s "from": row.get("sender_name") or "Ukendt", "to": recipient_name, "text": row.get("message_text") or "", + "message_kind": row.get("message_kind") or "message", + "contact_id": row.get("contact_id"), + "contact_name": row.get("contact_name"), + "caller_name": row.get("caller_name") or "", + "callback_phone": row.get("callback_phone") or "", "requires_manual_ack": bool(row.get("requires_manual_ack")), "created_at": row.get("created_at").isoformat() if row.get("created_at") else None, "is_own": int(row.get("sender_user_id") or 0) == int(user_id), + "is_read": row.get("read_at") is not None, "is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id), "is_acknowledged": row.get("acknowledged_at") is not None, } diff --git a/app/modules/sag/backend/create_support.py b/app/modules/sag/backend/create_support.py new file mode 100644 index 0000000..729c31b --- /dev/null +++ b/app/modules/sag/backend/create_support.py @@ -0,0 +1,195 @@ +"""Creation helpers: transactional associations and advisory case lookups.""" +import json +import re +from difflib import SequenceMatcher +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict, Field, StrictInt +from psycopg2.extras import Json +from app.core.auth_dependencies import require_any_permission +from app.core.database import execute_query, execute_query_single + +read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin') +admin_access = require_any_permission('users.manage', 'system.admin') +router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)]) + + +def ids(value, field): + if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value): + raise HTTPException(400, f'{field} skal være en liste med positive heltal') + return list(dict.fromkeys(value)) + + +def attach_create_relations(cursor, case_id, data, user_id): + hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids') + tag_ids = ids(data.get('tag_ids', []), 'tag_ids') + if hardware_ids: + cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,)) + if {r['id'] for r in cursor.fetchall()} != set(hardware_ids): + raise HTTPException(400, 'En valgt hardware findes ikke længere') + for hardware_id in hardware_ids: + cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id)) + actions = [] + if tag_ids: + cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,)) + tags = {r['id']: r for r in cursor.fetchall()} + if set(tags) != set(tag_ids): + raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt') + # Same single/toggle group semantics as the global picker: last choice wins. + chosen = [] + for tag_id in tag_ids: + tag = tags[tag_id] + if tag['behavior'] in ('single', 'toggle'): + chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']] + chosen.append(tag_id) + for tag_id in chosen: + cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id)) + cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,)) + action = cursor.fetchone() + if action: + actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id}) + return actions + + +def closed_statuses(): + row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'") + try: + configured = json.loads((row or {}).get('value') or '[]') + values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')] + except (ValueError, TypeError): + values = [] + return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'] + + +def title_similarity(left, right): + def normalize(value): + return ' '.join(re.findall(r'\w+', value.casefold())) + left, right = normalize(left), normalize(right) + if not left or not right: + return 0 + a, b = set(left.split()), set(right.split()) + return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b)) + + +@router.get('/duplicates') +def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)): + rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn + FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id + WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or [] + ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows] + ranked.sort(key=lambda item: (-item[0], -item[1]['id'])) + return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5] + + +@router.get('/workload') +def workload(user_id: int): + rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total + FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id + WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s)) + ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or [] + return {'total': rows[0]['total'] if rows else 0, 'items': rows} + + +@router.get('/contacts-open-cases') +def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)): + """A small, advisory view used while selecting contacts on a new case.""" + contact_ids = ids(contact_ids, 'contact_ids') + rows = execute_query("""WITH ranked AS ( + SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline, + COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn, + COUNT(*) OVER (PARTITION BY sc.contact_id) AS total, + ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position + FROM sag_kontakter sc + JOIN sag_sager s ON s.id=sc.sag_id + LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id + WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL + AND NOT (LOWER(TRIM(s.status))=ANY(%s)) + ) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total + FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or [] + result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids} + for row in rows: + bucket = result[row['contact_id']] + bucket['total'] = row['total'] + bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')}) + return result + + +class PipelineDefaults(BaseModel): + model_config = ConfigDict(extra='forbid') + stage_id: Optional[int] = Field(default=None, gt=0) + amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False) + probability: Optional[int] = Field(default=None, ge=0, le=100) + description: Optional[str] = Field(default=None, max_length=10000) + + +class TemplateValues(BaseModel): + model_config = ConfigDict(extra='forbid') + type: str = Field(default='ticket', min_length=1, max_length=80) + titel: str = Field(default='', max_length=1000) + beskrivelse: str = Field(default='', max_length=50000) + status: str = Field(default='åben', min_length=1, max_length=80) + assigned_group_id: Optional[int] = Field(default=None, gt=0) + tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100) + pipeline: Optional[PipelineDefaults] = None + + +class CaseTemplate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: str = Field(min_length=1, max_length=120) + icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80) + is_active: bool = True + sort_order: int = 0 + values: TemplateValues + + +@router.get('/templates') +def templates(): + return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or [] + + +@router.get('/template-options', dependencies=[Depends(admin_access)]) +def template_options(): + return execute_query('SELECT id,name FROM groups ORDER BY name') or [] + + +@router.get('/templates/admin', dependencies=[Depends(admin_access)]) +def admin_templates(): + return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or [] + + +def template_args(data): + values = data.values.model_dump() + values['tag_ids'] = ids(values['tag_ids'], 'tag_ids') + if values['tag_ids']: + found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or [] + if {r['id'] for r in found} != set(values['tag_ids']): + raise HTTPException(400, 'Ugyldige tags') + if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)): + raise HTTPException(400, 'Ugyldig gruppe') + if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)): + raise HTTPException(400, 'Ugyldig pipeline-stage') + if not data.name.strip(): + raise HTTPException(400, 'Navn er påkrævet') + return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values)) + + +@router.post('/templates', dependencies=[Depends(admin_access)]) +def create_template(data: CaseTemplate): + return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data)) + + +@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)]) +def update_template(template_id: int, data: CaseTemplate): + row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,)) + if not row: + raise HTTPException(404, 'Skabelonen findes ikke') + return row + + +@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)]) +def delete_template(template_id: int): + row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,)) + if not row: + raise HTTPException(404, 'Skabelonen findes ikke') + return row diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 015bb79..9664db3 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -6,6 +6,7 @@ import re import hashlib import base64 import html +import math from pathlib import Path from datetime import datetime, timedelta, timezone from typing import Any, List, Optional, Dict @@ -1069,6 +1070,8 @@ async def create_sag(request: Request, data: dict): if pipeline_values["amount"] not in (None, ""): try: pipeline_values["amount"] = float(pipeline_values["amount"]) + if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0: + raise ValueError("Invalid amount") except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc else: @@ -1123,6 +1126,8 @@ async def create_sag(request: Request, data: dict): raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"): raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus") + if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))): + raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul") cursor.execute( """ diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 215654c..f79fc02 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -4,6 +4,21 @@ {% block extra_css %} {% endblock %} {% block content %} -
Udfyld formularen for at oprette en ny sag i systemet.
+Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.