From 85e019783b084db6ddcaf2e4c1a35481f30b70c3 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 15 Oct 2026 12:17:09 +0200 Subject: [PATCH] Add comprehensive test coverage for case creation, assignment, and remote recovery - Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads. - Added tests for v5 assignment functionality, validating employee identification and group assignment. - Implemented tests for remote recovery scenarios, including handling network failures and response validation. - Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation. - Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions. - Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation. --- .env.example | 3 + app/core/config.py | 4 + app/core/database.py | 7 +- app/modules/hardware/frontend/views.py | 21 + app/modules/hardware/labels.py | 26 + app/modules/hardware/templates/detail.html | 1 + app/modules/hardware/templates/index.html | 2 + app/modules/hardware/templates/label.html | 34 ++ app/modules/remote/__init__.py | 1 + app/modules/remote/manage.html | 6 + app/modules/remote/mobile.html | 31 ++ app/modules/remote/router.py | 298 +++++++++++ app/modules/remote/security.py | 92 ++++ app/modules/remote/service.py | 195 ++++++++ app/modules/remote/telephony.py | 87 ++++ app/modules/sag/frontend/views.py | 2 +- app/modules/sag/templates/create.html | 45 +- app/modules/sag/templates/detail_v5.html | 23 +- app/shared/frontend/base.html | 2 +- app/tags/frontend/classification.html | 59 ++- app/timetracking/backend/router.py | 52 +- app/timetracking/backend/timer_transaction.py | 35 ++ app/workflows/frontend/builder.html | 8 +- app/workflows/frontend/resources.html | 4 +- app/workflows/registry.py | 2 +- app/workflows/resources.py | 37 +- app/workflows/service.py | 4 +- app/workflows/settlement.py | 2 +- docs/remote-control.md | 61 +++ main.py | 11 + .../1068_workflow_customer_card_coverage.sql | 6 + migrations/1069_remote_control.sql | 49 ++ requirements.txt | 2 + static/css/case-create-v5.css | 35 ++ static/css/case-v5.css | 96 ++++ static/css/remote.css | 19 + static/js/bottom-bar.js | 9 +- static/js/case-command-composer.js | 55 +- static/js/case-command-suggestions.js | 17 +- static/js/case-create-commands.js | 168 +++++++ static/js/case-v5.js | 164 ++++-- static/js/remote-api.js | 45 ++ static/js/remote-hub.js | 70 +++ static/js/remote-manage.js | 45 ++ static/js/remote-mobile.js | 339 +++++++++++++ tests/browser/remote_control.cjs | 165 ++++++ tests/js/case-command-composer.test.cjs | 62 ++- tests/js/case-command-suggestions.test.cjs | 13 +- tests/js/case-create-commands.test.cjs | 55 ++ tests/js/case-v5-assignment.test.cjs | 27 + tests/js/remote-recovery.test.cjs | 25 + tests/test_hardware_labels.py | 50 ++ tests/test_remote_control.py | 469 ++++++++++++++++++ tests/test_sag_module.py | 2 +- tests/test_workflow_engine.py | 7 + tests/test_workflow_resources.py | 27 + tests/test_workflow_timer_registration.py | 41 ++ 57 files changed, 3125 insertions(+), 92 deletions(-) create mode 100644 app/modules/hardware/labels.py create mode 100644 app/modules/hardware/templates/label.html create mode 100644 app/modules/remote/__init__.py create mode 100644 app/modules/remote/manage.html create mode 100644 app/modules/remote/mobile.html create mode 100644 app/modules/remote/router.py create mode 100644 app/modules/remote/security.py create mode 100644 app/modules/remote/service.py create mode 100644 app/modules/remote/telephony.py create mode 100644 app/timetracking/backend/timer_transaction.py create mode 100644 docs/remote-control.md create mode 100644 migrations/1068_workflow_customer_card_coverage.sql create mode 100644 migrations/1069_remote_control.sql create mode 100644 static/css/case-create-v5.css create mode 100644 static/css/remote.css create mode 100644 static/js/case-create-commands.js create mode 100644 static/js/remote-api.js create mode 100644 static/js/remote-hub.js create mode 100644 static/js/remote-manage.js create mode 100644 static/js/remote-mobile.js create mode 100644 tests/browser/remote_control.cjs create mode 100644 tests/js/case-create-commands.test.cjs create mode 100644 tests/js/case-v5-assignment.test.cjs create mode 100644 tests/js/remote-recovery.test.cjs create mode 100644 tests/test_hardware_labels.py create mode 100644 tests/test_remote_control.py create mode 100644 tests/test_workflow_timer_registration.py diff --git a/.env.example b/.env.example index c37cf8e..18fa489 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ # ===================================================== DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub HUB_BASE_URL=https://hub.bmcnetworks.dk +# Remote control remains disabled until migrated; uses the existing Hub on local network/VPN. +REMOTE_ENABLED=false +REMOTE_LOOKUP_KEY= MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token # Database credentials (bruges af docker-compose) diff --git a/app/core/config.py b/app/core/config.py index 01569b1..4fd5e65 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -37,6 +37,10 @@ class Settings(BaseSettings): ENABLE_RELOAD: bool = False # Added to match docker-compose.yml HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" + # Local-network/VPN remote control. Configure a separate random key before enabling. + REMOTE_ENABLED: bool = False + REMOTE_LOOKUP_KEY: str = "" + # Non-interactive service token for Apple Configurator/cfgutil provisioning. # Leave empty to disable the endpoint rather than accepting unauthenticated calls. MOBILE_RECORDER_PROVISIONING_TOKEN: str = "" diff --git a/app/core/database.py b/app/core/database.py index 6377964..2996434 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -5,7 +5,7 @@ PostgreSQL connection and helpers using psycopg2 import psycopg2 from psycopg2.extras import RealDictCursor -from psycopg2.pool import SimpleConnectionPool +from psycopg2.pool import ThreadedConnectionPool from functools import lru_cache from typing import Optional import logging @@ -17,7 +17,7 @@ from app.core.config import settings logger = logging.getLogger(__name__) # Connection pool -connection_pool: Optional[SimpleConnectionPool] = None +connection_pool: Optional[ThreadedConnectionPool] = None _transaction_connection = ContextVar("bmc_transaction_connection", default=None) @@ -44,7 +44,8 @@ def init_db(): global connection_pool try: - connection_pool = SimpleConnectionPool( + # Synchronous API handlers run in worker threads, including remote polls. + connection_pool = ThreadedConnectionPool( minconn=1, maxconn=10, dsn=settings.DATABASE_URL diff --git a/app/modules/hardware/frontend/views.py b/app/modules/hardware/frontend/views.py index 83d8d5a..f80c215 100644 --- a/app/modules/hardware/frontend/views.py +++ b/app/modules/hardware/frontend/views.py @@ -438,6 +438,27 @@ async def hardware_eset_import(request: Request): }) +@router.get("/hardware/{hardware_id:int}/label", response_class=HTMLResponse) +async def hardware_label(request: Request, hardware_id: int, + size: str = Query("62x40"), base_url: Optional[str] = Query(None, max_length=300)): + from app.core.config import settings + from app.modules.hardware.labels import LABEL_SIZES, hardware_label_url, hardware_label_qr + + if size not in LABEL_SIZES: + raise HTTPException(400, detail="Vælg en af de understøttede labelstørrelser") + rows = execute_query("SELECT id, brand, model, serial_number FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)) + if not rows: + raise HTTPException(404, detail="Hardware not found") + label_base = (base_url or settings.HUB_BASE_URL).strip().rstrip('/') + target_url = hardware_label_url(label_base, hardware_id) + width, height = LABEL_SIZES[size] + return templates.TemplateResponse("modules/hardware/templates/label.html", { + "request": request, "hardware": rows[0], "size": size, + "width": width, "height": height, "base_url": label_base, + "target_url": target_url, "qr_svg": hardware_label_qr(target_url), + }, headers={"Cache-Control": "no-store"}) + + @router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse) async def hardware_detail(request: Request, hardware_id: int): """Display hardware details.""" diff --git a/app/modules/hardware/labels.py b/app/modules/hardware/labels.py new file mode 100644 index 0000000..308fd74 --- /dev/null +++ b/app/modules/hardware/labels.py @@ -0,0 +1,26 @@ +"""Local QR generation for printable inventory labels. No asset writes.""" +from urllib.parse import urlsplit, urlunsplit + +import qrcode +from qrcode.image.svg import SvgPathImage +from fastapi import HTTPException + +LABEL_SIZES = {'62x40': (62, 40), '62x29': (62, 29), '89x36': (89, 36)} + + +def hardware_label_url(base_url: str, hardware_id: int) -> str: + try: + parts = urlsplit(base_url.strip()) + except ValueError as error: + raise HTTPException(400, "Hub-adressen er ugyldig") from error + if (parts.scheme not in {'http', 'https'} or not parts.hostname + or parts.username or parts.password or parts.query or parts.fragment): + raise HTTPException(400, 'Brug en http- eller https-adresse uden login, søgeparametre eller fragment.') + return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip('/') + f'/hardware/{hardware_id}', '', '')) + + +def hardware_label_qr(url: str) -> str: + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4) + qr.add_data(url) + qr.make(fit=True) + return qr.make_image(image_factory=SvgPathImage).to_string().decode('utf-8') diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index bd19571..b5bd346 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -259,6 +259,7 @@
+ Label diff --git a/app/modules/hardware/templates/index.html b/app/modules/hardware/templates/index.html index 879265e..50f56f6 100644 --- a/app/modules/hardware/templates/index.html +++ b/app/modules/hardware/templates/index.html @@ -394,6 +394,7 @@
@@ -447,6 +448,7 @@ 👁️ Se ✏️ Rediger + Label {% endfor %} diff --git a/app/modules/hardware/templates/label.html b/app/modules/hardware/templates/label.html new file mode 100644 index 0000000..15c330d --- /dev/null +++ b/app/modules/hardware/templates/label.html @@ -0,0 +1,34 @@ + + + + +Label · BMC-{{ '%05d'|format(hardware.id) }} + + + +
+

Label til jeres hardware

Print i 100 % størrelse uden sidehoved og sidefod. QR-koden åbner hardwarekortet i Hub.

+
+
+
Brug en adresse, telefonen kan åbne.
+
Tilbage til delen
+
+

QR-link: {{ target_url }}

+
+
+
+
BMC NETWORKS · UDSTYR
{{ hardware.brand or '' }} {{ hardware.model or 'Hardware' }}
BMC-{{ '%05d'|format(hardware.id) }}
{% if hardware.serial_number %}
S/N {{ hardware.serial_number }}
{% endif %}
+
{{ qr_svg|safe }}Scan for hardwarekort
+
+
+ + diff --git a/app/modules/remote/__init__.py b/app/modules/remote/__init__.py new file mode 100644 index 0000000..326c70d --- /dev/null +++ b/app/modules/remote/__init__.py @@ -0,0 +1 @@ +"""Restricted mobile remote control for BMC Hub.""" diff --git a/app/modules/remote/manage.html b/app/modules/remote/manage.html new file mode 100644 index 0000000..027dfdd --- /dev/null +++ b/app/modules/remote/manage.html @@ -0,0 +1,6 @@ +Hub · Mine fjernbetjeninger +
BMC HUB

Mine fjernbetjeninger

Til Hub ↗
+

Din daglige PIN

Samme PIN bruges på alle dine enheder. Ændring kræver ny oplåsning på enhederne.

+

Opret fjernbetjening

+

Fjernbetjeninger

Forbundne enheder

+
diff --git a/app/modules/remote/mobile.html b/app/modules/remote/mobile.html new file mode 100644 index 0000000..901e263 --- /dev/null +++ b/app/modules/remote/mobile.html @@ -0,0 +1,31 @@ + + + +Hub · Fjernbetjening +
BMC HUB

Hub RemoteDin arbejdsdag. Lige ved hånden.

Forbinder…
+ + + + +

Skal timeren også skifte?

Den nuværende timer pauses, hvis du skifter timer.

+
diff --git a/app/modules/remote/router.py b/app/modules/remote/router.py new file mode 100644 index 0000000..aba4f6a --- /dev/null +++ b/app/modules/remote/router.py @@ -0,0 +1,298 @@ +"""Remote API and mobile/administration pages in the existing Hub.""" +import re +import secrets +from pathlib import Path +from uuid import UUID, uuid4 +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query +from fastapi.responses import HTMLResponse +from pydantic import BaseModel, Field, ConfigDict +from app.core.auth_dependencies import get_current_user +from app.core.auth_service import AuthService +from app.core.database import atomic_transaction, execute_query, execute_query_single +from . import service +from .security import COOKIE, CSRF_COOKIE, guard, today, digest, lookup, new_code, reserve_attempt, remote_device, actor + +router = APIRouter(prefix='/api/v1/remote', dependencies=[Depends(guard)]) +views = APIRouter(dependencies=[Depends(guard)]) +ROOT = Path(__file__).parent + + +class Credentials(BaseModel): + code: str = Field(default='', max_length=200) + pin: str = Field(max_length=200) + name: str = Field(default='Mobil / tablet', min_length=1, max_length=100) + + +class Pin(BaseModel): + pin: str = Field(pattern=r'^[0-9]{4}$') + + +class Name(BaseModel): + name: str = Field(min_length=1, max_length=100) + + +class Command(BaseModel): + model_config = ConfigDict(extra='forbid') + request_id: UUID + version: int = Field(ge=0) + timer_version: str = Field(pattern=r'^[0-9a-f]{64}$') + action: Literal['select', 'start', 'pause', 'resume', 'stop', 'manual', 'read', 'ack'] + case_id: int | None = Field(default=None, gt=0) + time_id: int | None = Field(default=None, gt=0) + description: str = Field(default='', max_length=2000) + minutes: int | None = Field(default=None, ge=1, le=1440) + switch_timer: bool | None = None + partner_user_id: int | None = Field(default=None, gt=0) + message_id: int | None = Field(default=None, gt=0) + + +class Follow(BaseModel): + tab_id: UUID + enable: bool = False + disable: bool = False + after: int = Field(default=0, ge=0) + + +class PhoneCall(BaseModel): + model_config = ConfigDict(extra='forbid') + request_id: UUID + contact_id: int = Field(gt=0) + field: Literal['phone', 'mobile'] + + +@router.get('/contacts') +async def remote_contacts(request: Request, q: str = Query('', max_length=100), offset: int = Query(0, ge=0)): + from . import telephony + return await telephony.contacts(actor(remote_device(request)), q, offset) + + +@router.get('/day') +def remote_day(request: Request): + return service.planned_day(actor(remote_device(request))) + + +@router.get('/telephony') +def remote_telephony(request: Request): + from . import telephony + return telephony.status(actor(remote_device(request))) + + +@router.get('/call/{request_id}') +def remote_call_result(request_id: UUID, request: Request): + from . import telephony + user = actor(remote_device(request)); telephony.require_access(user) + row = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id))) + if not row or row['payload'].get('action') != 'call': + return {'status': 'unknown', 'message': 'Hub har ikke registreret opkaldet. Kontrollér telefonen før et nyt opkald.'} + return row['result'] + + +@router.post('/call') +async def remote_call(payload: PhoneCall, request: Request): + from . import telephony + return await telephony.call(payload, request) + + +def authenticated(request: Request): + return actor(remote_device(request)) + + +def lock_user(uid): + execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,)) + + +@router.get('/bootstrap') +def bootstrap(request: Request, response: Response): + csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32) + response.set_cookie(CSRF_COOKIE, csrf, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/', max_age=30*86400) + status = 'connect' + try: + device = remote_device(request, unlocked=False) + status = 'ready' if device['unlocked_date'] == today() and device['pin_generation'] == device['generation'] else 'locked' + except HTTPException: + pass + return {'csrf': csrf, 'status': status} + + +@router.post('/connect') +def connect(payload: Credentials, request: Request, response: Response): + remote = execute_query_single('SELECT * FROM remote_controls WHERE code_lookup=%s', (lookup(payload.code),)) + reserve_attempt(request.client.host if request.client else 'unknown', (remote or {}).get('user_id')) + if not re.fullmatch(r'[0-9]{12}', payload.code) or not re.fullmatch(r'[0-9]{4}', payload.pin) or not remote: + raise HTTPException(401, 'Forkert kode eller PIN') + with atomic_transaction(): + lock_user(remote['user_id']) + remote = execute_query_single('SELECT r.*,u.is_active FROM remote_controls r JOIN users u ON u.user_id=r.user_id WHERE r.id=%s AND NOT r.revoked', (str(remote['id']),)) + pin = execute_query_single('SELECT * FROM remote_pins WHERE user_id=%s', (remote['user_id'],)) if remote else None + if not remote or not remote['is_active'] or not pin or not AuthService.verify_password(payload.code, remote['code_hash']) or not AuthService.verify_password(lookup('pin:' + payload.pin), pin['pin_hash']): + raise HTTPException(401, 'Forkert kode eller PIN') + token = secrets.token_urlsafe(32) + execute_query('''INSERT INTO remote_devices(id,remote_id,name,token_hash,expires_at,unlocked_date,pin_generation) + VALUES(%s,%s,%s,%s,NOW()+INTERVAL '30 days',%s,%s)''', + (str(uuid4()), str(remote['id']), payload.name.strip() or 'Mobil / tablet', digest(token), today(), pin['generation'])) + response.set_cookie(COOKIE, token, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/api/v1/remote', max_age=30*86400) + return {'ok': True} + + +@router.post('/unlock') +def unlock(payload: Credentials, request: Request): + device = remote_device(request, unlocked=False) + reserve_attempt(request.client.host if request.client else 'unknown', device['user_id'], device['id']) + with atomic_transaction(): + lock_user(device['user_id']) + device = remote_device(request, unlocked=False) + if not re.fullmatch(r'[0-9]{4}', payload.pin) or not AuthService.verify_password(lookup('pin:' + payload.pin), device['pin_hash']): + raise HTTPException(401, 'Forkert PIN') + execute_query('UPDATE remote_devices SET unlocked_date=%s,pin_generation=%s,last_seen_at=NOW() WHERE id=%s', (today(), device['generation'], str(device['id']))) + return {'ok': True} + + +@router.post('/logout') +def logout(request: Request, response: Response): + device = remote_device(request, unlocked=False) + with atomic_transaction(): + lock_user(device['user_id']) + execute_query('UPDATE remote_devices SET revoked=TRUE WHERE id=%s', (str(device['id']),)) + response.delete_cookie(COOKIE, path='/api/v1/remote', secure=request.url.scheme == 'https', httponly=True, samesite='strict') + return {'ok': True} + + +@router.get('/state') +def get_state(request: Request): + device = remote_device(request) + with atomic_transaction(): + lock_user(device['user_id']) + device = remote_device(request) + execute_query('UPDATE remote_devices SET last_seen_at=NOW() WHERE id=%s', (str(device['id']),)) + return service.state(actor(device)) + + +@router.get('/cases') +def list_cases(kind: Literal['new', 'own', 'search'] = 'search', q: str = Query('', max_length=100), + offset: int = Query(0, ge=0), user=Depends(authenticated)): + return service.cases(user, kind, q, offset) + + +@router.post('/command') +async def run_command(payload: Command, request: Request, user=Depends(authenticated)): + return await service.command(payload, user, request=request) + + +@router.get('/command-result/{request_id}') +def command_result(request_id: UUID, user=Depends(authenticated)): + result = execute_query_single('SELECT result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id))) + return {'completed': bool(result), 'result': result['result'] if result else None} + + +@router.get('/admin') +def admin(user=Depends(get_current_user)): + uid = user['id'] + remotes = execute_query('SELECT id,name,revoked,created_at FROM remote_controls WHERE user_id=%s ORDER BY created_at DESC', (uid,)) + devices = execute_query('''SELECT d.id,d.remote_id,d.name,d.expires_at,d.revoked,d.last_seen_at FROM remote_devices d + JOIN remote_controls r ON r.id=d.remote_id WHERE r.user_id=%s ORDER BY d.last_seen_at DESC''', (uid,)) + return {'remotes': remotes, 'devices': devices, 'pin_set': bool(execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)))} + + +@router.put('/admin/pin') +def set_pin(payload: Pin, user=Depends(get_current_user)): + hashed = AuthService.hash_password(lookup('pin:' + payload.pin)) + with atomic_transaction(): + lock_user(user['id']) + execute_query('''INSERT INTO remote_pins(user_id,pin_hash) VALUES(%s,%s) + ON CONFLICT(user_id) DO UPDATE SET pin_hash=EXCLUDED.pin_hash,generation=remote_pins.generation+1''', (user['id'], hashed)) + return {'ok': True} + + +def create_code(uid, name, remote_id=None): + with atomic_transaction(): + lock_user(uid) + if remote_id and not execute_query_single('SELECT id FROM remote_controls WHERE id=%s AND user_id=%s AND NOT revoked', (str(remote_id), uid)): + raise HTTPException(404, 'Fjernbetjeningen findes ikke') + if not execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)): + raise HTTPException(409, 'Opret din PIN først') + # A global advisory lock prevents even extremely unlikely code collisions. + execute_query('SELECT pg_advisory_xact_lock(1069,1)') + code = new_code() + while execute_query_single('SELECT id FROM remote_controls WHERE code_lookup=%s', (lookup(code),)): + code = new_code() + if remote_id: + execute_query('UPDATE remote_controls SET code_lookup=%s,code_hash=%s WHERE id=%s', (lookup(code), AuthService.hash_password(code), str(remote_id))) + execute_query('UPDATE remote_devices SET revoked=TRUE WHERE remote_id=%s', (str(remote_id),)) + else: + remote_id = uuid4() + execute_query('INSERT INTO remote_controls(id,user_id,name,code_lookup,code_hash) VALUES(%s,%s,%s,%s,%s)', (str(remote_id), uid, name, lookup(code), AuthService.hash_password(code))) + return {'id': str(remote_id), 'code': code} + + +@router.post('/admin/remotes') +def create_remote(payload: Name, user=Depends(get_current_user)): + return create_code(user['id'], payload.name.strip() or 'Fjernbetjening') + + +@router.post('/admin/remotes/{remote_id}/rotate') +def rotate_remote(remote_id: UUID, user=Depends(get_current_user)): + return create_code(user['id'], '', remote_id) + + +@router.delete('/admin/remotes/{remote_id}') +def revoke_remote(remote_id: UUID, user=Depends(get_current_user)): + with atomic_transaction(): + lock_user(user['id']) + rows = execute_query('UPDATE remote_controls SET revoked=TRUE WHERE id=%s AND user_id=%s RETURNING id', (str(remote_id), user['id'])) + if not rows: + raise HTTPException(404, 'Fjernbetjeningen findes ikke') + return {'ok': True} + + +@router.delete('/admin/devices/{device_id}') +def revoke_device(device_id: UUID, user=Depends(get_current_user)): + with atomic_transaction(): + lock_user(user['id']) + rows = execute_query('''UPDATE remote_devices d SET revoked=TRUE FROM remote_controls r + WHERE d.remote_id=r.id AND d.id=%s AND r.user_id=%s RETURNING d.id''', (str(device_id), user['id'])) + if not rows: + raise HTTPException(404, 'Enheden findes ikke') + return {'ok': True} + + +@router.post('/follow') +def follow(payload: Follow, user=Depends(get_current_user)): + with atomic_transaction(): + lock_user(user['id']) + s = service.user_state(user['id']) + if payload.enable: + execute_query('''UPDATE remote_user_state SET follower_id=%s,follower_seen_at=NOW(), + selection_target=NULL,selection_expires_at=NULL WHERE user_id=%s''', (str(payload.tab_id), user['id'])) + return {'following': True, 'sequence': s['selection_seq']} + if str(s['follower_id']) != str(payload.tab_id): + return {'following': False, 'sequence': s['selection_seq']} + if payload.disable: + execute_query('UPDATE remote_user_state SET follower_id=NULL,follower_seen_at=NULL,selection_target=NULL WHERE user_id=%s', (user['id'],)) + return {'following': False, 'sequence': s['selection_seq']} + # A missed heartbeat must not deliver a historical selection on reconnect. + connected = bool(s['connected']) + execute_query('UPDATE remote_user_state SET follower_seen_at=NOW() WHERE user_id=%s', (user['id'],)) + event = None + if connected and str(s['selection_target']) == str(payload.tab_id) and s['selection_seq'] > payload.after and s['selection_expires_at'] and s['selection_expires_at'] > service.datetime.now(service.timezone.utc): + service.case(s['active_case_id'], user) + event = {'case_id': s['active_case_id']} + return {'following': True, 'sequence': s['selection_seq'], 'selection': event} + + +@views.get('/remote', response_class=HTMLResponse) +def remote_page(): + return HTMLResponse((ROOT / 'mobile.html').read_text(), headers={'Cache-Control': 'no-store'}) + + +@views.get('/remote/manage', response_class=HTMLResponse) +def manage_page(user=Depends(get_current_user)): + return HTMLResponse((ROOT / 'manage.html').read_text(), headers={'Cache-Control': 'no-store'}) + + +@views.get('/remote/manifest.webmanifest') +def manifest(): + from fastapi.responses import JSONResponse + return JSONResponse({'name': 'BMC Hub Fjernbetjening', 'short_name': 'Hub Remote', 'start_url': '/remote', + 'display': 'standalone', 'theme_color': '#0f4c75', 'background_color': '#f4f6f8'}, media_type='application/manifest+json') diff --git a/app/modules/remote/security.py b/app/modules/remote/security.py new file mode 100644 index 0000000..bcf7d63 --- /dev/null +++ b/app/modules/remote/security.py @@ -0,0 +1,92 @@ +"""Remote credentials are deliberately separate from normal Hub authentication.""" +import hashlib +import hmac +import math +import secrets +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from fastapi import HTTPException, Request, Response +from app.core.config import settings +from app.core.auth_service import AuthService +from app.core.database import atomic_transaction, execute_query, execute_query_single + +COOKIE = 'hub_remote_device' +CSRF_COOKIE = 'hub_remote_csrf' + + +def today(now=None): + return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo('Europe/Copenhagen')).date() + + +def digest(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def lookup(value): + return hmac.new(settings.REMOTE_LOOKUP_KEY.encode(), value.encode(), hashlib.sha256).hexdigest() + + +def new_code(): + return f'{secrets.randbelow(10**12):012d}' + + +def guard(request: Request, response: Response): + response.headers['Cache-Control'] = 'no-store' + if not settings.REMOTE_ENABLED: + raise HTTPException(404, 'Fjernbetjening er slået fra') + if len(settings.REMOTE_LOOKUP_KEY) < 32: + raise HTTPException(503, 'Fjernbetjening mangler sikker serverkonfiguration') + if request.method not in {'GET', 'HEAD', 'OPTIONS'}: + origin = str(request.base_url).rstrip('/') + if request.headers.get('origin') != origin: + raise HTTPException(403, 'Ugyldig oprindelse') + cookie = request.cookies.get(CSRF_COOKIE, '') + token = request.headers.get('x-remote-csrf', '') + if not cookie or not hmac.compare_digest(cookie, token): + raise HTTPException(403, 'Opdatér siden og prøv igen') + + +def reserve_attempt(ip, user_id=None, device_id=None): + """Commit even failed attempts, and serialize all buckets across worker processes.""" + buckets = sorted({lookup('ip:' + ip), *([lookup('user:' + str(user_id))] if user_id else []), + *([lookup('device:' + str(device_id))] if device_id else [])}) + blocked = None + with atomic_transaction(): + for bucket in buckets: + execute_query("INSERT INTO remote_attempts(bucket,next_allowed_at) VALUES(%s,'epoch') ON CONFLICT DO NOTHING", (bucket,)) + rows = execute_query('SELECT next_allowed_at FROM remote_attempts WHERE bucket=ANY(%s) ORDER BY bucket FOR UPDATE', (buckets,)) + now = execute_query_single('SELECT clock_timestamp() AS now')['now'] + wait = max([(row['next_allowed_at'] - now).total_seconds() for row in rows] + [0]) + if wait > 0: + blocked = max(1, math.ceil(wait)) + else: + execute_query("UPDATE remote_attempts SET next_allowed_at=clock_timestamp()+INTERVAL '30 seconds' WHERE bucket=ANY(%s)", (buckets,)) + if blocked: + raise HTTPException(429, 'Vent før næste loginforsøg', headers={'Retry-After': str(blocked)}) + + +def remote_device(request, unlocked=True): + token = request.cookies.get(COOKIE) + if not token: + raise HTTPException(401, 'Forbind enheden med din kode') + row = execute_query_single('''SELECT d.*,r.user_id,p.generation,p.pin_hash,u.username,u.full_name,u.is_superadmin + FROM remote_devices d JOIN remote_controls r ON r.id=d.remote_id + JOIN remote_pins p ON p.user_id=r.user_id JOIN users u ON u.user_id=r.user_id + WHERE d.token_hash=%s AND NOT d.revoked AND NOT r.revoked AND d.expires_at>NOW() AND u.is_active=TRUE''', (digest(token),)) + if not row: + raise HTTPException(401, 'Forbindelsen er udløbet eller tilbagekaldt') + if unlocked and (row['unlocked_date'] != today() or row['pin_generation'] != row['generation']): + raise HTTPException(423, 'Indtast dagens PIN') + return row + + +def actor(device): + # Current permissions are fetched for every request; no cached admin JWT is minted. + return {'id': device['user_id'], 'username': device['username'], 'full_name': device['full_name'], + 'is_superadmin': device['is_superadmin'], 'permissions': AuthService.get_user_permissions(device['user_id'])} + + +def case_permission(user): + if not user.get('is_superadmin') and not {'cases.view', 'tickets.view'}.intersection(user.get('permissions') or []): + raise HTTPException(403, 'Du har ikke adgang til sager') diff --git a/app/modules/remote/service.py b/app/modules/remote/service.py new file mode 100644 index 0000000..1d16cee --- /dev/null +++ b/app/modules/remote/service.py @@ -0,0 +1,195 @@ +"""Restricted remote actions reuse existing time and messaging business logic.""" +from datetime import datetime, timezone +from uuid import UUID +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from psycopg2.extras import Json +from app.core.database import atomic_transaction, execute_query, execute_query_single +from app.modules.bottom_bar.backend.service import get_own_timer_snapshot, get_user_messages_summary, mark_user_messages_read, acknowledge_message +from app.modules.sag.backend.create_support import closed_statuses +from .security import case_permission, today + + +def user_state(user_id): + execute_query('INSERT INTO remote_user_state(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (user_id,)) + return execute_query_single('SELECT *,follower_seen_at>NOW()-INTERVAL \'10 seconds\' AS connected FROM remote_user_state WHERE user_id=%s', (user_id,)) + + +def case(case_id, user): + case_permission(user) + row = execute_query_single('SELECT id,titel,status,deadline,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (case_id,)) + if not row: + raise HTTPException(404, 'Sagen findes ikke længere') + return case_display(row) + + +def case_display(row, now=None): + row = dict(row) + deadline = row.get('deadline') + if deadline: + # Existing case timestamps without offset use Hub's local Danish timezone. + from zoneinfo import ZoneInfo + if deadline.tzinfo is None: + deadline = deadline.replace(tzinfo=ZoneInfo('Europe/Copenhagen')) + seconds = int((deadline - (now or datetime.now(timezone.utc))).total_seconds()) + row.update(deadline=deadline.isoformat(), deadline_seconds=seconds, + deadline_state='overdue' if seconds < 0 else 'warning' if seconds <= 3600 else 'normal') + else: + row.update(deadline_seconds=None, deadline_state='none') + return row + + +def cases(user, kind='search', q='', offset=0): + case_permission(user) + where = 's.deleted_at IS NULL' + params = [] + if kind == 'new': + where += " AND LOWER(TRIM(s.status))='ny'" + elif kind == 'own': + where += ' AND s.ansvarlig_bruger_id=%s AND NOT (LOWER(TRIM(s.status))=ANY(%s))' + params.extend([user['id'], closed_statuses()]) + if q: + where += ' AND (s.titel ILIKE %s OR s.id::text=%s OR c.name ILIKE %s)' + params.extend(['%' + q + '%', q, '%' + q + '%']) + rows = execute_query(f'''SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name FROM sag_sager s + LEFT JOIN customers c ON c.id=s.customer_id WHERE {where} + ORDER BY s.deadline ASC NULLS LAST,s.created_at DESC,s.id DESC LIMIT 51 OFFSET %s''', tuple(params + [offset])) + return {'items': [case_display(row) for row in rows[:50]], 'has_more': len(rows) > 50} + + +def timer_version(user_id): + """Fingerprint changes made by desktop as well as remote, excluding elapsed seconds.""" + from .security import digest + rows = execute_query('''SELECT id,sag_id,aktiv_timer,start_tid,slut_tid,paused_at,pause_total_seconds,updated_at + FROM tmodule_times WHERE medarbejder_id=%s AND slut_tid IS NULL + AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id''', (user_id,)) + latest = execute_query_single('''SELECT id,updated_at FROM tmodule_times WHERE medarbejder_id=%s + ORDER BY updated_at DESC NULLS LAST,id DESC LIMIT 1''', (user_id,)) + import json + return digest(json.dumps(jsonable_encoder([rows, latest]), sort_keys=True)) + + +def planned_day(user, day=None): + from datetime import timedelta + from zoneinfo import ZoneInfo + case_permission(user) + day = day or today() + zone = ZoneInfo('Europe/Copenhagen') + start = datetime.combine(day, datetime.min.time(), tzinfo=zone) + end = datetime.combine(day + timedelta(days=1), datetime.min.time(), tzinfo=zone) + rows = execute_query("""SELECT a.id, a.sag_id AS case_id, a.starts_at, a.ends_at, + s.titel AS title, c.name AS customer_name, 'case' AS kind + FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id + LEFT JOIN customers c ON c.id=s.customer_id + WHERE a.user_id=%s AND a.deleted_at IS NULL AND s.deleted_at IS NULL + AND a.starts_at<%s AND a.ends_at>%s + UNION ALL + SELECT q.id, NULL AS case_id, q.starts_at, q.ends_at, q.description AS title, + COALESCE(c.name,'Intern tid') AS customer_name, 'standalone' AS kind + FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id + WHERE q.user_id=%s AND q.state='planned' AND q.starts_at<%s AND q.ends_at>%s + ORDER BY starts_at""", (user['id'], end, start, user['id'], end, start)) or [] + return {'date': day.isoformat(), 'items': rows} + + +def state(user): + s = user_state(user['id']) + selected = None + if s['active_case_id']: + try: + selected = case(s['active_case_id'], user) + except HTTPException as exc: + if exc.status_code != 404: + raise + total = execute_query_single('''SELECT COALESCE(SUM(faktisk_tid_min),0)::int AS minutes + FROM tmodule_times WHERE medarbejder_id=%s AND worked_date=%s + AND NOT aktiv_timer AND paused_at IS NULL''', (user['id'], today())) + return {'version': s['version'], 'timer_version': timer_version(user['id']), 'active_case': selected, + 'timer': get_own_timer_snapshot(user['id']), 'today_minutes': total['minutes'], + 'desktop_connected': bool(s['connected']), 'messages': get_user_messages_summary(user['id']), + 'server_time': datetime.now(timezone.utc).isoformat(), 'user_id': user['id'], + 'user_name': user['full_name'] or user['username']} + + +async def command(payload, user, request=None): + """Atomic idempotency + remote version + user lock shared with desktop timer writes.""" + import importlib + timers = importlib.import_module('app.timetracking.backend.router') + uid = user['id'] + value = payload.model_dump(mode='json') + with atomic_transaction(): + execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,)) + if request is not None: + from .security import remote_device, actor + user = actor(remote_device(request)) + s = user_state(uid) + old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id))) + if old: + if old['payload'] != value: + raise HTTPException(409, 'Handlingen har allerede andre oplysninger') + return old['result'] + if payload.version != s['version'] or payload.timer_version != timer_version(uid): + raise HTTPException(409, 'Hub er ændret. Opdatér og prøv igen') + action = payload.action + result = {'ok': True} + if action in {'select', 'start', 'manual'}: + if not payload.case_id: + raise HTTPException(400, 'Vælg en sag') + case(payload.case_id, user) + timer_data = {'sag_id': payload.case_id, 'beskrivelse': payload.description, + 'billing_mode': 'automatic'} + if action == 'select': + active = get_own_timer_snapshot(uid)['active'] + if active.get('active') and active.get('sag_id') != payload.case_id: + if payload.switch_timer is None: + raise HTTPException(409, 'Vælg om timeren også skal skifte') + if payload.switch_timer: + await timers.pause_live_timer_v1(current_user=user) + paused = execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s + AND sag_id=%s AND NOT aktiv_timer AND paused_at IS NOT NULL AND slut_tid IS NULL + ORDER BY paused_at DESC LIMIT 1''', (uid, payload.case_id)) + if paused: + await timers.resume_live_timer_v1(payload={'time_id': paused['id']}, current_user=user) + else: + await timers.start_live_timer_v1(payload=timer_data, current_user=user) + execute_query('''UPDATE remote_user_state SET active_case_id=%s,selection_seq=selection_seq+1, + selection_target=CASE WHEN follower_seen_at>NOW()-INTERVAL '10 seconds' THEN follower_id ELSE NULL END, + selection_expires_at=NOW()+INTERVAL '10 seconds' WHERE user_id=%s''', (payload.case_id, uid)) + elif action == 'start': + active = get_own_timer_snapshot(uid)['active'] + if active.get('active'): + raise HTTPException(409, 'En timer kører allerede. Brug sagsvalg for at skifte') + result = await timers.start_live_timer_v1(payload=timer_data, current_user=user) + elif action in {'pause', 'resume', 'stop'}: + if payload.time_id: + owned = execute_query_single('''SELECT id FROM tmodule_times WHERE id=%s AND medarbejder_id=%s + AND slut_tid IS NULL AND start_tid IS NOT NULL AND (aktiv_timer OR paused_at IS NOT NULL)''', (payload.time_id, uid)) + if not owned: + raise HTTPException(404, 'Timeren findes ikke eller er afsluttet') + if action == 'pause': + result = await timers.pause_live_timer_v1(current_user=user) + elif action == 'resume': + result = await timers.resume_live_timer_v1(payload={'time_id': payload.time_id}, current_user=user) + else: + if not payload.description.strip(): + raise HTTPException(400, 'Angiv beskrivelse før stop') + active = get_own_timer_snapshot(uid)['active'] + tid = payload.time_id or active.get('time_entry_id') + if not tid: + raise HTTPException(404, 'Ingen timer valgt') + execute_query('UPDATE tmodule_times SET description=%s WHERE id=%s AND medarbejder_id=%s AND slut_tid IS NULL', (payload.description, tid, uid)) + result = await timers.stop_live_timer_v1(payload={'time_id': tid}, current_user=user) + elif action == 'manual': + if not payload.description.strip() or not payload.minutes: + raise HTTPException(400, 'Angiv minutter og beskrivelse') + timer_data.update(faktisk_tid_min=payload.minutes, worked_date=today().isoformat()) + result = await timers.create_manual_time_v1(payload=timer_data, current_user=user) + elif action == 'read': + result = {'updated': mark_user_messages_read(uid, partner_user_id=payload.partner_user_id)} + elif action == 'ack': + if not payload.message_id or not acknowledge_message(uid, payload.message_id): + raise HTTPException(404, 'Beskeden findes ikke eller er allerede bekræftet') + execute_query('UPDATE remote_user_state SET version=version+1 WHERE user_id=%s', (uid,)) + result = jsonable_encoder({'ok': True, 'result': result, 'version': s['version'] + 1}) + execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)', (uid, str(payload.request_id), Json(value), Json(result))) + return result diff --git a/app/modules/remote/telephony.py b/app/modules/remote/telephony.py new file mode 100644 index 0000000..964f33d --- /dev/null +++ b/app/modules/remote/telephony.py @@ -0,0 +1,87 @@ +"""Contact lookup and restricted control of the owner's existing VoIP phone.""" +from fastapi import HTTPException +from psycopg2.extras import Json +from app.core.database import atomic_transaction, execute_query, execute_query_single +from app.dashboard.backend.mission_service import MissionService +from .security import actor, remote_device + + +def permitted(user): + return bool(user.get('is_superadmin') or 'customers.view' in (user.get('permissions') or [])) + + +def require_access(user): + if not permitted(user): + raise HTTPException(403, 'Du har ikke adgang til kontakter og telefoni') + + +async def contacts(user, q, offset): + require_access(user) + from app.contacts.backend.router_simple import get_contacts + data = await get_contacts(search=q, customer_id=None, is_active=True, limit=50, offset=offset) + fields = ('id', 'first_name', 'last_name', 'email', 'phone', 'mobile', 'company_names') + return {'items': [{key: row.get(key) for key in fields} for row in data['contacts']], + 'has_more': offset + len(data['contacts']) < data['total']} + + +def status(user): + if not permitted(user): + return {'allowed': False, 'incoming': []} + # Match Mission Control's ring window without changing shared call state. + rows = MissionService.get_active_calls() + from datetime import datetime, timezone + from zoneinfo import ZoneInfo + now = datetime.now(timezone.utc) + incoming = [] + for row in rows: + updated = row.get('updated_at') or row.get('started_at') + if not updated: + continue + if updated.tzinfo is None: + updated = updated.replace(tzinfo=ZoneInfo('Europe/Copenhagen')) + if (now - updated).total_seconds() > MissionService.get_ring_timeout_seconds(): + continue + incoming.append({key: row.get(key) for key in ('call_id', 'caller_number', 'contact_name', 'company_name', 'ringing_phones')}) + return {'allowed': True, 'incoming': incoming} + + +async def call(payload, request): + """Persist a reservation before external I/O; never automatically redial on uncertainty.""" + from app.modules.telefoni.backend.router import click_to_call + from app.modules.telefoni.backend.schemas import TelefoniClickToCallRequest + user = actor(remote_device(request)) + require_access(user) + uid = user['id'] + value = {'action': 'call', **payload.model_dump(mode='json')} + with atomic_transaction(): + execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,)) + user = actor(remote_device(request)); require_access(user) + old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id))) + if old: + if old['payload'] != value: + raise HTTPException(409, 'Handlingen har allerede andre oplysninger') + return old['result'] + contact = execute_query_single('SELECT id,phone,mobile FROM contacts WHERE id=%s AND is_active=TRUE', (payload.contact_id,)) + if not contact or not contact.get(payload.field): + raise HTTPException(404, 'Kontakten har ikke det valgte telefonnummer') + # Only stored phone numbers can reach the existing action URL template. + import re + number = str(contact[payload.field]).strip() + if not re.fullmatch(r'\+?[0-9][0-9 () .-]{1,39}', number): + raise HTTPException(400, 'Kontaktens telefonnummer skal rettes i Hub') + pending = {'status': 'uncertain', 'message': 'Opkald er afsendt eller under behandling. Kontrollér telefonen før et nyt opkald.'} + execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)', + (uid, str(payload.request_id), Json(value), Json(pending))) + request.state.user_id = uid + try: + await click_to_call(TelefoniClickToCallRequest(number=number, user_id=uid), request) + result = {'status': 'ok', 'message': 'Opkald startet på din VoIP-telefon'} + except HTTPException as exc: + # A timeout can mean that the phone received the command. Never redial it. + result = {'status': 'failed' if exc.status_code < 500 else 'uncertain', + 'message': 'Opkald kunne ikke startes. Kontrollér telefonopsætningen i Hub.' if exc.status_code < 500 else pending['message']} + except Exception: + result = pending + execute_query('UPDATE remote_commands SET result=%s WHERE user_id=%s AND request_id=%s', + (Json(result), uid, str(payload.request_id))) + return result diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index d580308..7701017 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -125,7 +125,7 @@ templates.env.filters["safe_case_html"] = sanitize_safe_html def _fetch_assignment_users(): return execute_query( """ - SELECT user_id, COALESCE(full_name, username) AS display_name + SELECT user_id, username, full_name, COALESCE(full_name, username) AS display_name FROM users ORDER BY display_name """, diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 27a8a18..bc5c948 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -3,6 +3,8 @@ {% block title %}Ny Sag - BMC Hub{% endblock %} {% block extra_css %} + + {% endblock %} {% block content %}
-
Automatisering

Tagklassifikation

Organisér tags, vedligehold nøgleord og gennemgå automatiske forslag.

Tagadministration
+
Automatisering

Tagklassifikation

Vælg et tag, tilføj de ord der skal finde det, og gem.

Tagadministration
{% include 'workflows/frontend/_nav.html' %} +
+

Hjælp systemet med at foreslå de rigtige tags

+

Her bestemmer du, hvilke ord systemet skal genkende i tekst på sager, tidsregistreringer og mails. Tags gør det lettere at finde og sortere arbejdet og kan indgå i workflows.

+
+
1. Find et tag
Søg til venstre, og vælg fx “Printer”. Tagget skal være oprettet i Tagadministration.
+
2. Tilføj nøgleord og gem
Skriv fx “printer, udskrivning, papir”. Ét af ordene er nok til at matche. Afprøv en tekst, og vælg “Gem ændringer”.
+
3. Gennemgå forslag
Under “Forslag” vælger du sag, tid eller mail og indtaster dens ID. Her kan du godkende eller afvise eksisterende forslag.
+
+
Eksempel og forskellen på tags og afregning +

Teksten “Printeren vil ikke udskrive” matcher nøgleordet “printer” og kan give tagget “Printer”. “Afprøv” tester kun teksten i feltet; den ændrer ikke en sag og opretter ikke et forslag.

+

Nøgleord bruges, når systemets klassifikation kører. At gemme her starter ikke en gennemgang af alle eksisterende sager, timer eller mails. Om et match tilføjes automatisk eller kræver gennemgang, afhænger af klassifikationsforløbet.

+

Denne side vælger ikke fastpris, klippekort eller faktura. Det styres af aftalernes dækning og workflowregler. “Kan påvirke afregning” markerer et tag som relevant for afregning; det opretter ikke en afregningsregel.

+
+
+
-
- -
Vælg et tag i oversigten

Overtagets regler kan omfatte alle undertags. Kun det konkrete tag behøver at være tildelt.

- - +
+ +
Vælg et tag i oversigten

Find et tag til venstre for at redigere dets nøgleord.

+ +
-
Forslag og historik

Vælg sag, tid eller mail og indtast dens ID. Godkend eller afvis forslag med en begrundelse.

Vælg et område og et ID for at se forslag.
Taghistorik
Vælg data ovenfor.
+
{% endblock %} {% block extra_js %} diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index 5e7a6e1..58b3c72 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Query from fastapi.responses import JSONResponse, Response from app.core.database import execute_query, execute_update, execute_query_single +from app.timetracking.backend.timer_transaction import serialized_timer from app.timetracking.backend.models import ( TModuleSyncStats, TModuleApprovalStats, @@ -2498,6 +2499,7 @@ async def list_time_entries_v1( @router.post("/time/start", tags=["Internal"]) +@serialized_timer async def start_live_timer_v1( payload: Dict[str, Any] = Body(...), current_user: Optional[dict] = Depends(get_optional_user) @@ -2606,6 +2608,7 @@ async def start_live_timer_v1( @router.post("/time/stop", tags=["Internal"]) +@serialized_timer async def stop_live_timer_v1( payload: Dict[str, Any] = Body(...), current_user: Optional[dict] = Depends(get_optional_user) @@ -2694,6 +2697,7 @@ async def stop_live_timer_v1( @router.post("/time/pause", tags=["Internal"]) +@serialized_timer async def pause_live_timer_v1( current_user: Optional[dict] = Depends(get_optional_user) ): @@ -2743,6 +2747,7 @@ async def pause_live_timer_v1( @router.post("/time/resume", tags=["Internal"]) +@serialized_timer async def resume_live_timer_v1( payload: Dict[str, Any] = Body(default={}), current_user: Optional[dict] = Depends(get_optional_user) @@ -2905,6 +2910,7 @@ async def list_my_switchable_timers_v1( LEFT JOIN users u ON u.user_id = t.medarbejder_id WHERE t.medarbejder_id = %s AND t.slut_tid IS NOT NULL + AND COALESCE(t.kilde, '') <> 'workflow_registered' AND t.aktiv_timer = FALSE AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending' @@ -2957,7 +2963,7 @@ async def list_team_timer_status_v1( LEFT JOIN users u ON u.user_id = t.medarbejder_id WHERE ( (t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL)) - OR (t.slut_tid IS NOT NULL AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending') + OR (t.slut_tid IS NOT NULL AND COALESCE(t.kilde, '') <> 'workflow_registered' AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending') ) {employee_filter} ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END, @@ -3062,6 +3068,7 @@ async def get_case_billing_options(sag_id: int): @router.post("/time/manual", tags=["Internal"]) +@serialized_timer async def create_manual_time_v1( payload: Dict[str, Any] = Body(...), current_user: Optional[dict] = Depends(get_optional_user) @@ -3332,6 +3339,49 @@ async def get_time_settlement_options_v1(time_id: int): return {**_settlement_options_for_case_entry(entry), 'workflow_active': workflow_active} +@router.post("/time/{time_id}/register", tags=["Internal"]) +async def register_workflow_timer_v1( + time_id: int, + payload: Dict[str, Any] = Body(default={}), + current_user: Optional[dict] = Depends(get_optional_user), +): + """Record a stopped workflow timer for review, without approval or settlement.""" + user_id = _resolve_current_user_id(current_user) + if not user_id: + raise HTTPException(401, "Authentication required") + entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,)) + if not entry: + raise HTTPException(404, "Time entry not found") + if int(entry.get('medarbejder_id') or 0) != int(user_id) and not _can_view_all_team_timers(current_user): + raise HTTPException(403, "Du kan kun registrere dine egne timere") + from app.core.config import settings + from app.workflows.integration import active_for_customer + from app.workflows.settlement import workflow_customer + if not (settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active' and active_for_customer(workflow_customer(time_id))): + raise HTTPException(409, "Brug den normale registrering for denne timer") + if entry.get('aktiv_timer') or not entry.get('slut_tid') or entry.get('status') != 'pending' or entry.get('economy_settled_at') or entry.get('economy_order_draft_id') or entry.get('billed_via_thehub_id'): + raise HTTPException(409, "Kun afsluttet, ikke godkendt tid kan registreres") + work_type = str(payload.get('work_type') or entry.get('work_type') or 'support') + if work_type not in {'support', 'troubleshooting', 'development', 'maintenance', 'on_site', 'meeting', 'other'}: + raise HTTPException(400, "Invalid work_type") + try: + minutes = int(payload.get('fakturerbar_tid_min', entry.get('fakturerbar_tid_min') or 0)) + except (ValueError, TypeError): + raise HTTPException(400, "Minutter skal være et helt tal") + if minutes <= 0: + raise HTTPException(400, "Angiv mindst ét minut") + updated = execute_query(""" + UPDATE tmodule_times SET work_type=%s, fakturerbar_tid_min=%s, + entry_type='manuel', kilde='workflow_registered', updated_at=NOW() + WHERE id=%s AND status='pending' AND aktiv_timer=FALSE AND slut_tid IS NOT NULL + AND economy_settled_at IS NULL AND economy_order_draft_id IS NULL + AND billed_via_thehub_id IS NULL RETURNING * + """, (work_type, minutes, time_id)) + if not updated: + raise HTTPException(409, "Timeren er ændret. Opdatér og prøv igen") + return updated[0] + + @router.post("/time/{time_id}/approve", tags=["Internal"]) async def approve_time_entry_v1( time_id: int, diff --git a/app/timetracking/backend/timer_transaction.py b/app/timetracking/backend/timer_transaction.py new file mode 100644 index 0000000..855c5b8 --- /dev/null +++ b/app/timetracking/backend/timer_transaction.py @@ -0,0 +1,35 @@ +"""Serialize opt-in remote/desktop timer writes using the same transaction helpers.""" +from contextlib import nullcontext +from functools import wraps + +from app.core.config import settings +from app.core.database import atomic_transaction, _transaction_connection, execute_query + + +def serialized_timer(function): + @wraps(function) + async def wrapped(*args, **kwargs): + # Leave legacy behavior unchanged while remote control is disabled. + if not settings.REMOTE_ENABLED: + return await function(*args, **kwargs) + import inspect + arguments = inspect.signature(function).bind_partial(*args, **kwargs).arguments + user = arguments.get('current_user') + if not isinstance(user, dict) or not user.get('id'): + return await function(*args, **kwargs) + context = nullcontext() if _transaction_connection.get() else atomic_transaction() + with context: + ids = {int(user['id'])} + payload = arguments.get('payload') or {} + if function.__name__ in {'start_live_timer_v1', 'create_manual_time_v1'}: + raw_ids = payload.get('medarbejder_ids') or [payload.get('medarbejder_id')] + if isinstance(raw_ids, list): + for uid in raw_ids: + try: + if uid is not None: + ids.add(int(uid)) + except (ValueError, TypeError): + pass # The existing handler supplies the validation error. + execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) ORDER BY user_id FOR UPDATE', (sorted(ids),)) + return await function(*args, **kwargs) + return wrapped diff --git a/app/workflows/frontend/builder.html b/app/workflows/frontend/builder.html index 7bb80db..e30f7d4 100644 --- a/app/workflows/frontend/builder.html +++ b/app/workflows/frontend/builder.html @@ -14,7 +14,7 @@

Versioner og test
-

Vælg en eksisterende sag eller tid ovenfor for at teste med de faktiske tags, aftaler og klippekort.

+

Vælg en eksisterende sag eller tid ovenfor for at teste med de faktiske tags, aftaler og klippekort.

Tekniske testdetaljer
Aktiveringshistorik
@@ -24,7 +24,7 @@ (()=>{'use strict'; const $=id=>document.getElementById('wf-'+id); let tagRows=[],customerRows=[],actionOptions={users:[],groups:[],workflows:[],agreements:[],cards:[]};let catalog={}, current=null, version=null, receipt=null, dirty=false; let definition={trigger:'time.created',stop_after_match:false,branches:[{condition:{all:[{field:'description',operator:'contains',value:''}]},actions:[{type:'set_billing',method:'invoice'}]}]}; -const labels={customer_id:'Kunde',case_id:'Sag',description:'Beskrivelse',hours:'Timer',worked_date:'Arbejdsdato',tags:'Tags',tag_ids:'Tags',status:'Status',billable:'Fakturerbar',entity_type:'Modul',sla_status:'SLA-status',priority:'Prioritet',subject:'Emne',has_active_agreement:'Aktiv fastprisaftale',has_active_card:'Aktivt klippekort',eq:'Er lig med',ne:'Er ikke lig med',contains:'Indeholder',not_contains:'Indeholder ikke',in:'Er en af',not_in:'Er ingen af',gt:'Større end',gte:'Mindst',lt:'Mindre end',lte:'Højst',exists:'Findes',all:'Alle betingelser (OG)',any:'Mindst én betingelse (ELLER)',low:'Lav',normal:'Normal',high:'Høj',urgent:'Haster','time.created':'Tid oprettet','time.updated':'Tid ændret','case.created':'Sag oprettet','case.updated':'Sag ændret','tags.changed':'Tags ændret','mail.received':'Mail modtaget','sla.changed':'SLA ændret','customer.updated':'Kunde ændret','task.updated':'Opgave ændret','reminder.due':'Reminder forfalden','notification.created':'Notifikation oprettet',send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'}; +const labels={customer_id:'Kunde',case_id:'Sags-ID',case_title:'Sagstitel',description:'Beskrivelse',hours:'Timer',worked_date:'Arbejdsdato',tags:'Tags',tag_ids:'Tags',status:'Status',billable:'Fakturerbar',entity_type:'Modul',sla_status:'SLA-status',priority:'Prioritet',subject:'Emne',has_active_agreement:'Aktiv fastprisaftale',has_active_card:'Aktivt klippekort',eq:'Er lig med',ne:'Er ikke lig med',contains:'Indeholder',not_contains:'Indeholder ikke',in:'Er en af',not_in:'Er ingen af',gt:'Større end',gte:'Mindst',lt:'Mindre end',lte:'Højst',exists:'Findes',all:'Alle betingelser (OG)',any:'Mindst én betingelse (ELLER)',low:'Lav',normal:'Normal',high:'Høj',urgent:'Haster','time.created':'Tid oprettet','time.updated':'Tid ændret','case.created':'Sag oprettet','case.updated':'Sag ændret','tags.changed':'Tags ændret','mail.received':'Mail modtaget','sla.changed':'SLA ændret','customer.updated':'Kunde ændret','task.updated':'Opgave ændret','reminder.due':'Reminder forfalden','notification.created':'Notifikation oprettet',send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'}; function message(text,error=false){$('message').textContent=text;$('message').className='alert '+(error?'alert-danger':'alert-info');} async function api(path='',body){const response=await fetch('/api/v1/workflows'+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await response.json();if(!response.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;} function changed(){dirty=true;receipt=null;$('activate').disabled=true;} @@ -32,7 +32,7 @@ function element(tag,className,text){const el=document.createElement(tag);if(cla function select(values,value,onchange){const el=element('select','form-select form-select-sm');values.forEach(v=>{const opt=element('option','',labels[v]||v);opt.value=v;el.append(opt);});el.value=value;el.onchange=()=>{onchange(el.value);changed();};return el;} function input(value,onchange){const el=element('input','form-control form-control-sm');el.value=typeof value==='string'?value:JSON.stringify(value??'');el.onchange=()=>{let v=el.value;try{v=JSON.parse(v);}catch{}onchange(v);changed();};return el;} function button(text,fn){const el=element('button','btn btn-sm btn-outline-secondary',text);el.type='button';el.onclick=()=>{fn();changed();render();};return el;} -function namedSelect(rows,value,onchange,multiple=false){const el=element('select','form-select form-select-sm');el.multiple=multiple;if(!multiple){const placeholder=element('option','','Vælg…');placeholder.value='';el.append(placeholder);}for(const row of rows){const opt=element('option','',row.label||row.name);opt.value=String(row.id);opt.selected=(Array.isArray(value)?value.map(String):[String(value)]).includes(String(row.id));el.append(opt);}el.onchange=()=>{onchange(multiple?[...el.selectedOptions].map(o=>Number(o.value)):(el.value?Number(el.value):null));changed();};return el;}function conditionValue(node){if(['tags','tag_ids'].includes(node.field)){const box=element('div','');box.append(namedSelect(tagRows,node.value,v=>node.value=v,['in','not_in'].includes(node.operator)));const label=element('label','small',' Medtag undertags');const check=element('input');check.type='checkbox';check.checked=!!node.include_descendants;check.onchange=()=>{node.include_descendants=check.checked;changed();};label.prepend(check);box.append(label);return box;}if(node.field==='customer_id')return namedSelect(customerRows,node.value,v=>node.value=v===null?null:Array.isArray(v)?v.map(String):String(v),['in','not_in'].includes(node.operator));return input(node.value,v=>node.value=v);}function readableCondition(node,host,depth=0){if(!node)return;const names={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',worked_date:'Arbejdsdato',status:'Status'},operators={eq:'er',ne:'er ikke',contains:'indeholder',not_contains:'indeholder ikke',in:'er en af',not_in:'er ingen af',gt:'større end',gte:'mindst',lt:'mindre end',lte:'højst',exists:'findes'};let text;if(node.children)text=node.kind==='all'?'Alle betingelser':'Mindst én betingelse';else if(node.kind==='else')text='Ellers';else{const values=Array.isArray(node.expected)?node.expected:[node.expected];const expected=values.map(v=>['tags','tag_ids'].includes(node.field)?tagRows.find(t=>Number(t.id)===Number(v))?.label||v:node.field==='customer_id'?customerRows.find(c=>String(c.id)===String(v))?.name||v:v).join(', ');text=(names[node.field]||node.field)+' '+(operators[node.operator]||node.operator)+' '+expected;}const line=element('div','small',`${node.matched?'✓':'×'} ${text}`);line.style.paddingLeft=(depth*16)+'px';host.append(line);for(const child of node.children||[])readableCondition(child,host,depth+1);}function leaf(){return {field:'description',operator:'contains',value:''};} +function namedSelect(rows,value,onchange,multiple=false){const el=element('select','form-select form-select-sm');el.multiple=multiple;if(!multiple){const placeholder=element('option','','Vælg…');placeholder.value='';el.append(placeholder);}for(const row of rows){const opt=element('option','',row.label||row.name);opt.value=String(row.id);opt.selected=(Array.isArray(value)?value.map(String):[String(value)]).includes(String(row.id));el.append(opt);}el.onchange=()=>{onchange(multiple?[...el.selectedOptions].map(o=>Number(o.value)):(el.value?Number(el.value):null));changed();};return el;}function conditionValue(node){if(['tags','tag_ids'].includes(node.field)){const box=element('div','');box.append(namedSelect(tagRows,node.value,v=>node.value=v,['in','not_in'].includes(node.operator)));const label=element('label','small',' Medtag undertags');const check=element('input');check.type='checkbox';check.checked=!!node.include_descendants;check.onchange=()=>{node.include_descendants=check.checked;changed();};label.prepend(check);box.append(label);return box;}if(node.field==='customer_id')return namedSelect(customerRows,node.value,v=>node.value=v===null?null:Array.isArray(v)?v.map(String):String(v),['in','not_in'].includes(node.operator));return input(node.value,v=>node.value=v);}function readableCondition(node,host,depth=0){if(!node)return;const names={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',worked_date:'Arbejdsdato',status:'Status',case_title:'Sagstitel',case_id:'Sags-ID'},operators={eq:'er',ne:'er ikke',contains:'indeholder',not_contains:'indeholder ikke',in:'er en af',not_in:'er ingen af',gt:'større end',gte:'mindst',lt:'mindre end',lte:'højst',exists:'findes'};let text;if(node.children)text=node.kind==='all'?'Alle betingelser':'Mindst én betingelse';else if(node.kind==='else')text='Ellers';else{const values=Array.isArray(node.expected)?node.expected:[node.expected];const expected=values.map(v=>['tags','tag_ids'].includes(node.field)?tagRows.find(t=>Number(t.id)===Number(v))?.label||v:node.field==='customer_id'?customerRows.find(c=>String(c.id)===String(v))?.name||v:v).join(', ');text=(names[node.field]||node.field)+' '+(operators[node.operator]||node.operator)+' '+expected+' · Testværdi: '+(node.actual==null?'mangler':Array.isArray(node.actual)?node.actual.join(', '):String(node.actual));}const line=element('div','small',`${node.matched?'✓':'×'} ${text}`);line.style.paddingLeft=(depth*16)+'px';host.append(line);for(const child of node.children||[])readableCondition(child,host,depth+1);}function leaf(){return {field:'description',operator:'contains',value:''};} function condition(node,replace,remove){const box=element('div','border rounded p-2 my-1');if(node.all||node.any){const key=node.all?'all':'any';box.append(select(['all','any'],key,v=>{node[v]=node[key];if(v!==key)delete node[key];render();}));node[key].forEach((child,i)=>box.append(condition(child,v=>node[key][i]=v,()=>node[key].splice(i,1))));const controls=element('div','d-flex gap-1');controls.append(button('+ Betingelse',()=>node[key].push(leaf())),button('+ Gruppe',()=>node[key].push({all:[leaf()]})));if(remove)controls.append(button('Fjern gruppe',remove));box.append(controls);}else{const row=element('div','d-flex gap-1');row.append(select(catalog.fields,node.field,v=>{node.field=v;if(['tags','tag_ids'].includes(v))node.operator='contains';render();}),select(['tags','tag_ids'].includes(node.field)?['contains','not_contains','exists']:catalog.operators,node.operator,v=>{node.operator=v;render();}),conditionValue(node),button('Gruppér',()=>replace({all:[node]})));if(remove)row.append(button('Fjern',remove));box.append(row);}return box;} function render(){const root=$('branches');root.replaceChildren();definition.branches.forEach((branch,i)=>{const box=element('section','border rounded p-2 mb-2');box.append(element('strong','',branch.condition===null?'Ellers':i===0?'Hvis':'Ellers hvis'));if(branch.condition)box.append(condition(branch.condition,v=>branch.condition=v));branch.actions.forEach((action,j)=>{const row=element('div','d-flex gap-1 my-1');row.append(select(catalog.actions,action.type,v=>{branch.actions[j]=v==='set_billing'?{type:v,method:'invoice'}:v==='set_field'?{type:v,field:'priority',value:'normal'}:{type:v};render();}));if(action.type==='set_billing'){row.append(select(catalog.billing_methods,action.method,v=>action.method=v));if(action.method==='subscription'||action.method==='prepaid'){const key=action.method==='subscription'?'agreement_id':'card_id',pool=action.method==='subscription'?actionOptions.agreements:actionOptions.cards;const scoped=$('customer').value?pool.filter(r=>String(r.customer_id)===$('customer').value):pool;row.append(namedSelect(scoped,action[key],v=>{if(v===null)delete action[key];else action[key]=v;}));}if(action.method==='invoice'){const product=input(action.product_number||'',v=>{if(v==='')delete action.product_number;else action.product_number=v;});product.placeholder='Varenummer (valgfrit)';row.append(product);}}else if(['add_tag','remove_tag','suggest_tag'].includes(action.type)){row.append(namedSelect(tagRows,action.tag_id,v=>action.tag_id=v));}else if(action.type==='set_field'){row.append(select(['priority','status'],action.field||'priority',v=>action.field=v),input(action.value,v=>action.value=v));}else if(['assign_user','assign_group','start_workflow'].includes(action.type)){const key={assign_user:'user_id',assign_group:'group_id',start_workflow:'workflow_id'}[action.type];const pool={assign_user:actionOptions.users,assign_group:actionOptions.groups,start_workflow:actionOptions.workflows}[action.type];row.append(namedSelect(pool,action[key],v=>action[key]=v));}else if(action.type==='send_to_review'){const reason=input(action.reason||'',v=>action.reason=v);reason.placeholder='Begrundelse (valgfri)';row.append(reason);}else{const title=input(action.title||'',v=>action.title=v);title.placeholder='Titel';row.append(title);if(['notify','create_reminder'].includes(action.type)){row.append(namedSelect(actionOptions.users,action.recipient_user_ids||[],v=>action.recipient_user_ids=v,true));}if(action.type==='create_reminder'){const due=element('input','form-control form-control-sm');due.type='datetime-local';if(action.scheduled_at){const date=new Date(action.scheduled_at);if(!Number.isNaN(date.getTime()))due.value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);}due.onchange=()=>{if(due.value)action.scheduled_at=new Date(due.value).toISOString();else delete action.scheduled_at;changed();};row.append(due);}}row.append(button('Fjern handling',()=>branch.actions.splice(j,1)));box.append(row);});box.append(button('+ Handling',()=>branch.actions.push({type:'set_billing',method:'invoice'})));if(definition.branches.length>1)box.append(button('Fjern gren',()=>definition.branches.splice(i,1)));root.append(box);});} function loadVersion(v){version=v;definition=structuredClone(v.definition);const meta=definition.metadata||{};$('name').value=meta.name||current.name;$('customer').value=meta.customer_id??current.customer_id??'';$('priority').value=meta.priority??current.priority;$('trigger').value=definition.trigger;$('stop').checked=!!definition.stop_after_match;dirty=false;receipt=null;$('activate').disabled=true;render();} @@ -45,7 +45,7 @@ $('add-branch').onclick=()=>{const index=definition.branches.findIndex(b=>b.cond $('add-else').onclick=()=>{if(!definition.branches.some(b=>b.condition===null))definition.branches.push({condition:null,actions:[{type:'set_billing',method:'invoice'}]});changed();render();}; $('save').onclick=()=>guard(async()=>{definition.trigger=$('trigger').value;definition.stop_after_match=$('stop').checked;const draft={name:$('name').value,customer_id:$('customer').value||null,priority:Number($('priority').value),definition};const saved=await api(current?'/'+current.id+'/versions':'',draft);await list();await open(current?current.id:saved.id);message('Ny version gemt. Test den før aktivering.');}); $('versions').onchange=()=>loadVersion(current.versions.find(v=>v.id===Number($('versions').value))); -$('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændringerne som en ny version før test');const entity=$('entity').value;const payload={version_id:version.id};if(entity){payload.entity_type=entity;payload.entity_id=Number($('entity-id').value);}else payload.context={customer_id:$('test-customer').value||null,description:$('test-description').value,hours:Number($('test-hours').value),tag_ids:[]};const data=await api('/'+current.id+'/simulate',payload);receipt=data.simulation_receipt;$('test-explanation').replaceChildren();const summary=element('p','',data.result.status==='decided'?'Afregning: '+(labels[data.result.method]||data.result.method):'Afventer manuel vurdering');$('test-explanation').append(summary);for(const trace of data.result.trace||[]){$('test-explanation').append(element('div','small fw-semibold',`Workflow #${trace.workflow_id} · gren ${trace.branch+1}: ${trace.matched?'opfyldt':'ikke opfyldt'}`));readableCondition(trace.condition,$('test-explanation'));}$('result').textContent=JSON.stringify(data.result,null,2);$('activate').disabled=false;}); +$('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændringerne som en ny version før test');const entity=$('entity').value;if(!entity&&$('entity-id').value)throw Error('Du har indtastet et ID. Vælg Sag eller Tidsregistrering under Eksisterende data, så testen bruger de faktiske oplysninger.');const payload={version_id:version.id};if(entity){payload.entity_type=entity;payload.entity_id=Number($('entity-id').value);}else payload.context={customer_id:$('test-customer').value||null,description:$('test-description').value,case_title:$('test-case-title').value,hours:Number($('test-hours').value),tag_ids:[]};const data=await api('/'+current.id+'/simulate',payload);receipt=data.simulation_receipt;$('test-explanation').replaceChildren();const summary=element('p','',data.result.status==='decided'?'Afregning: '+(labels[data.result.method]||data.result.method):data.result.actions?.length?'Reglen matcher · '+data.result.actions.length+' handling(er) planlagt. Testen udfører dem ikke.':'Ingen handling matcher · '+(data.result.reason==='no_matching_rule'?'ingen matchende regel':'manuel vurdering'));$('test-explanation').append(summary);for(const trace of data.result.trace||[]){if(trace.source==='resource_coverage'){$('test-explanation').append(element('div','small',trace.matched?'Aftaler og klippekort: dækning matcher':'Aftaler og klippekort: ingen dækning matcher'));continue;}if(!Number.isInteger(trace.branch))continue;$('test-explanation').append(element('div','small fw-semibold',`Workflow #${trace.workflow_id} · gren ${trace.branch+1}: ${trace.matched?'opfyldt':'ikke opfyldt'}`));readableCondition(trace.condition,$('test-explanation'));}$('result').textContent=JSON.stringify(data.result,null,2);$('activate').disabled=false;}); $('activate').onclick=()=>guard(async()=>{if(!receipt||dirty)throw Error('Test versionen først');await api('/'+current.id+'/activate',{version_id:version.id,simulation_receipt:receipt});await list();await open(current.id);message('Den valgte version er aktiveret. Motorens featureflag styrer behandling.');}); $('disable').onclick=()=>guard(async()=>{if(!current)throw Error('Vælg et workflow');await api('/'+current.id+'/disable',{});await list();await open(current.id);message('Workflow deaktiveret.');}); $('copy').onclick=()=>guard(async()=>{if(!current)throw Error('Gem workflowet først');const row=await api('/'+current.id+'/copy',{name:($('name').value||current.name)+' – kopi'});await list();await open(row.id);}); diff --git a/app/workflows/frontend/resources.html b/app/workflows/frontend/resources.html index d723557..a7b15ca 100644 --- a/app/workflows/frontend/resources.html +++ b/app/workflows/frontend/resources.html @@ -8,7 +8,7 @@ async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;} function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;} function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;} -async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt og bruges automatisk ved afregningsvalg, når workflows er aktive for kunden. Ændringen er gemt i historikken.';await load();});card.append(save);$('list').append(card);}} +async function load(initialData){const data=initialData||await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',[...data.cards].sort((a,b)=>(a.status==='active'?0:1)-(b.status==='active'?0:1)||Number(b.id)-Number(a.id))]])for(const row of rows.filter((r,i,all)=>kind!=='cards'||i===all.findIndex(x=>Number(x.customer_id)===Number(r.customer_id)))){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris '+(row.agreement_number||row.id):'Klippekort · fælles tagdækning'} · ${name}`),el('p',kind==='cards'?'Gælder kundens nuværende og fremtidige klippekort. Saldo og afregning følger det aktive kort.':'Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);if(kind==='cards')await api(`/resources/customers/${row.customer_id}/card-coverage`,payload.workflow_coverage);else await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt og bruges automatisk ved afregningsvalg, når workflows er aktive for kunden. Ændringen er gemt i historikken.';await load();});card.append(save);$('list').append(card);}} let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}} -$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));customers.forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load();}); +$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));const resources=await api('/resources');const customerIds=new Set([...resources.agreements,...resources.cards].map(row=>Number(row.customer_id)));customers.filter(c=>customerIds.has(Number(c.id))).forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load(resources);}); })();{% endblock %} diff --git a/app/workflows/registry.py b/app/workflows/registry.py index 5a49aa7..1117d30 100644 --- a/app/workflows/registry.py +++ b/app/workflows/registry.py @@ -2,7 +2,7 @@ METHODS = {'invoice', 'subscription', 'prepaid', 'internal', 'non_billable'} FIELDS = {'customer_id', 'case_id', 'description', 'hours', 'worked_date', 'tags', 'tag_ids', 'status', 'billable', 'entity_type', 'sla_status', 'priority', 'subject', 'has_active_agreement', 'has_active_card'} -FIELDS.update({'remaining_card_hours','has_available_card'}) +FIELDS.update({'remaining_card_hours','has_available_card','case_title'}) OPERATORS = {'eq', 'ne', 'contains', 'not_contains', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte', 'exists'} TRIGGERS = {'time.created', 'time.updated', 'case.created', 'case.updated', 'tags.changed', 'mail.received', 'sla.changed', 'customer.updated', 'task.updated', 'reminder.due', 'notification.created'} TRIGGERS.update({'case.closed','tags.added','tags.removed','customer.created','task.created'}) diff --git a/app/workflows/resources.py b/app/workflows/resources.py index 08e5355..21889da 100644 --- a/app/workflows/resources.py +++ b/app/workflows/resources.py @@ -27,7 +27,14 @@ def list_resources(customer_id:Optional[int]=None,user=Depends(access)): cursor.execute('SELECT id,agreement_number,customer_id,status,workflow_coverage,monthly_price FROM customer_fixed_price_agreements'+where+' ORDER BY customer_id,id',params) agreements=cursor.fetchall() cursor.execute('SELECT id,card_number,customer_id,status,remaining_hours,workflow_coverage FROM tticket_prepaid_cards'+where+' ORDER BY customer_id,id',params) - return json_safe({'agreements':agreements,'cards':cursor.fetchall()}) + cards=cursor.fetchall() + cursor.execute('SELECT customer_id,workflow_coverage FROM bmc_workflow_customer_card_coverage') + profiles={int(p['customer_id']):p['workflow_coverage'] for p in cursor.fetchall()} + for card in cards: + if int(card['customer_id']) in profiles: + card['workflow_coverage']=profiles[int(card['customer_id'])] + card['coverage_scope']='customer' + return json_safe({'agreements':agreements,'cards':cards}) def update_resource(kind,resource_id,payload,user): table='customer_fixed_price_agreements' if kind=='agreement' else 'tticket_prepaid_cards' @@ -69,3 +76,31 @@ def update_agreement(resource_id:int,payload:ResourceUpdate,user=Depends(access) @router.put('/cards/{resource_id}/coverage') def update_card(resource_id:int,payload:ResourceUpdate,user=Depends(access)): return update_resource('card',resource_id,payload,user) + +@router.put('/customers/{customer_id}/card-coverage') +def update_customer_card_coverage(customer_id:int,payload:Coverage,user=Depends(access)): + coverage=payload.model_dump() + coverage['auto_billing']=True + tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids'])) + if any(tag_id<=0 for tag_id in tag_ids): + raise HTTPException(422,'Tag-ID skal være positivt') + with transaction() as cursor: + cursor.execute('SELECT id FROM customers WHERE id=%s',(customer_id,)) + if not cursor.fetchone(): + raise HTTPException(404,'Kunden findes ikke') + if tag_ids: + cursor.execute('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE',(tag_ids,)) + if {r['id'] for r in cursor.fetchall()}!=set(tag_ids): + raise HTTPException(422,'Et tag findes ikke eller er inaktivt') + # Match settlement lock order. Approved decisions are immutable and revalidated at settlement. + cursor.execute("SELECT t.id FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id LEFT JOIN tmodule_customers c ON c.id=t.customer_id WHERE COALESCE(s.customer_id,c.hub_customer_id)=%s AND t.status NOT IN ('approved','billed') AND t.economy_order_draft_id IS NULL ORDER BY t.id FOR UPDATE OF t",(customer_id,)) + time_ids=[r['id'] for r in cursor.fetchall()] + cursor.execute('SELECT id FROM customers WHERE id=%s FOR UPDATE',(customer_id,)) + cursor.execute('SELECT workflow_coverage FROM bmc_workflow_customer_card_coverage WHERE customer_id=%s',(customer_id,)) + previous=cursor.fetchone() + cursor.execute('INSERT INTO bmc_workflow_customer_card_coverage(customer_id,workflow_coverage) VALUES(%s,%s) ON CONFLICT(customer_id) DO UPDATE SET workflow_coverage=EXCLUDED.workflow_coverage,updated_at=now()', (customer_id,Json(coverage))) + cursor.execute('INSERT INTO bmc_workflow_resource_history(resource_type,resource_id,created_by,before_value,after_value) VALUES(%s,%s,%s,%s,%s)',('customer_card',customer_id,str(user['id']),Json(json_safe(dict(previous))) if previous else Json({}),Json({'workflow_coverage':coverage}))) + if time_ids: + cursor.execute('UPDATE tmodule_times SET workflow_revision=workflow_revision+1 WHERE id=ANY(%s) RETURNING id,workflow_revision',(time_ids,)) + for time in cursor.fetchall(): emit_event(cursor,'time.updated','time_entry',time['id'],time['workflow_revision']) + return {'customer_id':customer_id,'workflow_coverage':coverage} diff --git a/app/workflows/service.py b/app/workflows/service.py index 388755a..39e921a 100644 --- a/app/workflows/service.py +++ b/app/workflows/service.py @@ -55,7 +55,7 @@ def load_time_context(time_id, cursor=None): if row['customer_id']: cursor.execute("SELECT * FROM customer_fixed_price_agreements WHERE customer_id=%s AND status IN ('active','pending_cancellation') AND start_date<=COALESCE(%s::date,CURRENT_DATE) AND (end_date IS NULL OR end_date>=COALESCE(%s::date,CURRENT_DATE)) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>COALESCE(%s::date,CURRENT_DATE))", (int(row['customer_id']),row.get('worked_date'),row.get('worked_date'),row.get('worked_date'))) row['agreements']=cursor.fetchall() - cursor.execute("SELECT * FROM tticket_prepaid_cards WHERE customer_id=%s AND status='active' AND (expires_at IS NULL OR expires_at>now())",(int(row['customer_id']),)) + cursor.execute("SELECT c.*,COALESCE(p.workflow_coverage,c.workflow_coverage) AS workflow_coverage FROM tticket_prepaid_cards c LEFT JOIN bmc_workflow_customer_card_coverage p ON p.customer_id=c.customer_id WHERE c.customer_id=%s AND c.status='active' AND (c.expires_at IS NULL OR c.expires_at>now())",(int(row['customer_id']),)) row['cards']=cursor.fetchall() else: row['agreements'],row['cards']=[],[] @@ -71,7 +71,7 @@ def load_case_context(case_id, cursor=None): cursor.execute('SELECT * FROM sag_sager WHERE id=%s',(case_id,)) row = cursor.fetchone() if not row: raise ValueError('Case not found') - context=dict(row,entity_type='case',case_id=case_id,description=row.get('beskrivelse'),subject=row.get('titel')) + context=dict(row,entity_type='case',case_id=case_id,case_title=row.get('titel'),description=row.get('beskrivelse'),subject=row.get('titel')) context['customer_id']=str(row['customer_id']) if row.get('customer_id') is not None else None cursor.execute("SELECT tag_id FROM entity_tags WHERE entity_type='case' AND entity_id=%s",(case_id,)) concrete=[r['tag_id'] for r in cursor.fetchall()] diff --git a/app/workflows/settlement.py b/app/workflows/settlement.py index 827acff..2103a2b 100644 --- a/app/workflows/settlement.py +++ b/app/workflows/settlement.py @@ -88,7 +88,7 @@ def settle_workflow_entries(ids, actor_id, line_mode='separate', review_validato if hours <= 0: raise HTTPException(409, 'Tidsregistreringen skal have positiv tid') if method=='prepaid': - card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id=%s AND status='active' AND (expires_at IS NULL OR expires_at>=CURRENT_DATE)", (row.get('prepaid_card_id'),)) + card = execute_query_single("SELECT c.*,COALESCE(p.workflow_coverage,c.workflow_coverage) AS workflow_coverage FROM tticket_prepaid_cards c LEFT JOIN bmc_workflow_customer_card_coverage p ON p.customer_id=c.customer_id WHERE c.id=%s AND c.status='active' AND (c.expires_at IS NULL OR c.expires_at>=CURRENT_DATE)", (row.get('prepaid_card_id'),)) if not card or int(card['customer_id']) != int(row.get('billing_customer_id') or 0): raise HTTPException(409, 'Klippekortet er ikke gyldigt for kunden') _check_coverage(card, decisions[tid]) diff --git a/docs/remote-control.md b/docs/remote-control.md new file mode 100644 index 0000000..b63b16e --- /dev/null +++ b/docs/remote-control.md @@ -0,0 +1,61 @@ +# Fjernbetjening til BMC Hub + +Implementeret lokalt. Funktionen er slået fra som standard og er ikke aktiveret i produktion. + +## Aktivering og pilot + +1. Tag den normale databasebackup. Anvend kun migration `1069_remote_control.sql` gennem migrationscentret eller `scripts/run_migrations.py migrations/1069_remote_control.sql --stop-on-error`. Den opretter nye tabeller og ændrer ikke eksisterende sager eller tidsregistreringer. +2. Konfigurér `REMOTE_LOOKUP_KEY` med en særskilt, kryptografisk tilfældig hemmelighed på mindst 32 tegn i serverens miljø. Gem ikke nøglen i Git. Nøglen beskytter kodeopslag, PIN og anonymiserede loginbegrænsninger. Udskiftning kræver nye koder/PIN til brugerne. +3. Sæt `REMOTE_ENABLED=true` og genstart applikationen. Hold test og produktion adskilt. Fjernbetjeningen bruger samme server og port som Hub: `http://172.16.31.183:8001/remote`. HTTP understøttes til lokalnet/VPN; der kræves ingen separat Docker eller port. +4. Hvis der bruges reverse proxy, skal den bevare Host og videregive korrekt scheme. Uvicorn må kun stole på forwarded headers fra den konkrete proxy. Begræns netværksadgang til lokalnet/VPN i eksisterende firewall/proxy; modulet åbner ingen internetforbindelse eller ekstern tunnel. +5. Pilotbrugeren logger normalt ind i Hub, vælger **Fjernbetjening**, opretter en 4-cifret PIN og en navngivet fjernbetjening. Gem den 12-cifrede kode, som kun vises ved oprettelse/udskiftning. Koden bevarer indledende nuller. +6. Åbn `/remote` på mobil/tablet. Forbind med kode + PIN. Enhedens forbindelse udløber efter 30 dage; PIN kræves igen hver ny kalenderdag i København. Tilføj siden til hjemmeskærmen via browserens menu; ingen offlinehandlinger eller service-worker-cache anvendes. +7. I én Hub-fane vælges **Følg i denne fane**. En ny valgt fane overtager. Mobilen virker også uden en computer. Der synkroniseres hvert tredje sekund på synlige sider. + +## Adfærd + +- Timer, pause, genoptagelse, stop og manuel tid bruger de eksisterende handlers og regler. Stop kræver beskrivelse. Manuel tid gemmes på den valgte sag med dagens danske dato og automatisk afregningsvalg som Hubs hurtigregistrering. Der godkendes eller faktureres ikke automatisk fra fjernbetjeningen. +- Dagens tid tæller gemte faktiske minutter; kørende og pausede timere tælles ikke med. +- **Nye** betyder status `Ny`. **Mine** bruger Hubs konfiguration af lukkede statusser. Søgning understøtter nummer, titel og kundenavn; visningen er pagineret med 50 sager. +- Deadline vises med lokal tidszone: gul ved højst 60 minutter tilbage, rød efter overskridelse. En deadline uden offset fortolkes i `Europe/Copenhagen`. Ingen SLA-arbejdstidsregler beregnes. +- Almindelige beskeder markeres kun som læst på brugerens tryk. Beskeder med krav om bekræftelse bruger den eksisterende separate bekræftelsesfunktion. +- Ved forbindelsesbrud fortsætter serverens timer, mens mobilens handlinger deaktiveres. Ved tabt svar kan brugeren kontrollere resultatet eller eksplicit prøve samme handling igen med samme idempotensnøgle. Intet afspilles automatisk efter genforbindelse. +- Hvis Hub ændres samtidig, returneres konflikt, og mobilen henter aktuel tilstand. Sagsvalg til en afbrudt computer leveres ikke senere. En fane med igangværende redigering spørger før navigation. + +## Sikkerhed og API + +`/api/v1/remote` har en separat enhedscookie og giver aldrig almindelig Hub-/administratorlogin. Administration og faneforbindelse kræver almindeligt Hub-login. Alle kald kræver featureflag; cookies er Secure ved HTTPS og kan bruges over HTTP på lokalnettet. HTTP krypterer ikke kode, PIN eller session under transport. Alle skrivninger kræver samme Origin og CSRF-token fra `/bootstrap`. + +- `GET /bootstrap`, `POST /connect`, `/unlock`, `/logout`: forbindelse og daglig oplåsning. +- `GET /state`, `/cases`, `/command-result/{request_id}` og `POST /command`: begrænsede remote-funktioner. Kommandoer kræver UUID, remote-version og timerfingeraftryk. Ukendte felter afvises. +- `GET /admin`, `PUT /admin/pin`, `POST /admin/remotes`, `POST /admin/remotes/{id}/rotate`, `DELETE /admin/remotes/{id}`, `DELETE /admin/devices/{id}`: kun ejerens administration. +- `POST /follow`: valgt Hub-fane, heartbeat og kortvarigt sagsvalg. + +Loginforsøg reserverer 30 sekunder i PostgreSQL før legitimationskontrollen. Begrænsningen deles mellem processer og gælder klient-IP samt kendt bruger/enhed. Forkerte forsøg rulles ikke tilbage. PIN hashes med nøglebeskyttet forbehandling; koder hashes separat. Sessiontokens hashes og gemmes i Secure/HttpOnly/SameSite=Strict-cookies. Rettigheder og brugerens aktive status kontrolleres løbende. Alle remote-svar har `Cache-Control: no-store`. + +## Test + +Enhedstest: `./.venv/bin/python -m pytest tests/test_remote_control.py -q`. + +Databaseintegration: `BMC_REMOTE_DB_TESTS=1 ./.venv/bin/python -m pytest tests/test_remote_control.py -q`. Kræver lokal PostgreSQL med eksisterende tidsmodul-schema; testen opretter og fjerner kun sit eget tilfældigt navngivne schema. Det offentlige schema ændres ikke. Tests dækker loginbegrænsning med samtidige forbindelser, sessionsadskillelse, PIN, tilbagekaldelse, timere, idempotens, konflikter, sagsfiltre og valgt fane. + +Hubs databasepool er gjort trådsikker uden ændring af forbindelsesgrænser eller API-kontrakter, så samtidige synkrone remote- og Hub-kald kan bruge den sikkert. + +Browser: `BMC_PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/remote_control.cjs`. Brug `BMC_REMOTE_BROWSER=webkit` for Safari/WebKit-motoren. Tests bruger udelukkende mockdata og mobil/tabletstørrelser. En fysisk iPhone/iPad-pilot skal desuden kontrollere lokalnet/VPN og Tilføj til hjemmeskærm. + +Verificeret lokalt 13. oktober 2026: 23 remote-tests med isoleret PostgreSQL og 37 eksisterende timer-/registrerings-/afregningstests bestod. Regressionstestene blev kørt med `WORKFLOWS_ENABLED=false WORKFLOWS_MODE=shadow`, som de eksisterende tests forudsætter. Chrome og Safari/WebKit bestod mobil (390 px), tablet (820 px), administration og synkronisering med kun den valgte Hub-fane. JavaScript/Python-syntaks og `git diff --check` bestod. Produktionsaktivering og fysisk enhedstest over VPN er ikke udført. + +## Tilbagerulning + +Sæt `REMOTE_ENABLED=false` og genstart. Remote-ruter returnerer 404, og eksisterende Hub-adfærd fortsætter. Bevar de additive tabeller og alle tidsregistreringer. Hvis forbindelser skal være permanent tilbagekaldt før en senere genaktivering, tilbagekald dem i administrationen før deaktivering eller sæt `revoked=true` på remote-enhederne gennem en særskilt, godkendt driftsændring. Slet ikke sager eller tidsregistreringer. + + +## Kontakter og VoIP + +Fjernbetjeningen kan søge aktive kontakter efter navn, firma, telefon og e-mail med Hub'ens eksisterende kontaktopslag. Det kræver `customers.view` (eller superadministrator), samme adgang som kundeoplysninger. Numrene vælges fra kontaktens telefon/mobil; fjernbetjeningen kan ikke vælge andre brugeres telefon eller angive en Action URL. + +Ring-knappen bruger Hub'ens eksisterende click-to-call med den tilknyttede brugers VoIP-/Yealink-telefon og gældende netværkskontrol. Telefonopsætning og click-to-call skal være aktiveret i Hub. Samtalens lyd foregår på VoIP-telefonen, ikke i mobilbrowseren. Remote returnerer aldrig Action URL eller telefonens adgangsoplysninger. + +Indgående ringende opkald vises fra Mission Controls fælles opkaldstilstand med kontakt, firma, nummer og modtagende telefoner. Visningen opdateres hvert tredje sekund, mens siden er synlig; forældede opkald skjules efter den eksisterende ringegrænse. Det er Hub'ens fælles indgående visning. En fejl i telefonivisningen stopper ikke timeren. + +Opkald får en idempotensnøgle, der reserveres før kommandoen sendes til telefonen. Gentagelse med samme nøgle ringer ikke igen. Ved timeout eller tabt svar kan brugeren læse status og kontrollere telefonen; et gammelt opkald sendes aldrig automatisk efter genforbindelse. Et udfald efter reservation kan efterlade ukendt status, fordi telefonens eksterne handling ikke kan indgå i databasens transaktion. diff --git a/main.py b/main.py index 929d2f2..0d3d671 100644 --- a/main.py +++ b/main.py @@ -383,6 +383,14 @@ app.add_middleware( @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path + # Remote endpoints validate their own restricted device session. Never turn + # a remote cookie into a normal Hub login; admin routes still require Hub auth. + if path == "/remote" or path.startswith("/remote/") or path.startswith("/api/v1/remote/"): + response = await call_next(request) + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Frame-Options"] = "DENY" + return response mission_path = path.startswith("/dashboard/mission-control") or path.startswith("/api/v1/mission/") mission_pin = _get_mission_access_pin() if mission_path else "" @@ -561,6 +569,9 @@ app.include_router(also_api.router, prefix="/api/v1", tags=["ALSO Cloud Billing" app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"]) app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"]) app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"]) +from app.modules.remote.router import router as remote_api, views as remote_views +app.include_router(remote_api, tags=["Remote Control"]) +app.include_router(remote_views, tags=["Remote Control"]) app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"]) app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) diff --git a/migrations/1068_workflow_customer_card_coverage.sql b/migrations/1068_workflow_customer_card_coverage.sql new file mode 100644 index 0000000..0a93a9a --- /dev/null +++ b/migrations/1068_workflow_customer_card_coverage.sql @@ -0,0 +1,6 @@ +-- Additive customer-level coverage; no balances, invoices or existing coverage are changed. +CREATE TABLE IF NOT EXISTS bmc_workflow_customer_card_coverage ( + customer_id BIGINT PRIMARY KEY, + workflow_coverage JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/1069_remote_control.sql b/migrations/1069_remote_control.sql new file mode 100644 index 0000000..7a60d6a --- /dev/null +++ b/migrations/1069_remote_control.sql @@ -0,0 +1,49 @@ +-- Additive remote-control storage; existing cases and time entries are untouched. +CREATE TABLE IF NOT EXISTS remote_pins ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + pin_hash TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS remote_controls ( + id UUID PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + code_lookup CHAR(64) UNIQUE NOT NULL, + code_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS remote_devices ( + id UUID PRIMARY KEY, + remote_id UUID NOT NULL REFERENCES remote_controls(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + token_hash CHAR(64) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + unlocked_date DATE NOT NULL, + pin_generation INTEGER NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS remote_devices_remote ON remote_devices(remote_id); +CREATE TABLE IF NOT EXISTS remote_attempts ( + bucket CHAR(64) PRIMARY KEY, + next_allowed_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE IF NOT EXISTS remote_user_state ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + version BIGINT NOT NULL DEFAULT 0, + active_case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL, + follower_id UUID, + follower_seen_at TIMESTAMPTZ, + selection_seq BIGINT NOT NULL DEFAULT 0, + selection_target UUID, + selection_expires_at TIMESTAMPTZ +); +CREATE TABLE IF NOT EXISTS remote_commands ( + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + request_id UUID NOT NULL, + payload JSONB NOT NULL, + result JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(user_id, request_id) +); diff --git a/requirements.txt b/requirements.txt index e7735f3..e337e88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,5 @@ pyzbar==0.1.9 pypdfium2==4.30.0 reportlab==4.2.5 openpyxl==3.1.5 + +qrcode==8.2 diff --git a/static/css/case-create-v5.css b/static/css/case-create-v5.css new file mode 100644 index 0000000..3ead08c --- /dev/null +++ b/static/css/case-create-v5.css @@ -0,0 +1,35 @@ +/* Match the V5 workspace while retaining all creation fields and modes. */ +.container.cc-page{max-width:1600px;padding:18px 20px 110px!important;background:var(--theme-surface-soft,#dbe3eb);border-radius:12px;--cc-blue:#336699;--cc-border:#bdcbd5} +.container.cc-page .cc-pagebar{margin-bottom:10px;font-size:12px} +.cc-v5-hero{background:linear-gradient(110deg,#162b40,#203d59);color:#f2f6fa;border:1px solid #496783;border-left:3px solid #6699cc;border-radius:12px;padding:16px 20px;margin-bottom:14px} +.cc-v5-eyebrow{font-size:10px;letter-spacing:1px;color:#c2d1df} +.container.cc-page .cc-v5-hero h1{font-size:25px;margin:3px 0 5px;font-weight:700;letter-spacing:-.5px} +.cc-v5-hero p{font-size:12px;color:#c2d1df;margin:0} +.container.cc-page .card-custom{border:1px solid var(--cc-border);border-radius:11px;box-shadow:none} +.container.cc-page .card-custom>.card-body{padding:18px 20px!important} +.container.cc-page .form-control,.container.cc-page .form-select{min-height:34px;font-size:13px;border-radius:7px;border-color:var(--cc-border)} +.container.cc-page .form-label{font-size:12px;margin-bottom:5px} +.container.cc-page textarea.form-control{background:var(--bg-element,#f6f8fa);padding:10px 12px;line-height:1.5} +.container.cc-page #cc-types{gap:6px} +.container.cc-page #cc-types>button{padding:7px 10px;min-height:34px;border-radius:7px;flex:0 1 auto} +.container.cc-page .cc-type-name{font-size:12px} +.container.cc-page #cc-types>button.active{border-color:var(--cc-blue);background:#eaf1f8;box-shadow:none} +.container.cc-page .cc-type-hint{font-size:10px;margin-top:2px} +.container.cc-page .cc-panel{border-color:var(--cc-border);border-radius:9px} +.container.cc-page .cc-panel>summary{padding:9px 12px} +.container.cc-page .cc-panel-icon{width:27px;height:27px;border-radius:6px;background:#eaf1f8;color:var(--cc-blue)} +.container.cc-page .cc-actions{padding:10px 12px;border-radius:9px;margin-top:14px!important;border-color:var(--cc-border);box-shadow:0 3px 12px #143b5614} +.container.cc-page #submitBtn{background:var(--cc-blue);border-color:var(--cc-blue);border-radius:7px;font-size:13px} +.cc-command-tools{margin:6px 0 8px;font-size:12px} +.cc-command-toolbar{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;color:var(--text-secondary,#667788)} +.cc-command-toolbar summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--cc-blue)} +.cc-command-help{display:flex;flex-wrap:wrap;gap:4px;padding:8px 0} +.cc-command-help button{border:1px solid var(--cc-border);border-radius:6px;padding:4px 7px;background:var(--bg-surface,#fff);color:var(--cc-blue);font-size:11px} +.cc-command-preview{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px} +.cc-command-chip{display:inline-flex;padding:4px 7px;border:1px solid #a9bfd5;border-radius:6px;background:#eaf1f8;color:#234d75;font-size:11px} +.cc-command-preview:empty{display:none} +.cc-command-recovery{padding:10px;border:1px solid #a9bfd5;border-radius:7px;margin-top:8px;background:#eaf1f8;color:#234d75} +.cc-command-recovery[hidden]{display:none} +[data-bs-theme=dark] .container.cc-page{--cc-border:#526675;--cc-blue:#91bce8;background:#101b24} +[data-bs-theme=dark] .container.cc-page #cc-types>button.active,[data-bs-theme=dark] .cc-command-chip,[data-bs-theme=dark] .cc-command-recovery{background:#203850;color:#dbeafa} +@media(max-width:600px){.container.cc-page{padding:12px 10px 110px!important}.container.cc-page .card-custom>.card-body{padding:14px 12px!important}.cc-v5-hero{padding:12px 14px}.container.cc-page .cc-v5-hero h1{font-size:22px}} diff --git a/static/css/case-v5.css b/static/css/case-v5.css index cab68c1..aac6baf 100644 --- a/static/css/case-v5.css +++ b/static/css/case-v5.css @@ -283,3 +283,99 @@ .case-v5 .v5-action-options button[aria-pressed=true],.case-v5 .v5-picker button[aria-pressed=true]{border-color:#7ca2c8} @media(max-width:1100px){.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr) 250px;gap:10px 18px}.case-v5 #sag-titel-text{font-size:24px!important}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 12px}} @media(max-width:767px){.case-v5 .header-bg{padding:12px 0!important}.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr);gap:11px}.case-v5 .v5-hero-status{grid-column:1;grid-row:2;border-left:0;padding-left:0}.case-v5 .v5-hero-timeline{grid-column:1;grid-row:3}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.case-v5 .case-header-todo{grid-column:1;grid-row:4;width:100%}.case-v5 #sag-titel-text{font-size:23px!important}} + + +/* V5 facts belong to the hero; the case workspace has no left sidebar. */ +.case-v5 .v5-board{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,300px);gap:18px} +.case-v5 .v5-board>main{grid-column:1;grid-row:1} +.case-v5 .v5-board>.v5-case-modules{grid-column:2;grid-row:1} +.case-v5 .header-bg{overflow:visible!important} +.case-v5 .header-bg:has(.v5-hero-facts-panel.show){position:relative;z-index:1040} +.case-v5 .v5-hero-facts-menu{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px} +.case-v5 .v5-hero-facts-button{display:inline-flex;align-items:center;gap:7px;border:1px solid #5c7a94;border-radius:7px;padding:6px 10px;background:#29465f;color:#edf5fc;font-size:12px;line-height:1.4} +.case-v5 .v5-hero-facts-button:hover,.case-v5 .v5-hero-facts-button[aria-expanded="true"]{background:#365c7e;border-color:#9abbd9} +.case-v5 .v5-hero-facts-button:focus-visible{outline:2px solid #b2d2f1;outline-offset:2px} +.case-v5 .v5-hero-facts-panel{width:340px;max-width:calc(100vw - 40px);padding:0;border:1px solid var(--case-border);border-radius:12px;box-shadow:0 14px 40px #142d4933;color:var(--text-color,#243449);background:var(--bg-surface,#fff);z-index:1050} +.case-v5 .v5-hero-facts-panel .card{margin:0;border:0!important;background:transparent!important} +.case-v5 .v5-hero-facts-panel .card-body{padding:18px!important} +.case-v5 .v5-hero-facts-panel .form-select{max-width:100%} +@media(min-width:768px) and (max-width:1250px){.case-v5 .v5-case-modules .right-modules-grid{display:flex!important;flex-direction:column!important}} +@media(max-width:767px){.case-v5 .v5-board{grid-template-columns:minmax(0,1fr);gap:14px}.case-v5 .v5-board>main{grid-column:1;grid-row:1}.case-v5 .v5-board>.v5-case-modules{grid-column:1;grid-row:2}.case-v5 .v5-hero-facts-menu{gap:6px;margin-top:12px}.case-v5 .v5-hero-facts-button{font-size:11px;padding:7px 8px}} + +/* Use the wide hero as a compact work toolbar instead of stacked full-width rows. */ +@media(min-width:1401px){ + .case-v5 .case-header-main{grid-template-columns:minmax(320px,1fr) minmax(500px,620px) 250px;column-gap:22px;row-gap:12px;align-items:start} + .case-v5 .case-header-copy{grid-column:1;grid-row:1 / span 2;align-self:center} + .case-v5 .v5-hero-timeline{grid-column:2;grid-row:1;border-top:0!important;border-left:1px solid var(--hero-line)!important;padding:0 0 0 20px!important} + .case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(3,minmax(0,1fr)) minmax(110px,.8fr);gap:8px;padding:0!important} + .case-v5 .v5-hero-status{grid-column:3;grid-row:1;padding-left:16px;gap:8px} + .case-v5 .case-header-todo{grid-column:2 / -1;grid-row:2;justify-self:stretch;width:100%;margin:0!important} + .case-v5 .v5-hero-facts-menu{margin-top:11px} +} +@media(min-width:768px) and (max-width:1400px){ + .case-v5 .case-header-main{grid-template-columns:minmax(0,1fr) 250px;column-gap:18px;row-gap:10px} + .case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(3,minmax(0,1fr)) minmax(100px,.7fr);gap:10px 14px} + .case-v5 .case-header-todo{width:100%;justify-self:stretch} +} + +/* Keep command tools compact and preserve native hidden menu behaviour. */ +.case-v5 .v5-action-menu[hidden]{display:none!important} +.case-v5 .case-command-panel{padding:4px 0;border:0;background:transparent;display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px} +.case-v5 .case-command-hint{font-size:11px;margin:0;color:var(--text-muted,#667788)} +.case-v5 .case-command-help-disclosure{border:0;padding:0;margin:0;background:transparent;font-size:12px} +.case-v5 .case-command-help-disclosure>summary{padding:2px 0;font-size:12px} +.case-v5 .case-command-help-disclosure[open]{flex-basis:100%} +.case-v5 .case-command-suggestions:empty,.case-v5 .case-command-chips:empty,.case-v5 .case-command-status:empty{display:none} +.case-v5 .case-command-suggestions,.case-v5 .case-command-chips,.case-v5 .case-command-status{flex-basis:100%} +.case-v5 .case-command-help-item small{display:none} +.case-v5 .case-command-help-item{padding:5px 8px} +.case-v5 .case-command-panel [hidden]{display:none!important} + +.case-v5 .right-modules-grid>[data-module="tags"]{order:-1;display:flex!important} +.case-v5 .case-command-panel .case-command-help-disclosure{margin-top:0} +.case-v5 .case-command-help-disclosure>summary{border:0;background:transparent} + +/* Actions stay within reach above the editor, with one clear panel per task. */ +.case-v5 .v5-composer-actions{margin:0 0 8px;align-items:center;gap:8px} +.case-v5 #v5ActionToggle{display:inline-flex;align-items:center;gap:7px;width:auto;height:32px;padding:5px 10px;font-size:12px;border-radius:7px} +.case-v5 .v5-action-menu{padding:10px;margin:0 0 8px;background:var(--case-primary-soft)} +.case-v5 .v5-action-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px} +.case-v5 .v5-action-options button{width:100%;padding:8px 10px;min-height:36px} +.case-v5 .v5-assignment-fields{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;padding:12px;margin:0 0 8px;border:1px solid var(--case-border);border-radius:9px;background:var(--bg-surface,#fff)} +.case-v5 .v5-assignment-fields[hidden]{display:none!important} +.case-v5 .v5-assignment-fields label[for=v5GroupSearch],.case-v5 #v5GroupSearch,.case-v5 #v5GroupOptions{grid-column:1} +.case-v5 .v5-assignment-fields label[for=v5UserSearch]{grid-column:2;grid-row:1} +.case-v5 #v5UserSearch{grid-column:2;grid-row:2} +.case-v5 #v5UserOptions{grid-column:2;grid-row:3} +.case-v5 .v5-assignment-fields label[for=v5AssignmentReason],.case-v5 #v5AssignmentReason,.case-v5 .v5-assignment-fields p{grid-column:1/-1} +.case-v5 .v5-picker{margin:0;max-height:110px;overflow:auto} +.case-v5 .v5-submit-row{display:flex;justify-content:flex-end;margin-top:8px} +@media(max-width:600px){.case-v5 .v5-action-options{grid-template-columns:repeat(2,minmax(0,1fr))}.case-v5 .v5-assignment-fields{display:flex;flex-direction:column}} + +/* Employee-first assignment; groups stay behind an optional disclosure. */ +.case-v5 .v5-assignment-fields{display:flex;flex-direction:column;gap:6px;padding:10px} +.case-v5 .v5-employee-buttons{display:flex;flex-wrap:wrap;gap:5px;max-height:145px} +.case-v5 .v5-employee-buttons button{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:30px;padding:3px 7px;font-size:11px;font-weight:650;border-radius:6px} +.case-v5 .v5-employee-buttons button[aria-pressed=true]{background:var(--case-primary);color:#fff;border-color:var(--case-primary);box-shadow:0 0 0 1px var(--case-primary)} +.case-v5 .v5-assignment-groups{font-size:11px;margin:2px 0 4px;color:var(--text-muted,#667788)} +.case-v5 .v5-assignment-groups summary{cursor:pointer} +.case-v5 .v5-assignment-groups[open] input{margin:6px 0} + +/* Call history is a compact list in the narrow V5 sidebar. */ +.case-v5 #caseCallHistoryBody{padding:0!important;max-height:320px;overflow-y:auto} +.case-v5 #caseCallHistoryBody .table-responsive{overflow:visible} +.case-v5 #caseCallHistoryBody table,.case-v5 #caseCallHistoryRows{display:block;width:100%} +.case-v5 #caseCallHistoryBody thead{display:none} +.case-v5 #caseCallHistoryRows tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 8px;padding:10px 12px;border-bottom:1px solid var(--case-border)} +.case-v5 #caseCallHistoryRows tr:last-child{border-bottom:0} +.case-v5 #caseCallHistoryRows td{padding:0!important;border:0!important;background:transparent!important;box-shadow:none!important;font-size:11px;line-height:1.5;min-width:0;color:var(--text-muted,#667788);overflow-wrap:anywhere} +.case-v5 #caseCallHistoryRows td:nth-child(3){grid-column:1/-1;grid-row:1;color:var(--text-color,#243449);font-size:13px;font-weight:600} +.case-v5 #caseCallHistoryRows td:nth-child(3)>div{display:flex!important;flex-wrap:nowrap!important;justify-content:space-between;gap:8px} +.case-v5 #caseCallHistoryRows td:nth-child(3) span{min-width:0;overflow-wrap:anywhere} +.case-v5 #caseCallHistoryRows td:nth-child(3) button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:27px;height:27px;padding:0;border-radius:6px;border-color:var(--case-border);color:var(--case-primary)} +.case-v5 #caseCallHistoryRows td:nth-child(3) button:hover{background:var(--case-primary);border-color:var(--case-primary);color:#fff} +.case-v5 #caseCallHistoryRows td:nth-child(1){grid-column:1;grid-row:2;white-space:nowrap} +.case-v5 #caseCallHistoryRows td:nth-child(2){grid-column:1;grid-row:3;display:flex;align-items:center;gap:5px} +.case-v5 #caseCallHistoryRows td:nth-child(2) i{color:var(--case-primary);font-size:12px} +.case-v5 #caseCallHistoryRows td:nth-child(4){grid-column:1/-1;grid-row:4;font-size:10px} +.case-v5 #caseCallHistoryRows td:nth-child(5){grid-column:2;grid-row:3;font-variant-numeric:tabular-nums;white-space:nowrap} diff --git a/static/css/remote.css b/static/css/remote.css new file mode 100644 index 0000000..64364be --- /dev/null +++ b/static/css/remote.css @@ -0,0 +1,19 @@ +:root{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#233d43;background:#f3f6f6;--accent:#087e72;--muted:#73878b;--line:#e0e8e8;--surface:#fff;color-scheme:light} +*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:100px}body{margin:0;min-height:100vh;padding:28px 24px 32px;background:radial-gradient(ellipse at 90% 0,#dfedeb70,transparent 60%),#f3f6f6}main{max-width:1120px;margin:auto}button,input,textarea{font:inherit}button{min-height:44px;border:1px solid transparent;border-radius:12px;padding:12px 16px;background:#087e72;color:white;font-weight:650;cursor:pointer;transition:background .18s,transform .18s}button:hover:not(:disabled){background:#06695f}button:active:not(:disabled){transform:translateY(1px)}button:disabled{opacity:.38;cursor:default}button:focus-visible,a:focus-visible{outline:3px solid #53bdac;outline-offset:3px}a{color:#087e72;text-decoration:none}h1,h2,h3,p{margin-top:0}h1{font-size:23px;letter-spacing:-.8px;margin:2px 0 0}h2{font-size:17px;letter-spacing:-.4px;margin:0}p{line-height:1.6}.eyebrow{display:block;font-size:9px;font-weight:750;letter-spacing:1.6px;color:#71868a}.icon{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;vertical-align:middle}.icon-defs{position:absolute;width:0;height:0;overflow:hidden}.app-header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:0 0 30px;padding-bottom:22px;border-bottom:1px solid #dfe8e7}.brand{display:flex;align-items:center;gap:13px}.brand-mark{display:grid;place-content:center;position:relative;width:48px;height:48px;border-radius:15px;background:#113f42;color:white;font-size:28px;font-weight:750;box-shadow:0 5px 12px #153c4015}.brand-mark span{position:absolute;right:6px;bottom:1px;color:#66dbb8;font-size:25px}.brand-subtitle{display:block;font-size:11px;letter-spacing:0;font-weight:400;color:#7d9194;margin-top:4px}#connection{max-width:100%;border:1px solid #dce7e4;border-radius:30px;padding:9px 13px;font-size:10px;color:#4e7470;background:#fff;display:flex;align-items:center;gap:7px}#connection:before{content:"";flex:0 0 6px;height:6px;border-radius:50%;background:var(--connection-dot,#087e72)}#controls{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);grid-template-areas:"heading heading" "hero today" "hero cases" "manual cases" "phone messages" "footer footer";gap:20px;align-items:start}.workspace-heading{grid-area:heading;display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}.workspace-heading h2{font-size:30px;letter-spacing:-1px;margin-top:7px;font-weight:650}#remote-date{font-size:12px;color:#71868a;text-transform:capitalize}section{background:white;border:1px solid #e0e8e8;border-radius:22px;padding:24px;box-shadow:0 5px 20px #1d484104;min-width:0}.section-top{display:flex;justify-content:space-between;align-items:center;gap:12px}.section-heading{display:flex;align-items:center;gap:10px;margin-bottom:20px}.section-icon{display:grid;place-content:center;width:34px;height:34px;border-radius:11px;background:#eef5f3;color:#247f73}.section-icon .icon{width:19px;height:19px}.section-hint{margin-left:auto;font-size:10px;color:#809497}.hero{grid-area:hero;position:relative;overflow:hidden;background:radial-gradient(ellipse at 90% 10%,#28585960,transparent 65%),#123639;color:#eefaf7;border-color:#123639;box-shadow:0 14px 35px #153c4018;padding:26px}.hero .eyebrow{color:#86aaa6}#active-case{font-size:23px;line-height:1.35;margin:17px 0 12px;letter-spacing:-.6px;font-weight:600}#desktop{font-size:9px;color:#96b9b3;display:flex;align-items:center;gap:5px}#desktop:before{content:"";height:5px;width:5px;flex-shrink:0;border-radius:50%;background:#607f79}#desktop.connected:before{background:#75d9b6}#deadline{display:inline-block;font-size:10px;color:#a5c8bf;padding:6px 10px;border:1px solid #46716a;border-radius:8px;margin:0;background:#ffffff06}.timer-dial{width:244px;height:244px;border-radius:50%;border:1px solid #7ba69b38;display:grid;place-items:center;margin:22px auto 24px;position:relative;background:radial-gradient(circle,#28534d44,transparent 66%)}.timer-dial:before{content:"";position:absolute;inset:8px;border:1px dashed #93b5aa20;border-radius:50%}.timer-dial:after{content:"";position:absolute;width:7px;height:7px;top:3px;left:50%;border-radius:50%;background:#96b6aa;box-shadow:0 0 0 5px #96b6aa0d}.timer-face{position:relative;text-align:center;max-width:214px}.timer-caption{display:flex;justify-content:center;align-items:center;gap:6px;font-size:9px;letter-spacing:1.9px;color:#97b8b0;font-weight:650}.timer-caption .icon{width:13px;height:13px}.clock{font-size:46px;font-weight:500;letter-spacing:-2px;font-variant-numeric:tabular-nums;margin:13px 0 9px;line-height:1.15;color:#effff8}#timer-case{font-size:10px;color:#92b4aa;margin:0;max-width:200px;overflow-wrap:anywhere}.hero.running .clock{color:#85ebc0}.hero.running .timer-dial{border-color:#85ebc077}.hero.running .timer-dial:after{background:#85ebc0;box-shadow:0 0 0 5px #85ebc015}.buttons,nav{display:flex;gap:9px}.buttons button{flex:1;padding:13px 6px;font-size:11px}.control-icon{display:block;margin-bottom:8px}.control-icon .icon{height:19px;width:19px}.hero [data-action="start"]{background:#83e2b8;color:#123e32;box-shadow:0 4px 12px #0001}.hero [data-action="start"]:hover:not(:disabled){background:#a4efcf}.hero [data-action="pause"]{background:#ffffff0c;color:#d2e8df;border-color:#57776c77}.hero [data-action="stop"]{background:#ffffff0c;color:#e9d6d4;border-color:#98757166}.hero [data-action="pause"]:hover:not(:disabled),.hero [data-action="stop"]:hover:not(:disabled){background:#ffffff18}label{display:block;font-size:11px;font-weight:500;color:#71898b;margin:18px 0 10px}input,textarea{display:block;width:100%;padding:13px 14px;color:#233d43;background:#f7f9f9;border:1px solid #dfe8e7;border-radius:11px;margin-top:8px;outline:none;font-size:14px;transition:border-color .2s}input:focus,textarea:focus{border-color:#43a18f;box-shadow:0 0 0 3px #43a18f12}input::placeholder,textarea::placeholder{color:#92a3a5}textarea{resize:vertical;min-height:74px}.hero label{color:#9bbcB3;font-size:10px;margin-top:24px}.hero textarea{color:#eaf8ef;background:#0c2b2d66;border-color:#426a60;font-size:12px}.hero textarea::placeholder{color:#799c91}.hero textarea:focus{border-color:#83e2b8}.cases-card{grid-area:cases}.manual-card{grid-area:manual}.messages-card{grid-area:messages}.today-card{grid-area:today;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#e6f1ec;border-color:#d3e7dc;padding:24px}.today-card .eyebrow{color:#699783;font-size:8px;letter-spacing:1px}.today-card .icon{width:12px;height:12px;margin-right:3px}.today-card h2{margin-top:9px;font-size:14px;color:#355e4e}#today{font-size:19px;font-weight:600;letter-spacing:-.6px;margin:0;color:#237d5d;text-align:right}#case-tabs{padding:4px;border-radius:11px;background:#f1f5f4}#case-tabs button{flex:1;min-height:35px;padding:8px;background:white;color:#30695c;box-shadow:0 2px 5px #23483a0c;font-size:11px}#case-tabs button[aria-pressed="false"]{background:transparent;color:#829491;box-shadow:none}#search-form{display:flex;align-items:end;gap:8px;margin:18px 0 14px}#search-form label{flex:1;margin:0;font-size:0}#search-form input{margin:0;font-size:12px}#search-form button{min-height:43px;padding:10px 13px;background:#eaf2ef;color:#467769;font-size:11px}.case{display:block;position:relative;width:100%;text-align:left;border:1px solid #e1eae7;background:white;color:#2d4644;margin:10px 0;padding:16px 30px 16px 16px;font-size:12px;line-height:1.5;border-radius:13px}.case:after{content:"›";position:absolute;right:14px;top:19px;font-size:23px;line-height:1;color:#a2b6ae}.case:hover:not(:disabled){background:#f1f7f4;border-color:#b6d2c3}.case.selected{border-color:#90bea8;background:#f3f9f5}.case small{display:block;margin-top:7px;font-weight:400;color:#8a9e96;font-size:10px;line-height:1.65}.row{border-top:1px solid #e8eeee;padding:16px 0;font-size:12px;line-height:1.6}.row p{color:#849793;font-size:11px;margin:6px 0}.row button{margin:9px 8px 0 0;font-size:11px;padding:10px 12px;min-height:40px}.hero .row{border-color:#43665b;color:#adcfc2}.hero .row button{background:#35564b;color:#d9efe5}.hero .row p{color:#adcfc2}#messages .row:first-child{border:0;padding-top:0}#messages strong{font-size:12px;color:#405951}#messages+[data-action="read"]{width:100%;font-size:10px;background:#f0f5f3;color:#6d8b7f;min-height:42px;margin-top:12px}.manual-card form{display:flex;gap:12px;align-items:end;flex-wrap:wrap}.manual-card label{flex:1;min-width:105px;margin:0}.manual-card button{flex:1;min-width:155px;font-size:10px;background:#eaf2ef;color:#467769}.secondary{background:#edf3f0;color:#527365;border-color:#d6e4dc}.phone-card{grid-area:phone}.phone-card>.section-hint{margin-top:-12px}.call-heading{display:flex;align-items:center;gap:7px;margin:24px 0 12px}.call-heading h3{font-size:10px;letter-spacing:.25px;margin:0;color:#55766a;font-weight:600}.live-dot{width:5px;height:5px;border-radius:50%;background:#59aa87}.count-badge{margin-left:auto;background:#e7f3ec;color:#2d8b63;border-radius:8px;padding:4px 7px;font-size:10px;font-weight:600}.incoming-call{position:relative;border:1px solid #bce0cc;background:#f0faf4;border-radius:13px;padding:14px 14px 14px 46px;margin:8px 0}.incoming-call:before{content:"↙";position:absolute;left:14px;top:13px;font-size:22px;color:#389672}.incoming-call strong{font-size:12px;color:#397457}.incoming-call p,#phone-notice{font-size:10px;overflow-wrap:anywhere;color:#719383;margin:5px 0 0}#incoming-calls{font-size:11px;color:#81968d}#contact-search-form{display:flex;gap:9px;align-items:end;flex-wrap:wrap;margin-top:22px}#contact-search-form label{flex:1;min-width:150px;margin:0;font-size:10px}#contact-search-form input{font-size:12px}#contact-search-form button{font-size:11px;padding:13px 12px}#contact-results .row{margin-top:12px}#contact-results button{max-width:100%;overflow-wrap:anywhere;background:#edf6f1;color:#267655;border:1px solid #cee4d8}#phone-notice:empty{display:none}#phone-notice:not(:empty){margin-top:16px;padding:12px;background:#f3f8f4;border-radius:10px}#check-call,#clear-call{margin-top:10px;font-size:11px}.remote-footer{grid-area:footer;display:flex;justify-content:space-between;align-items:center;gap:16px;color:#98aaa6;font-size:9px;padding:12px 4px}.remote-footer button{background:transparent;color:#879d94;border:0;min-height:36px;padding:8px 0;font-size:9px}.remote-nav{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);width:330px;max-width:calc(100% - 36px);padding:7px;border:1px solid #dde8e1;border-radius:22px;background:#fffffff2;backdrop-filter:blur(16px);box-shadow:0 6px 30px #17452d15;z-index:20;gap:4px}.remote-nav a{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:50px;border-radius:15px;font-size:9px;color:#8c9d95}.remote-nav .icon{width:20px;height:20px}.remote-nav a[aria-current]{background:#eaf4ee;color:#277a54}.warning,#deadline.warning{color:#ad7b26;background:#fff7e4;border-color:#f1d79e}.overdue,#deadline.overdue{color:#b6545e;background:#fff0f0;border-color:#efc6cc}.case small.warning,.case small.overdue{padding:3px 6px;border-radius:5px}#notice:empty{display:none}#notice:not(:empty),#uncertain{background:#fff8e8;border:1px solid #eadcbc;border-radius:13px;padding:14px;margin:0 0 16px;color:#977d43;font-size:12px}#uncertain button{font-size:12px;margin-right:8px}.remote-manage main{max-width:760px}.remote-manage section{margin:18px 0}.remote-manage section>p{color:#85958d;font-size:12px}.back-link{font-size:12px;border:1px solid #d4e2d9;padding:9px 12px;border-radius:10px}.remote-manage .clock{font-size:clamp(23px,6vw,40px);letter-spacing:1px;overflow-wrap:anywhere;color:#267951}.remote-manage #new-code{padding:18px;background:#eff7f1;border-radius:14px;margin-top:18px}.remote-manage #code{display:block;margin:12px 0}.login-emblem{width:56px;height:56px;display:grid;place-items:center;border-radius:18px;background:#eaf5ed;color:#328f69;margin:0 auto 25px}.login-emblem .icon{width:29px;height:29px}#login{max-width:420px;margin:55px auto;padding:32px;box-shadow:0 16px 45px #18483308}#login h2{font-size:25px;text-align:center;margin:12px 0 10px;letter-spacing:-.8px}#login>.eyebrow{text-align:center;font-size:8px}.login-intro{text-align:center;font-size:12px;color:#8c9e96;margin-bottom:28px}#login button{width:100%;margin-top:17px;background:#184d3e;border-radius:12px}#pin{letter-spacing:7px}#code{letter-spacing:1.5px}dialog{width:calc(100% - 32px);max-width:480px;border:1px solid #dbe5df;border-radius:22px;background:white;color:#2c4940;padding:26px;box-shadow:0 20px 80px #0002}dialog p{font-size:13px;color:#8a9d92}dialog::backdrop{background:#14352e66;backdrop-filter:blur(6px)}dialog .buttons{flex-wrap:wrap}dialog button{min-width:100px}[hidden]{display:none!important} +@media(min-width:700px){.remote-mobile{padding-bottom:105px}.remote-nav{bottom:20px}.hero{min-height:535px}} +@media(max-width:699px){html{scroll-padding-top:24px}body{padding:22px 18px calc(105px + env(safe-area-inset-bottom))}.app-header{gap:12px;margin-bottom:22px;padding-bottom:19px;align-items:start}.brand-mark{width:42px;height:42px;font-size:25px;border-radius:13px}.brand{gap:10px}.brand .eyebrow{font-size:8px;letter-spacing:1.3px}h1{font-size:20px}.brand-subtitle{font-size:9px;margin-top:5px}#connection{font-size:8px;padding:8px;gap:5px;max-width:125px;line-height:1.5}#connection:before{width:5px;height:5px;flex-basis:5px}#controls{display:flex;flex-direction:column;gap:16px}#controls>section{width:100%;padding:21px;border-radius:20px}.workspace-heading{width:100%;order:-1;margin:0 0 1px}.workspace-heading h2{font-size:26px;margin-top:5px}#remote-date{font-size:10px;max-width:105px;text-align:right}.workspace-heading .eyebrow{font-size:8px;letter-spacing:1.4px}.hero{order:0}.today-card{order:1}.phone-card{order:2}.cases-card{order:3}.manual-card{order:4}.messages-card{order:5}.remote-footer{order:6;width:100%;font-size:8px}.remote-footer button{font-size:8px}#active-case{font-size:20px;margin:15px 0 10px}.timer-dial{width:226px;height:226px;margin:18px auto 21px}.clock{font-size:44px;letter-spacing:-1.8px}.buttons button{font-size:10px;padding:12px 5px;border-radius:11px}.hero label{margin-top:21px}.section-heading{margin-bottom:18px}.section-hint{font-size:9px}.section-heading h2{font-size:15px}#today{font-size:16px}.today-card h2{font-size:12px}.today-card .eyebrow{font-size:7px}.today-card .icon{width:11px;height:11px}.remote-nav{bottom:calc(12px + env(safe-area-inset-bottom))}#login{margin:35px auto;padding:27px}.remote-manage h1{font-size:17px}.remote-manage .brand .eyebrow{font-size:8px}.back-link{font-size:10px}} +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}} + +@media(max-width:359px){.app-header{flex-wrap:wrap}#connection{max-width:100%;margin-left:52px}} + +/* Day plan leads the remote workspace; avoid fixed blurred layers on mobile. */ +#controls{grid-template-areas:"heading heading" "day hero" "day today" "cases manual" "phone messages" "footer footer"} +.day-card{grid-area:day}.day-card .section-heading{margin-bottom:12px} +.day-plan-item{display:flex;align-items:flex-start;gap:12px;padding:13px 0;border-bottom:1px solid var(--line)} +.day-plan-item:last-child{border-bottom:0}.day-plan-time{flex:0 0 86px;font-size:12px;font-weight:650;color:var(--accent)} +.day-plan-item strong{font-size:14px;display:block}.day-plan-item small{display:block;font-size:11px;color:var(--muted);margin:4px 0} +.day-plan-item button{font-size:11px;padding:7px 11px;min-height:32px;margin-top:6px} +#day-status,#screen-awake-status{display:block;font-size:11px;color:var(--muted);margin:8px 0} +#screen-awake{font-size:11px;min-height:32px;padding:7px 10px} +.remote-nav{background:#fff;backdrop-filter:none} +@media(max-width:699px){.day-card{order:-1}.workspace-heading{order:-2}.remote-nav{position:sticky;transform:none;left:auto;bottom:0;width:100%;max-width:none;order:7;margin-top:10px}.timer-dial{width:170px;height:170px;margin:12px auto}.timer-dial .clock{font-size:33px}} diff --git a/static/js/bottom-bar.js b/static/js/bottom-bar.js index 238ec11..fd3a13e 100644 --- a/static/js/bottom-bar.js +++ b/static/js/bottom-bar.js @@ -2189,11 +2189,13 @@ select.innerHTML=''; select.hidden=true;select.disabled=true;panel.dataset.workflowActive='true'; byId('bbConvertRecommendation').textContent='Afregning vælges automatisk. Bogholderiet gennemgår og godkender tiden.'; - byId('bbConvertSubmit').disabled=true; + byId('bbConvertSubmit').disabled=false; + byId('bbConvertSubmit').textContent='Gem tid'; return; } select.hidden=false;select.disabled=false;panel.dataset.workflowActive='false'; byId('bbConvertSubmit').disabled=false; + byId('bbConvertSubmit').textContent='Konverter tid'; const recommended = options.recommended || { method: 'invoice' }; let html = ''; (options.prepaid_cards || []).forEach(function (card) { @@ -2218,7 +2220,7 @@ const button = byId('bbConvertSubmit'); const timeId = Number((panel && panel.dataset.timeId) || 0); if (!timeId || !button) return; - if(panel.dataset.workflowActive==='true')return; + const workflowActive=panel.dataset.workflowActive==='true'; const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':'); const payload = { billing_method: selected[0], @@ -2228,9 +2230,10 @@ }; if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]); if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]); + if(workflowActive){delete payload.billing_method;delete payload.prepaid_card_id;delete payload.fixed_price_agreement_id;} button.disabled = true; try { - const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + const response = await fetch('/api/v1/timetracking/time/' + timeId + (workflowActive?'/register':'/approve'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await response.json().catch(function () { return {}; }); if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden'); panel.classList.add('d-none'); diff --git a/static/js/case-command-composer.js b/static/js/case-command-composer.js index 4d3fb29..e3453c6 100644 --- a/static/js/case-command-composer.js +++ b/static/js/case-command-composer.js @@ -20,7 +20,7 @@ panel.setAttribute('aria-label', 'Kommandoer og forhåndsvisning'); const hint = document.createElement('div'); hint.className = 'case-command-hint'; - hint.textContent = 'Skriv / på en ny linje for handlinger. Gem udfører kommandoerne. Vælg filer uploader særskilt med det samme.'; + hint.textContent = 'Skriv / for handlinger.'; const suggestions = document.createElement('div'); suggestions.className = 'case-command-suggestions'; const help = document.createElement('details'); @@ -48,7 +48,7 @@ gruppe:'Support "Begrundelse"', mail:'"Mailkladde"', note:'"Intern note"' }; help.className='case-command-help-disclosure'; - helpTitle.textContent='Kommandoer · hjælp og eksempler'; + helpTitle.textContent='/ Hjælp'; const intro=document.createElement('p');intro.className='case-command-help-intro'; intro.textContent='Klik på en kommando for at indsætte et eksempel. Ret teksten og kontrollér handlingerne før gemning.'; helpList.append(intro); @@ -189,12 +189,31 @@ button.addEventListener('click',()=>{ if(frozenText!==null)return; input.setRangeText(option.value,line.start+option.from,line.start+option.to,'end'); + if(option.caret!==undefined) { + const caret=line.start+option.from+option.caret; + input.setSelectionRange(caret,caret); + } input.focus();update(); });suggestions.append(button); }); } function update() { generation++; clearTimeout(timer); serverValid = false; + // Insert the required text delimiters as soon as the preceding + // argument is complete, for both typing and menu selections. + if (frozenText === null && window.CaseCommandSuggestions) { + const line = currentLine(); + const cursor = input.selectionStart - line.start; + if (cursor === line.text.length && /\s$/.test(line.text)) { + const textOption = window.CaseCommandSuggestions.suggest(line.text, cursor, completionValues) + .find(option => option.value === '"" ' && option.from === cursor); + if (textOption) { + input.setRangeText('""', input.selectionStart, input.selectionStart, 'end'); + const caret = line.start + cursor + 1; + input.setSelectionRange(caret, caret); + } + } + } parsed = window.CaseCommands.parse(input.value,{now:new Date()}); chips.replaceChildren(); parsed.commands.forEach(command => { @@ -256,6 +275,7 @@ if(!response.ok) throw new Error('Mailkladder kunne ikke hentes.'); const rows=await response.json(); draftList.replaceChildren(); draftTitle.textContent=`Mine gemte mailkladder (${rows.length})`; + drafts.hidden=!rows.length; if(!rows.length) draftList.textContent='Ingen gemte mailkladder.'; rows.forEach(draft=>{ const button=document.createElement('button'); button.type='button'; @@ -318,13 +338,42 @@ input.addEventListener('keyup',event=>{if(!['ArrowDown','ArrowUp','Enter','Escape'].includes(event.key)) renderSuggestions();}); input.addEventListener('keydown',event=>{ const options=[...suggestions.querySelectorAll('button')]; + if (event.key === 'Enter' && suggestionIndex < 0 && !event.shiftKey + && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing + && frozenText === null && input.selectionStart === input.selectionEnd) { + const line = currentLine(); + const cursor = input.selectionStart - line.start; + if (/^\s*\/\/?\S+/.test(line.text)) { + let opening = -1; + for (let index = 0; index < line.text.length; index++) { + if (line.text[index] === '\\' && opening >= 0 + && ['"', '\\'].includes(line.text[index + 1])) { index++; continue; } + if (line.text[index] !== '"') continue; + if (opening < 0) opening = index; + else { + if (cursor > opening && cursor <= index) { + event.preventDefault(); + const end = line.start + line.text.length; + input.setRangeText('\n', end, end, 'end'); + update(); + return; + } + opening = -1; + } + } + } + } if(!options.length) return; if(event.key==='Escape') {suggestions.replaceChildren();suggestionIndex=-1;return;} if(['ArrowDown','ArrowUp'].includes(event.key)) { event.preventDefault(); suggestionIndex=(suggestionIndex+(event.key==='ArrowDown'?1:-1)+options.length)%options.length; options.forEach((option,index)=>{option.classList.toggle('is-active',index===suggestionIndex);option.setAttribute('aria-pressed',String(index===suggestionIndex));}); options[suggestionIndex].scrollIntoView({block:'nearest'}); - } else if(event.key==='Enter' && suggestionIndex>=0) {event.preventDefault();options[suggestionIndex].click();} + } else if(event.key==='Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey + && !event.altKey && !event.isComposing && frozenText===null + && input.selectionStart===input.selectionEnd) { + event.preventDefault();options[suggestionIndex>=0?suggestionIndex:0].click(); + } }); update(); try { diff --git a/static/js/case-command-suggestions.js b/static/js/case-command-suggestions.js index ed20c2c..03e4a5f 100644 --- a/static/js/case-command-suggestions.js +++ b/static/js/case-command-suggestions.js @@ -2,12 +2,21 @@ (function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;if(root)root.CaseCommandSuggestions=api;})(typeof window!=='undefined'?window:globalThis,function(){ 'use strict'; const names=['tid','tildel','forventet','undersag','mig','todo','afventer','løsning','deadline','udsat','fil','hjælp','timer','relation','prioritet','gruppe','mail','note']; + const textCommands=new Set(['undersag','todo','note','mail','løsning']); const descriptions={tid:'Registrer tid',tildel:'Vælg medarbejder',forventet:'Forventet tid',undersag:'Ny undersag',mig:'Tildel mig',todo:'Ny opgave',afventer:'Afventer kunde',løsning:'Ny løsning',deadline:'Deadline',udsat:'Udsæt sagen',fil:'Vedhæft filer',hjælp:'Vis hjælp',timer:'Start eller pause',relation:'Kobl sag',prioritet:'Prioritet',gruppe:'Vælg gruppe',mail:'Mailkladde',note:'Intern note'}; const quote=value=>'"'+String(value).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"'; + const initials=user=>String(user.full_name||'').trim().split(/\s+/).filter(Boolean).map(part=>part[0]).join('').toLocaleLowerCase('da'); + function employeeToken(user,users){ + const short=initials(user); + const matches=token=>users.filter(candidate=>[String(candidate.user_id),String(candidate.username||'').toLocaleLowerCase('da'),String(candidate.full_name||'').trim().toLocaleLowerCase('da'),initials(candidate)].includes(token.toLocaleLowerCase('da'))).length; + if(short && matches(short)===1)return short; + if(user.username && matches(user.username)===1)return user.username; + return quote(user.full_name||user.username||''); + } function suggest(line,cursor,values={}){ const before=line.slice(0,cursor),match=/^(\s*)(\/\/|\/)([^\s]*)/.exec(before);if(!match)return []; const start=match[1].length,prefix=match[2],name=match[3].toLowerCase(),commandEnd=start+prefix.length+(/^(?:\s*)(?:\/\/|\/)([^\s]*)/.exec(line)?.[1] || name).length; - if(cursor<=commandEnd)return names.filter(n=>n.startsWith(name)).map(n=>({label:`${prefix}${n}`,hint:descriptions[n],value:`${prefix}${n} `,from:start,to:commandEnd})); + if(cursor<=commandEnd)return names.filter(n=>n.startsWith(name)).map(n=>({label:`${prefix}${n}`,hint:descriptions[n],value:`${prefix}${n} ${textCommands.has(n)?'""':''}`,caret:textCommands.has(n)?prefix.length+n.length+2:undefined,from:start,to:commandEnd})); const argsStart=commandEnd+1; const matches=[...line.slice(argsStart).matchAll(/"(?:\\.|[^"\\])*(?:"|$)|\S+/g)].map(m=>({text:m[0],start:argsStart+m.index,end:argsStart+m.index+m[0].length})); let index=matches.findIndex(t=>cursor>=t.start&&cursor<=t.end);if(index<0)index=matches.filter(t=>t.end[v,v,'Minutter eller timer']); if(name==='tid'&&index===1)options=['research','support','development','troubleshooting','on_site','meeting','other'].map(v=>[v,v,'Arbejdets kategori']); - if((name==='tildel'&&index===0)||(name==='todo'&&index===1&&(query.startsWith('@')||token?.text.startsWith('@')||!query)))options=(values.users||[]).map(u=>[u.full_name||u.username,`${name==='todo'?'@':''}${u.user_id}`,u.username||'Medarbejder']); + if((name==='tildel'&&index===0)||(name==='todo'&&index===1&&(query.startsWith('@')||token?.text.startsWith('@')||!query)))options=(values.users||[]).map(u=>{const token=employeeToken(u,values.users||[]);return [u.full_name||u.username,name==='todo'?(token.startsWith('"')?quote('@'+(u.full_name||u.username)):'@'+token):token,`${initials(u)} · ${u.username||'Medarbejder'}`];}); if(name==='gruppe'&&index===0)options=(values.groups||[]).map(g=>[g.name,quote(g.name),'Gruppe']); if(name==='prioritet'&&index===0)options=['lav','normal','høj','kritisk'].map(v=>[v,v,'Prioritet']); if(name==='afventer'&&index===0)options=[['kunde','kunde','Afvent kunden og opret todo']]; @@ -29,8 +38,8 @@ if(name==='todo'&&((index===1&&!token?.text.startsWith('@'))||index===2))options.push(...['i morgen','fredag','mandag'].map(v=>[v,v,'Opgavens frist'])); const textFields={undersag:[0,'Titel'],todo:[0,'Opgave'],note:[0,'Intern note'],mail:[0,'Mailtekst'],løsning:[0,'Løsning'],tildel:[1,'Begrundelse'],gruppe:[1,'Begrundelse'],prioritet:[1,'Begrundelse'],afventer:[1,'Begrundelse'],tid:[2,'Beskrivelse']}; const field=textFields[name] || (name==='timer' && matches[0]?.text==='start' ? [1,'Beskrivelse'] : null); - if(field && index===field[0] && !query)options.push([`Skriv ${field[1].toLowerCase()}`,quote(field[1]),'Tekst i dobbelte anførselstegn']); - return options.filter(([label,value,hint])=>`${label} ${value} ${hint}`.toLocaleLowerCase('da').includes(query)).slice(0,30).map(([label,value,hint])=>({label,hint,value:value+' ',from,to})); + if(field && index===field[0] && !query)options.push([`Skriv ${field[1].toLowerCase()}`,quote(''),'Tekst i dobbelte anførselstegn']); + return options.filter(([label,value,hint])=>`${label} ${value} ${hint}`.toLocaleLowerCase('da').includes(query)).slice(0,30).map(([label,value,hint])=>({label,hint,value:value+' ',caret:value==='""'?1:undefined,from,to})); } return {suggest}; }); diff --git a/static/js/case-create-commands.js b/static/js/case-create-commands.js new file mode 100644 index 0000000..c4828e7 --- /dev/null +++ b/static/js/case-create-commands.js @@ -0,0 +1,168 @@ +/* Creation uses the existing command parser and case command endpoints. */ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + if(root && root.document) api.init(root); +})(typeof window!=='undefined'?window:null,function(){ + 'use strict'; + class CommandRun { + constructor(request,uuid){this.request=request;this.uuid=uuid;this.pending=null;} + attach(caseId,commands){ + if(!commands.length)return; + this.pending={caseId,body:{request_id:this.uuid(),comment:'',internal:true,billing_mode:'automatic',commands}}; + } + async execute(){ + if(!this.pending)return; + const {caseId,body}=this.pending; + await this.request(`/api/v1/sag/${caseId}/command-execute`,body); + this.pending=null; + return caseId; + } + } + function init(root){ + const doc=root.document, input=doc.getElementById('beskrivelse'); + if(!input || !root.CaseCommands || !root.CaseCommandSuggestions)return; + const config=JSON.parse(doc.getElementById('caseCreateCommandData').textContent); + const values={users:(config.users||[]).map(user=>({...user,full_name:user.full_name,username:user.username||user.display_name})),groups:config.groups||[]}; + const panel=doc.createElement('section');panel.className='cc-command-tools'; + const toolbar=doc.createElement('div');toolbar.className='cc-command-toolbar'; + const hint=doc.createElement('small');hint.textContent='Skriv / på en ny linje for handlinger.'; + const help=doc.createElement('details'),summary=doc.createElement('summary');summary.textContent='/ Hjælp'; + const helpList=doc.createElement('div');helpList.className='cc-command-help';help.append(summary,helpList); + const suggestions=doc.createElement('div');suggestions.className='case-command-suggestions'; + const preview=doc.createElement('div');preview.className='cc-command-preview';preview.setAttribute('aria-live','polite'); + const recovery=doc.createElement('div');recovery.className='cc-command-recovery';recovery.hidden=true; + toolbar.append(hint,help);panel.append(toolbar,suggestions,preview,recovery);input.after(panel); + let active=-1, options=[],storageKey=null,prepared=null; + const request=async(url,body)=>{ + const response=await root.fetch(url,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const data=await response.json(); + if(!response.ok){ + const error=new Error(typeof data.detail==='string'?data.detail:'Kommandoerne kunne ikke udføres.'); + error.definite=[400,403,404,409,422].includes(response.status); + throw error; + } + return data; + }; + const run=new CommandRun(request,()=>root.crypto.randomUUID()); + const ready=(async()=>{ + try { + const response=await root.fetch('/api/v1/auth/me',{credentials:'include'}); + if(!response.ok)return; + const actor=await response.json(); + if(!actor.id && !actor.user_id)return; + storageKey=`bmc:create-command-run:${actor.id||actor.user_id}`; + const stored=root.sessionStorage.getItem(storageKey); + if(stored){run.pending=JSON.parse(stored);if(run.pending.draftText)input.value=run.pending.draftText;} + } catch(_) { /* The current page still supports an in-memory retry. */ } + showRecovery(); + })(); + function persist(){try{if(storageKey){if(run.pending)root.sessionStorage.setItem(storageKey,JSON.stringify(run.pending));else root.sessionStorage.removeItem(storageKey);}}catch(_){}} + function showRecovery(){ + recovery.replaceChildren();recovery.hidden=!run.pending; + if(!run.pending)return; + input.readOnly=!run.pending.canEdit; + const link=doc.createElement('a');link.href=`/sag/${run.pending.caseId}/v5`;link.textContent=`Åbn den oprettede sag #${run.pending.caseId}`; + recovery.append(doc.createTextNode('Sagen er oprettet. Kommandoerne afventer. '),link); + doc.getElementById('submitBtn').textContent=run.pending.canEdit?'Gem rettede kommandoer':'Prøv kommandoerne igen'; + } + function line(){const start=input.value.lastIndexOf('\n',input.selectionStart-1)+1;const end=input.value.indexOf('\n',input.selectionStart);return {start,text:input.value.slice(start,end<0?input.value.length:end)};} + function choose(option){ + if(input.readOnly)return; + const current=line(); + input.setRangeText(option.value,current.start+option.from,current.start+option.to,'end'); + if(option.caret!==undefined){const caret=current.start+option.from+option.caret;input.setSelectionRange(caret,caret);} + input.focus();update();input.dispatchEvent(new root.Event('input',{bubbles:true})); + } + function renderSuggestions(){ + suggestions.replaceChildren();active=-1; + const current=line();options=root.CaseCommandSuggestions.suggest(current.text,input.selectionStart-current.start,values); + options.forEach(option=>{ + const button=doc.createElement('button');button.type='button'; + const title=doc.createElement('strong');title.textContent=option.label; + const detail=doc.createElement('small');detail.textContent=option.hint; + button.append(title,detail);button.addEventListener('click',()=>choose(option));suggestions.append(button); + }); + } + function update(){ + if(run.pending){showRecovery();if(!run.pending.canEdit)return;} + const current=line(),cursor=input.selectionStart-current.start; + if(cursor===current.text.length && /\s$/.test(current.text)){ + const option=root.CaseCommandSuggestions.suggest(current.text,cursor,values).find(row=>row.value==='"" '&&row.from===cursor); + if(option){input.setRangeText('""',input.selectionStart,input.selectionStart,'end');const caret=current.start+cursor+1;input.setSelectionRange(caret,caret);} + } + const parsed=root.CaseCommands.parse(input.value); + if(parsed.commands.some(command=>command.type==='help'))help.open=true; + preview.replaceChildren(); + parsed.commands.forEach(command=>{ + const chip=doc.createElement('span');chip.className='cc-command-chip'; + const target=command.scope==='main'?'Ny sag':`Undersag ${command.scope+1}`; + const name=root.CaseCommands.names.find(name=>({tid:'time',tildel:'assign',forventet:'estimate',undersag:'subcase',mig:'self',afventer:'waiting',løsning:'solution',udsat:'defer',fil:'file',hjælp:'help',prioritet:'priority',gruppe:'group'}[name]||name)===command.type)||command.type; + chip.textContent=`/${name} · ${command.title||command.assignee||command.minutes&&command.minutes+' min'||command.date||command.action||target} · ${target}`; + preview.append(chip); + }); + if(parsed.errors.length){const error=doc.createElement('small');error.className='text-danger';error.textContent=parsed.errors.map(error=>`Linje ${error.line}: ${error.message}`).join(' · ');preview.append(error);} + renderSuggestions(); + } + root.CaseCommands.names.forEach(name=>{ + const button=doc.createElement('button');button.type='button';button.textContent='/'+name; + button.addEventListener('click',()=>{ + if(input.readOnly)return; + const prefix=input.value&&!input.value.endsWith('\n')?'\n':''; + input.value+=prefix+'/'+name+' ';input.setSelectionRange(input.value.length,input.value.length);input.focus();update();input.dispatchEvent(new root.Event('input',{bubbles:true})); + });helpList.append(button); + }); + input.addEventListener('input',update);input.addEventListener('click',renderSuggestions); + input.addEventListener('keydown',event=>{ + if(event.isComposing||event.ctrlKey||event.metaKey||event.altKey||event.shiftKey)return; + if(event.key==='Escape'){suggestions.replaceChildren();options=[];active=-1;return;} + if(event.key==='ArrowDown'||event.key==='ArrowUp'){ + if(!options.length)return;event.preventDefault();active=(active+(event.key==='ArrowDown'?1:-1)+options.length)%options.length; + [...suggestions.children].forEach((button,index)=>button.classList.toggle('is-active',index===active));return; + } + if(event.key!=='Enter'||input.selectionStart!==input.selectionEnd)return; + const current=line(),cursor=input.selectionStart-current.start; + if(active<0&&/^\s*\/\/?\S+/.test(current.text)){ + let opening=-1; + for(let index=0;index=0&&['"','\\'].includes(current.text[index+1])){index++;continue;} + if(current.text[index]!=='"')continue; + if(opening<0)opening=index; + else {if(cursor>opening&&cursor<=index){event.preventDefault();const end=current.start+current.text.length;input.setRangeText('\n',end,end,'end');update();input.dispatchEvent(new root.Event('input',{bubbles:true}));return;}opening=-1;} + } + } + if(options.length){event.preventDefault();choose(options[active>=0?active:0]);} + }); + root.caseCreateCommands={ + ready, + prepare(){ + const parsed=root.CaseCommands.parse(input.value); + if(parsed.errors.length)throw new Error(parsed.errors.map(error=>`Linje ${error.line}: ${error.message}`).join(' · ')); + if(!parsed.comment)throw new Error('Skriv også en beskrivelse af sagen over kommandoerne.'); + if(parsed.commands.some(command=>command.type==='file'))throw new Error('Vedhæft filer på sagen efter oprettelse. Fjern /fil før du opretter.'); + prepared=parsed;return parsed; + }, + hasPending:()=>Boolean(run.pending), + async created(result){run.attach(result.id,prepared?.commands||[]);if(run.pending)run.pending.draftText=input.value;persist();showRecovery();await this.retry();}, + async retry(){ + if(!run.pending)return; + if(run.pending.canEdit){ + const caseId=run.pending.caseId; + const parsed=this.prepare(); + run.attach(caseId,parsed.commands); + // An empty command list explicitly finishes the remaining step. + if(!parsed.commands.length){run.pending=null;persist();return;} + run.pending.draftText=input.value;persist(); + } + input.readOnly=true; + try {await run.execute();persist();showRecovery();} + catch(error){run.pending.canEdit=Boolean(error.definite);persist();showRecovery();throw new Error(`Sag #${run.pending.caseId} er oprettet, men kommandoerne afventer: ${error.message}`);} + finally {input.readOnly=Boolean(run.pending&&!run.pending.canEdit);} + }, + setCreating:busy=>{input.readOnly=busy||Boolean(run.pending&&!run.pending.canEdit);}, + pendingCaseId:()=>run.pending?.caseId + }; + update(); + } + return {CommandRun,init}; +}); diff --git a/static/js/case-v5.js b/static/js/case-v5.js index 7448cae..306b768 100644 --- a/static/js/case-v5.js +++ b/static/js/case-v5.js @@ -90,31 +90,48 @@ syncDateLimits(); const board = document.createElement('div'); board.className = 'v5-board'; - const facts = document.createElement('aside'); - facts.className = 'v5-case-facts'; - facts.setAttribute('aria-label', 'Kunde og sagsoplysninger'); const center = document.createElement('main'); const modules = document.createElement('aside'); modules.className = 'v5-case-modules'; modules.setAttribute('aria-label', 'Todo, filer og moduler'); const tabs = byId('caseTabs'); tabs.before(board); - board.append(facts, center, modules); + board.append(center, modules); const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')]; - [3, 1, 0].forEach(index => { if (widgets[index]) facts.append(widgets[index]); }); - const factsDisclosure = document.createElement('details'); - factsDisclosure.className = 'v5-facts-disclosure'; - const factsSummary = document.createElement('summary'); - factsSummary.textContent = 'Kunde, kontakt og sagsoplysninger'; - const factsContent = document.createElement('div'); - factsContent.className = 'v5-facts-content'; - factsContent.append(...facts.children); - factsDisclosure.append(factsSummary, factsContent); - facts.append(factsDisclosure); - const compactScreen = window.matchMedia('(max-width: 1400px)'); - function adaptFacts(event) { factsDisclosure.open = !event.matches; } - adaptFacts(compactScreen); - compactScreen.addEventListener('change', adaptFacts); + // Move the original controls into hero menus: handlers, IDs and values survive. + const factsMenu = document.createElement('nav'); + factsMenu.className = 'v5-hero-facts-menu'; + factsMenu.setAttribute('aria-label', 'Kontakt, tildeling og klassifikation'); + [[3, 'Kontakt', 'person-vcard'], [1, 'Tildeling', 'people'], [0, 'Klassifikation', 'tags']].forEach(([index, label, icon]) => { + const card = widgets[index]; + if (!card) return; + const dropdown = document.createElement('div'); + dropdown.className = 'dropdown'; + const button = document.createElement('button'); + button.type = 'button'; + button.id = `v5HeroFacts${index}`; + button.className = 'v5-hero-facts-button dropdown-toggle'; + button.dataset.bsToggle = 'dropdown'; + button.dataset.bsAutoClose = 'outside'; + button.setAttribute('aria-expanded', 'false'); + button.setAttribute('aria-controls', `v5HeroFactsPanel${index}`); + button.innerHTML = `${label}`; + const panel = document.createElement('div'); + panel.id = `v5HeroFactsPanel${index}`; + panel.className = 'dropdown-menu v5-hero-facts-panel v5-case-facts'; + panel.setAttribute('aria-labelledby', button.id); + panel.append(card); + dropdown.append(button, panel); + factsMenu.append(dropdown); + // Open secondary dialogs without leaving this popup above their backdrop. + panel.addEventListener('click', event => { + const target = event.target.closest('button, a'); + if (target && !target.matches('[data-bs-toggle="dropdown"]')) { + bootstrap.Dropdown.getOrCreateInstance(button).hide(); + } + }); + }); + shell.querySelector('.case-header-copy').append(factsMenu); const anyDeskButton = document.createElement('button'); anyDeskButton.id = 'v5AnyDeskOpenButton'; anyDeskButton.type = 'button'; @@ -170,7 +187,30 @@ details.prepend(detailsRow); detailsRow.append(left); modules.append(right); + // Keep the existing call action and data; give the narrow sidebar a list layout. + byId('caseCallHistoryRows')?.querySelectorAll('tr').forEach(row => { + const cells = row.querySelectorAll('td'); + const callButton = cells[2]?.querySelector('button'); + if (callButton) { + callButton.title = `Ring til ${cells[2].querySelector('span')?.textContent.trim() || 'nummeret'}`; + callButton.setAttribute('aria-label', callButton.title); + callButton.innerHTML = ''; + } + if (cells[1]) { + const outgoing = cells[1].textContent.trim() === 'Udgående'; + const icon = document.createElement('i'); + icon.className = `bi bi-arrow-${outgoing ? 'up-right' : 'down-left'}`; + icon.setAttribute('aria-hidden', 'true'); + cells[1].prepend(icon); + } + }); const rightGrid = right.querySelector('.right-modules-grid'); + const tagsCard = rightGrid?.querySelector('[data-module="tags"]'); + if (tagsCard) { + rightGrid.prepend(tagsCard); + tagsCard.classList.remove('d-none'); + tagsCard.style.setProperty('display', 'flex', 'important'); + } ['todo-steps', 'files', 'hardware', 'time'].reverse().forEach(key => { const card = shell.querySelector(`.right-module-card[data-module="${key}"]`); if (card) rightGrid.prepend(card); @@ -203,6 +243,39 @@ const legacyChips = form.querySelector('.comment-action-chips'); legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true)); byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]')); + // Keep action selection and its fields together above the comment editor. + const composer = form.querySelector('.comment-composer'); + const actionToolbar = byId('v5ActionToggle').parentElement; + const submitButton = form.querySelector('button[type="submit"]'); + const submitRow = document.createElement('div'); + submitRow.className = 'v5-submit-row'; + submitRow.append(submitButton); + composer.before(actionToolbar, byId('v5ActionMenu'), byId('v5AssignmentFields'), byId('commentQuickTimeRow')); + composer.after(submitRow); + byId('v5ActionToggle').innerHTML = 'Handling'; + + const commandFilterLabel = document.createElement('label'); + commandFilterLabel.className = 'comment-category-check'; + commandFilterLabel.title = 'Vis eller skjul /-kommandoer'; + commandFilterLabel.innerHTML = '0'; + shell.querySelector('.comment-toolbar').append(commandFilterLabel); + const originalCommentFilters = window.applyCommentFilters; + window.applyCommentFilters = function (...args) { + originalCommentFilters(...args); + const items = [...byId('comments-container').querySelectorAll('.comment-item')]; + const commandItems = items.filter(item => { + const raw = item.querySelector('.comment-body')?.dataset.commentRaw || ''; + return /^\s*\/\/?(?:tid|tildel|forventet|undersag|mig|todo|afventer|løsning|deadline|udsat|fil|hjælp|timer|relation|prioritet|gruppe|mail|note)(?:\s|$)/im.test(raw) + || /^(?:deadline|defer|priority):/i.test(raw.trim()); + }); + byId('commentSourceCountCommands').textContent = String(commandItems.length); + if (!byId('commentShowCommands').checked) commandItems.forEach(item => { item.hidden = true; }); + const count = items.filter(item => !item.hidden).length; + byId('commentsVisibleCountBadge').textContent = `${count}/${items.length} vises`; + byId('commentFilterEmptyState')?.classList.toggle('d-none', count > 0 || items.length === 0); + }; + byId('commentShowCommands').addEventListener('change', () => window.applyCommentFilters()); + window.applyCommentFilters(); let assignSelected = false; let groupId = config.groupId; let userId = config.userId; @@ -249,7 +322,7 @@ if (busy || pending) return; if (action[0] === 'assign') { assignSelected = !assignSelected; - if (assignSelected && groupId) loadMembers(); + if (assignSelected) loadMembers(); } else { const checkbox = byId(action[3]); checkbox.checked = !checkbox.checked; @@ -297,30 +370,59 @@ groupId = id; userId = null; members = []; renderGroups(); loadMembers(); }, byId('v5GroupSearch').value); } + function employeeInitials(name) { + const words = String(name || '').trim().split(/\s+/).filter(Boolean); + return (words.length > 1 ? words[0][0] + words[words.length - 1][0] : (words[0] || '?').slice(0, 2)).toLocaleUpperCase('da'); + } function renderUsers() { - const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))]; - renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value); + const host = byId('v5UserOptions'); + host.replaceChildren(); + const query = byId('v5UserSearch').value.trim().toLocaleLowerCase('da'); + members.filter(member => `${member.display_name} ${employeeInitials(member.display_name)}`.toLocaleLowerCase('da').includes(query)).forEach(member => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = employeeInitials(member.display_name); + button.title = member.display_name; + button.setAttribute('aria-label', member.display_name); + button.setAttribute('aria-pressed', String(member.user_id === userId)); + button.disabled = busy || Boolean(pending); + button.addEventListener('click', () => { + userId = member.user_id; + // Keep the current group when possible; otherwise infer it from + // the employee's membership without changing the API contract. + groupId = member.group_ids.includes(groupId) ? groupId : member.group_ids[0]; + renderGroups(); renderUsers(); + }); + host.append(button); + }); + if (!host.children.length) host.textContent = 'Ingen medarbejdere fundet'; renderActions(); } async function loadMembers() { - const requestedGroupId = groupId; const sequence = ++memberRequest; membersLoading = true; byId('v5UserSearch').disabled = true; byId('v5UserOptions').textContent = 'Henter medarbejdere…'; try { - let data = memberCache.get(requestedGroupId); - if (!data) { - const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${requestedGroupId}`, {credentials: 'include'}); - if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.'); - data = await response.json(); - memberCache.set(requestedGroupId, data); - } + const results = await Promise.all(config.groups.map(async group => { + let data = memberCache.get(group.id); + if (!data) { + const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${group.id}`, {credentials: 'include'}); + if (!response.ok) throw new Error('Kunne ikke hente medarbejdere. Åbn Tildel igen for at prøve igen.'); + data = await response.json(); + memberCache.set(group.id, data); + } + return {groupId: group.id, members: data.members || []}; + })); if (sequence !== memberRequest) return; - members = data.members || []; + const employees = new Map(); + results.forEach(result => result.members.forEach(member => { + if (!employees.has(member.user_id)) employees.set(member.user_id, {...member, group_ids: []}); + employees.get(member.user_id).group_ids.push(result.groupId); + })); + members = [...employees.values()].sort((a,b) => a.display_name.localeCompare(b.display_name, 'da')); if (!members.some(member => member.user_id === userId)) userId = null; byId('v5UserSearch').disabled = false; - byId('v5UserSearch').placeholder = 'Find medarbejder…'; renderUsers(); feedback(''); } catch (error) { if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); } diff --git a/static/js/remote-api.js b/static/js/remote-api.js new file mode 100644 index 0000000..c3e3bea --- /dev/null +++ b/static/js/remote-api.js @@ -0,0 +1,45 @@ +export const base = '/api/v1/remote'; +let csrf = ''; +export async function api(path, data, method = data === undefined ? 'GET' : 'POST') { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(base + path, {method, credentials: 'same-origin', cache: 'no-store', signal: controller.signal, + headers: data === undefined ? {} : {'Content-Type': 'application/json', 'X-Remote-CSRF': csrf}, + body: data === undefined ? undefined : JSON.stringify(data)}); + if (response.redirected || (response.ok && !response.headers.get('Content-Type')?.includes('application/json'))) { + throw new Error('Hub sendte ikke det forventede svar. Prøver igen…'); + } + const result = await response.json(); + if (!response.ok) { + const error = new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne og prøv igen'); + error.status = response.status; + error.retry = Number(response.headers.get('Retry-After') || 0); + throw error; + } + return result; + } catch (error) { + if (error.status || error.message?.includes('forventede svar')) throw error; + throw new Error('Forbindelsen til Hub er afbrudt. Prøver igen…'); + } finally { clearTimeout(timeout); } +} +export async function bootstrap() { + const result = await api('/bootstrap'); + csrf = result.csrf; + return result; +} +export function element(tag, text, className) { + const node = document.createElement(tag); + node.textContent = text; + if (className) node.className = className; + return node; +} + +// getRandomValues also works on HTTP local-network origins. +export function requestId() { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; +} diff --git a/static/js/remote-hub.js b/static/js/remote-hub.js new file mode 100644 index 0000000..f68ce08 --- /dev/null +++ b/static/js/remote-hub.js @@ -0,0 +1,70 @@ +// Loaded by normal Hub pages; dormant when the remote feature is disabled. +(async () => { + if (/^\/sag\/\d+\/v5\/?$/.test(location.pathname)) return; + const {api, bootstrap, requestId} = await import('/static/js/remote-api.js'); + try { await bootstrap(); } catch (_) { return; } + const panel = document.createElement('div'); + panel.style.cssText = 'position:fixed;right:12px;top:74px;z-index:1025;background:var(--bg-card,#fff);border:1px solid #ccd6df;border-radius:8px;padding:6px;display:flex;gap:8px;align-items:center;font-size:12px'; + const link = document.createElement('a'); link.href = '/remote/manage'; link.textContent = 'Fjernbetjening'; + const button = document.createElement('button'); button.className = 'btn btn-sm btn-outline-secondary'; button.textContent = 'Følg i denne fane'; + panel.append(link, button); document.body.append(panel); + const previousTabId = sessionStorage.getItem('hubRemoteTab'); + const tabId = requestId(); + let following = sessionStorage.getItem('hubRemoteFollow') === 'yes'; + // Browsers copy sessionStorage when duplicating a tab. Detect a living original + // so a duplicate cannot silently take over its remote connection. + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel('hub-remote-tabs'); + let duplicate = false; + channel.onmessage = ({data}) => { + if (data.type === 'probe' && data.previous === tabId) channel.postMessage({type:'occupied',target:data.target}); + if (data.type === 'occupied' && data.target === tabId) duplicate = true; + }; + if (previousTabId) { + channel.postMessage({type:'probe',previous:previousTabId,target:tabId}); + await new Promise(resolve => setTimeout(resolve, 150)); + if (duplicate) { following = false; sessionStorage.setItem('hubRemoteFollow', 'no'); } + } + window.addEventListener('pagehide', () => channel.close(), {once:true}); + } + sessionStorage.setItem('hubRemoteTab', tabId); + let sequence = 0, pending = false; + let dirty = false; + document.addEventListener('input', event => { if (event.isTrusted && !panel.contains(event.target)) dirty = true; }); + document.addEventListener('change', event => { if (event.isTrusted && !panel.contains(event.target)) dirty = true; }); + const label = () => { button.textContent = following ? 'Stop med at følge' : 'Følg i denne fane'; }; + async function enable() { + const result = await api('/follow', {tab_id: tabId, enable: true}); + following = result.following; sequence = result.sequence; sessionStorage.setItem('hubRemoteFollow', following ? 'yes' : 'no'); label(); + } + button.onclick = async () => { + if (pending) return; + pending = true; button.disabled = true; + try { + if (following) { await api('/follow', {tab_id: tabId, disable: true}); following = false; sessionStorage.setItem('hubRemoteFollow', 'no'); label(); } + else await enable(); + } catch (error) { button.title = error.message; } + finally { pending = false; button.disabled = false; } + }; + async function poll() { + if (!following || pending || document.hidden) return; + pending = true; + try { + const result = await api('/follow', {tab_id: tabId, after: sequence}); + following = result.following; sequence = result.sequence; label(); + if (!following) sessionStorage.setItem('hubRemoteFollow', 'no'); + if (result.selection) { + const path = `/sag/${result.selection.case_id}`; + if (!location.pathname.match(new RegExp(`^${path}(/|$)`))) { + // Keep an in-progress user edit visible before leaving the page. + const editing = dirty || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName); + if (!editing || confirm('Fjernbetjeningen har valgt en anden sag. Skift nu?')) location.assign(path); + } + } + } catch (error) { button.title = 'Forbindelse afbrudt · ' + error.message; } + finally { pending = false; } + } + if (following) { try { await enable(); } catch (_) { following = false; } } + label(); setInterval(poll, 3000); + document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +})(); diff --git a/static/js/remote-manage.js b/static/js/remote-manage.js new file mode 100644 index 0000000..3730781 --- /dev/null +++ b/static/js/remote-manage.js @@ -0,0 +1,45 @@ +import {api, bootstrap, element} from './remote-api.js'; +const $ = id => document.getElementById(id); +let busy = false; +async function action(work) { + if (busy) return; + busy = true; $('notice').textContent = ''; + document.querySelectorAll('button').forEach(b => { b.disabled = true; }); + try { await work(); await load(); } + catch (error) { $('notice').textContent = error.message; } + finally { busy = false; document.querySelectorAll('button').forEach(b => { b.disabled = false; }); } +} +function showCode(result) { $('code').textContent = result.code; $('new-code').hidden = false; } +function hideCode() { $('code').textContent = ''; $('new-code').hidden = true; } +async function load() { + const result = await api('/admin'); + $('remotes').replaceChildren(); $('devices').replaceChildren(); + result.remotes.forEach(remote => { + const row = element('div', remote.name + (remote.revoked ? ' · Tilbagekaldt' : ''), 'row'); + if (!remote.revoked) { + const rotate = element('button', 'Ny kode'); + rotate.onclick = () => { if (confirm('Udskift koden og afbryd alle enheder til denne fjernbetjening?')) action(async () => showCode(await api(`/admin/remotes/${remote.id}/rotate`, {}))); }; + const revoke = element('button', 'Tilbagekald', 'secondary'); + revoke.onclick = () => { if (confirm('Tilbagekald denne fjernbetjening og dens enheder?')) action(() => api(`/admin/remotes/${remote.id}`, {}, 'DELETE')); }; + row.append(rotate, revoke); + } + $('remotes').append(row); + }); + result.devices.forEach(device => { + const row = element('div', `${device.name} · Senest aktiv ${new Date(device.last_seen_at).toLocaleString('da-DK')} · ${device.revoked ? 'Tilbagekaldt' : 'Udløber ' + new Date(device.expires_at).toLocaleDateString('da-DK')}`, 'row'); + if (!device.revoked) { + const revoke = element('button', 'Afbryd enhed', 'secondary'); + revoke.onclick = () => action(() => api(`/admin/devices/${device.id}`, {}, 'DELETE')); row.append(revoke); + } + $('devices').append(row); + }); + if (!result.remotes.length) $('remotes').textContent = 'Ingen fjernbetjeninger endnu'; + if (!result.devices.length) $('devices').textContent = 'Ingen forbundne enheder'; + if (!result.pin_set) $('notice').textContent = 'Opret din 4-cifrede PIN før din første fjernbetjening.'; +} +$('pin-form').onsubmit = event => { event.preventDefault(); action(async () => { await api('/admin/pin', {pin: $('pin').value}, 'PUT'); $('pin').value = ''; }); }; +$('create-form').onsubmit = event => { event.preventDefault(); action(async () => showCode(await api('/admin/remotes', {name: $('name').value}))); }; +$('hide-code').onclick = hideCode; +window.addEventListener('pagehide', hideCode); +document.addEventListener('visibilitychange', () => { if (document.hidden) hideCode(); }); +try { await bootstrap(); await load(); } catch (error) { $('notice').textContent = error.message; } diff --git a/static/js/remote-mobile.js b/static/js/remote-mobile.js new file mode 100644 index 0000000..71d57a5 --- /dev/null +++ b/static/js/remote-mobile.js @@ -0,0 +1,339 @@ +import {api, bootstrap, element, requestId} from './remote-api.js?v=20261014-day'; +const $ = id => document.getElementById(id); +let snapshot, loadedAt, online = false, busy = false, loginStatus = 'connect', kind = 'new', offset = 0, selection; +let blockedUntil = 0, lastCases = 0, polling = false, lastDay = 0; +let uncertain = null, pendingOwner = null; +let contactOffset = 0, pendingCall = null; +const renderedLists = new Map(); +function listChanged(key, data) { + const next = JSON.stringify(data); + if (renderedLists.get(key) === next) return false; + renderedLists.set(key, next); + return true; +} +try { pendingCall = JSON.parse(sessionStorage.getItem('hubRemoteCall') || 'null'); } catch (_) {} +try { const stored = JSON.parse(sessionStorage.getItem('hubRemotePending') || 'null'); uncertain = stored?.payload; pendingOwner = stored?.user_id; } catch (_) {} +const notice = text => { $('notice').textContent = text; }; +const serverNow = () => snapshot?.server_time ? new Date(snapshot.server_time).getTime() + performance.now() - loadedAt : Date.now(); +function buttons() { + document.querySelectorAll('#controls button').forEach(b => { b.disabled = !online || busy || !!uncertain; }); + document.querySelectorAll('[data-phone-call]').forEach(b => {b.disabled ||= !!pendingCall;}); + $('uncertain').hidden = !uncertain; + $('retry-command').disabled = !online || busy; + $('check-command').disabled = !online || busy; + const active = snapshot?.timer.active.active; + document.querySelector('[data-action="start"]').disabled ||= !snapshot?.active_case || active; + document.querySelector('[data-action="pause"]').disabled ||= !active; + document.querySelector('[data-action="stop"]').disabled ||= !active; +} +function deadline(row) { + if (!row?.deadline) return 'Ingen deadline'; + const seconds = Math.floor((new Date(row.deadline) - serverNow()) / 1000); + return `${seconds < 0 ? 'Deadline overskredet' : 'Deadline om ' + Math.ceil(seconds / 60) + ' min'} · ${new Date(row.deadline).toLocaleString('da-DK', {timeZone:'Europe/Copenhagen'})}`; +} +function login(status) { + loginStatus = status; online = false; snapshot = null; + $('login').hidden = false; $('controls').hidden = true; + $('login').scrollIntoView({block:'start', behavior:'instant'}); + $('code-label').hidden = status === 'locked'; $('code').required = status !== 'locked'; + $('device-label').hidden = status === 'locked'; + $('login-title').textContent = status === 'locked' ? 'Lås op med din daglige PIN' : 'Forbind din enhed'; + $('login-submit').textContent = status === 'locked' ? 'Lås op' : 'Forbind'; + $('connection').textContent = status === 'locked' ? 'Låst' : 'Ikke forbundet'; + $('connection').style.setProperty('--connection-dot', '#8da1b6'); + buttons(); +} +function failure(error) { + online = false; buttons(); notice(error.message); + $('connection').textContent = 'Ingen forbindelse · timeren fortsætter i Hub'; + $('connection').style.setProperty('--connection-dot', '#f9c26b'); + if (error.status === 423) login('locked'); + if (error.status === 401) login('connect'); + if (error.retry) blockedUntil = Date.now() + error.retry * 1000; +} +function render() { + $('login').hidden = true; $('controls').hidden = false; + $('connection').textContent = 'Forbundet · ' + snapshot.user_name; + $('connection').style.setProperty('--connection-dot', '#5ee5bc'); + $('desktop').textContent = snapshot.desktop_connected ? 'Hub-fane forbundet' : 'Computer ikke forbundet'; + $('desktop').classList.toggle('connected', snapshot.desktop_connected); + document.querySelector('.hero').classList.toggle('running', snapshot.timer.active.active); + $('active-case').textContent = snapshot.active_case ? `#${snapshot.active_case.id} · ${snapshot.active_case.titel}` : 'Vælg en sag'; + $('deadline').textContent = deadline(snapshot.active_case); + $('deadline').className = snapshot.active_case?.deadline_state || ''; + $('timer-case').textContent = snapshot.timer.active.active ? `Timer: #${snapshot.timer.active.sag_id} · ${snapshot.timer.active.sag_navn}` : 'Ingen timer kører'; + $('today').textContent = `${Math.floor(snapshot.today_minutes / 60)} t ${snapshot.today_minutes % 60} min`; + $('remote-date').textContent = new Intl.DateTimeFormat('da-DK', {day:'numeric',month:'long',timeZone:'Europe/Copenhagen'}).format(new Date(serverNow())); + const unread = snapshot.messages.list.filter(m => m.is_unread).length; + $('message-count').textContent = unread; $('message-count').hidden = !unread; + document.querySelectorAll('.case').forEach(b => b.classList.toggle('selected', Number(b.dataset.caseId) === snapshot.active_case?.id)); + if (listChanged('paused', snapshot.timer.paused)) { + $('paused').replaceChildren(); + snapshot.timer.paused.forEach(timer => { + const row = element('div', `${timer.sag_navn} · ${timer.elapsed_hhmmss}`, 'row'); + const resume = element('button', 'Genoptag'); resume.onclick = () => send('resume', {time_id: timer.time_entry_id}); + resume.disabled = snapshot.timer.active.active; + const stop = element('button', 'Stop og gem'); stop.onclick = () => send('stop', {time_id: timer.time_entry_id}); + row.append(resume, stop); $('paused').append(row); + }); + } + if (listChanged('messages', snapshot.messages.list)) { + $('messages').replaceChildren(); + snapshot.messages.list.forEach(message => { + const row = element('div', '', 'row'); + row.append(element('strong', `${message.from}${message.is_unread ? ' · Ulæst' : ''}`), element('p', message.text)); + if (message.requires_manual_ack && !message.is_acknowledged && !message.is_own) { + const ack = element('button', 'Bekræft læst'); ack.onclick = () => send('ack', {message_id: message.id}); row.append(ack); + } + $('messages').append(row); + }); + if (!snapshot.messages.list.length) $('messages').textContent = 'Ingen beskeder'; + } + buttons(); + document.querySelectorAll('#paused button:first-of-type').forEach(b => { b.disabled ||= snapshot.timer.active.active; }); +} +async function sync() { + if (polling || busy || document.hidden || loginStatus !== 'ready') return; + polling = true; + try { const reconnecting = !online; const next = await api('/state'); + if (!next?.timer?.active || !Array.isArray(next.timer.paused) || !Array.isArray(next.messages?.list)) throw new Error('Hub sendte et ufuldstændigt svar. Prøver igen…'); + snapshot = next; loadedAt = performance.now(); online = true; + if (reconnecting && !uncertain) notice(''); + if (uncertain && pendingOwner !== snapshot.user_id) { clearPending(); notice('Den tidligere handling tilhørte en anden bruger og er fjernet fra denne enhed.'); } + render(); + if (Date.now() - lastDay > 30000) await syncDay(); + await syncPhone(); + if (Date.now() - lastCases > 15000) await loadCases(); + } catch (error) { failure(error); } finally { polling = false; } +} +function clearPending() { uncertain = null; pendingOwner = null; sessionStorage.removeItem('hubRemotePending'); buttons(); } +async function send(action, extra = {}, retry = null) { + if (busy || !online || !snapshot || (uncertain && !retry)) return; + const data = retry || {request_id: requestId(), version: snapshot.version, timer_version: snapshot.timer_version, + action, case_id: snapshot.active_case?.id, description: $('description').value.trim(), ...extra}; + if (['stop', 'manual'].includes(action) && !data.description) { notice('Angiv beskrivelse først'); $('description').focus(); return; } + busy = true; buttons(); notice(''); + // Preserve the same idempotency key through an uncertain network response and reload. + // Nothing is automatically replayed after losing connection. + pendingOwner = snapshot.user_id; + sessionStorage.setItem('hubRemotePending', JSON.stringify({user_id: pendingOwner, payload: data})); + try { await api('/command', data); clearPending(); if (action === 'manual' || action === 'stop') $('description').value = ''; } + catch (error) { + notice(error.message); + if (!error.status || error.status >= 500) uncertain = data; + else clearPending(); + if (error.status !== 409) failure(error); + } + finally { busy = false; await sync(); buttons(); } +} +$('retry-command').onclick = () => send(uncertain.action, {}, uncertain); +$('check-command').onclick = async () => { + if (!uncertain || busy) return; + try { + const result = await api('/command-result/' + uncertain.request_id); + if (result.completed) { clearPending(); notice('Handlingen blev gemt i Hub.'); await sync(); } + else notice('Hub har ikke registreret handlingen. Brug Prøv samme handling igen.'); + } catch (error) { failure(error); } +}; +async function select(row) { + if (snapshot?.timer.active.active && snapshot.timer.active.sag_id !== row.id) { + selection = row; $('switch-dialog').showModal(); + } else await send('select', {case_id: row.id}); +} +document.querySelectorAll('[data-switch]').forEach(b => b.onclick = async () => { + $('switch-dialog').close(); + if (b.dataset.switch !== 'cancel') await send('select', {case_id: selection.id, switch_timer: b.dataset.switch === 'timer'}); +}); +async function loadCases(append = false) { + const data = await api(`/cases?kind=${kind}&q=${encodeURIComponent($('search').value)}&offset=${offset}`); + if (!append && !listChanged('cases', {kind, offset, query:$('search').value, data})) {lastCases=Date.now();buttons();return;} + if (append) renderedLists.delete('cases'); + if (!append) $('cases').replaceChildren(); + data.items.forEach(row => { + const button = element('button', `#${row.id} · ${row.titel}`, 'case'); + button.dataset.caseId = row.id; + button.classList.toggle('selected', row.id === snapshot?.active_case?.id); + button.append(element('small', `${row.customer_name || ''} · ${row.status} · ${deadline(row)}`, row.deadline_state)); + button.onclick = () => select(row); $('cases').append(button); + }); + if (!data.items.length && !append) $('cases').textContent = 'Ingen sager'; + $('more').hidden = !data.has_more; lastCases = Date.now(); buttons(); +} +document.querySelectorAll('[data-action]').forEach(b => b.onclick = () => send(b.dataset.action)); +document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => { + kind = b.dataset.kind; offset = 0; + document.querySelectorAll('[data-kind]').forEach(x => x.setAttribute('aria-pressed', x === b)); + loadCases().catch(failure); +}); +$('search-form').onsubmit = event => { event.preventDefault(); kind = 'search'; offset = 0; loadCases().catch(failure); }; +$('more').onclick = () => { offset += 50; loadCases(true).catch(failure); }; +$('manual-form').onsubmit = event => { event.preventDefault(); send('manual', {minutes: Number($('minutes').value)}); }; +$('login-form').onsubmit = async event => { + event.preventDefault(); if (Date.now() < blockedUntil || busy) return; + busy = true; $('login-submit').disabled = true; + try { await api(loginStatus === 'locked' ? '/unlock' : '/connect', {code: $('code').value, pin: $('pin').value, name: $('device').value || 'Mobil / tablet'}); + loginStatus = 'ready'; $('pin').value = ''; $('code').value = ''; notice(''); + } catch (error) { notice(error.message); if (error.retry) blockedUntil = Date.now() + error.retry * 1000; } + finally { busy = false; await sync(); } +}; +$('logout').onclick = async () => { try { await api('/logout', {}); login('connect'); } catch (error) { failure(error); } }; +setInterval(() => { + const seconds = Math.max(0, Math.ceil((blockedUntil - Date.now()) / 1000)); + $('login-submit').disabled = busy || seconds > 0; + if (seconds) $('login-submit').textContent = `Prøv igen om ${seconds} sek`; + else $('login-submit').textContent = loginStatus === 'locked' ? 'Lås op' : 'Forbind'; + if (snapshot) { + const active = snapshot.timer.active; + const elapsed = active.active ? active.elapsed + Math.floor((performance.now() - loadedAt) / 1000) : 0; + $('elapsed').textContent = [Math.floor(elapsed/3600), Math.floor(elapsed/60)%60, elapsed%60].map(x => String(x).padStart(2,'0')).join(':'); + $('deadline').textContent = deadline(snapshot.active_case); + const left = snapshot.active_case?.deadline ? (new Date(snapshot.active_case.deadline) - serverNow()) / 1000 : null; + $('deadline').className = left === null ? '' : left < 0 ? 'overdue' : left <= 3600 ? 'warning' : ''; + } +}, 1000); +setInterval(sync, 3000); +document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); }); +window.addEventListener('online', sync); +window.addEventListener('pageshow', () => sync()); +window.addEventListener('offline', () => failure(new Error('Forbindelsen er afbrudt'))); +try { const result = await bootstrap(); loginStatus = result.status; if (loginStatus === 'ready') await sync(); else login(loginStatus); } +catch (error) { failure(error); } + +async function syncDay() { + try { + const data = await api('/day'); + if (!Array.isArray(data.items)) throw new Error('Dagens plan kunne ikke læses'); + if (listChanged('day', data)) { + const host = $('day-items');host.replaceChildren(); + data.items.forEach(item => { + const row=element('div','','day-plan-item'); + const time=value=>new Intl.DateTimeFormat('da-DK',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Copenhagen'}).format(new Date(value)); + const detail=element('div',''); + detail.append(element('strong',item.title||'Planlagt opgave'),element('small',item.customer_name||'Intern opgave')); + if (item.case_id) { + const button=element('button',`Vælg sag #${item.case_id}`); + button.onclick=()=>select({id:item.case_id});detail.append(button); + } + row.append(element('span',`${time(item.starts_at)}–${time(item.ends_at)}`,'day-plan-time'),detail);host.append(row); + }); + if(!data.items.length)host.textContent='Ingen planlagte opgaver i dag'; + } + $('day-status').textContent='';lastDay=Date.now(); + } catch(error) { + $('day-status').textContent='Dagens plan kunne ikke opdateres. Prøver igen…'; + if(error.status===401||error.status===423)throw error; + } +} +let wakeLock=null,keepAwake=false; +async function holdScreen() { + if(!keepAwake||document.hidden||wakeLock)return; + try { + if(!navigator.wakeLock)throw new Error('unsupported'); + wakeLock=await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release',()=>{wakeLock=null;$('screen-awake-status').textContent='Skærmlåsen blev frigivet';}); + $('screen-awake-status').textContent='Skærmen holdes tændt, mens remote er synlig'; + } catch(_) {$('screen-awake-status').textContent='Browseren kunne ikke holde skærmen tændt';} +} +$('screen-awake').onclick=async()=>{ + keepAwake=!keepAwake;$('screen-awake').textContent=keepAwake?'Tillad skærmen at slukke':'Hold skærmen tændt'; + if(keepAwake)await holdScreen();else {await wakeLock?.release();wakeLock=null;$('screen-awake-status').textContent='';} +}; +document.addEventListener('visibilitychange',()=>{if(!document.hidden){lastDay=0;holdScreen();}}); + +async function syncPhone() { + try { + const data = await api('/telephony'); + $('phone-card').hidden = !data.allowed; + document.querySelector('.remote-nav a[href="#phone-card"]').hidden = !data.allowed; + $('call-count').textContent = data.incoming?.length || 0; $('call-count').hidden = !data.incoming?.length; + if (listChanged('incoming', data.incoming || [])) { + $('incoming-calls').replaceChildren(); + (data.incoming || []).forEach(call => { + const row = element('div', '', 'incoming-call'); + row.append(element('strong', call.contact_name || call.caller_number || 'Ukendt nummer'), + element('p', [call.company_name, call.caller_number, (call.ringing_phones || []).join(', ')].filter(Boolean).join(' · '))); + $('incoming-calls').append(row); + }); + if (!data.incoming?.length) $('incoming-calls').textContent = 'Ingen indgående opkald'; + } + if (pendingCall && pendingCall.user_id !== snapshot.user_id) { + pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); + } + $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; + if (pendingCall) $('phone-notice').textContent = 'Svaret på opkaldet mangler. Kontrollér status og din telefon før et nyt opkald.'; + } catch (error) { + renderedLists.delete('incoming'); + $('incoming-calls').textContent = 'Opkaldsvisningen er midlertidigt utilgængelig'; + if (error.status === 401 || error.status === 423) throw error; + } + buttons(); +} +async function loadContacts(append = false) { + const data = await api(`/contacts?q=${encodeURIComponent($('contact-search').value)}&offset=${contactOffset}`); + if (!append) $('contact-results').replaceChildren(); + data.items.forEach(contact => { + const row = element('div', '', 'row'); + row.append(element('strong', [contact.first_name, contact.last_name].filter(Boolean).join(' ')), + element('p', [...(contact.company_names || []), contact.email].filter(Boolean).join(' · '))); + ['phone', 'mobile'].forEach(field => { + if (!contact[field]) return; + const button = element('button', `Ring ${field === 'mobile' ? 'mobil' : 'telefon'} · ${contact[field]}`); + button.dataset.phoneCall = 'yes'; + button.onclick = () => dialContact(contact.id, field); + row.append(button); + }); + $('contact-results').append(row); + }); + if (!data.items.length && !append) $('contact-results').textContent = 'Ingen kontakter fundet'; + $('contact-more').hidden = !data.has_more; buttons(); +} +async function dialContact(contact_id, field) { + if (!online || busy || pendingCall) return; + busy = true; buttons(); + const payload = {request_id: requestId(), contact_id, field}; + pendingCall = {user_id: snapshot.user_id, payload}; + sessionStorage.setItem('hubRemoteCall', JSON.stringify(pendingCall)); + try { + const result = await api('/call', payload); + $('phone-notice').textContent = result.message; + if (result.status !== 'uncertain') { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); } + } catch (error) { + $('phone-notice').textContent = error.status ? error.message : 'Svaret mangler. Kontrollér telefonen før et nyt opkald.'; + if (error.status) { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); } + } finally { busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons(); } +} +$('contact-search-form').onsubmit = event => {event.preventDefault(); contactOffset = 0; loadContacts().catch(error => {$('phone-notice').textContent = error.message;});}; +$('contact-more').onclick = () => {contactOffset += 50; loadContacts(true).catch(error => {$('phone-notice').textContent = error.message;});}; +$('check-call').onclick = async () => { + if (!pendingCall || !online || busy) return; + busy = true; buttons(); + try { + // Read status only; never submit an old call while reconnecting. + const result = await api(`/call/${pendingCall.payload.request_id}`); + $('phone-notice').textContent = result.message; + if (result.status !== 'uncertain') {pendingCall = null; sessionStorage.removeItem('hubRemoteCall');} + } catch (error) {$('phone-notice').textContent = error.message;} + finally {busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons();} +}; + +$('clear-call').onclick = () => { + pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); + $('check-call').hidden = true; $('clear-call').hidden = true; + $('phone-notice').textContent = 'Du kan nu starte et nyt opkald'; buttons(); +}; + +const navLinks = [...document.querySelectorAll('.remote-nav a')]; +function highlightNavigation(id) { + navLinks.forEach(link => { + if (link.hash === '#' + id) link.setAttribute('aria-current', 'location'); + else link.removeAttribute('aria-current'); + }); +} +navLinks.forEach(link => link.addEventListener('click', () => highlightNavigation(link.hash.slice(1)))); +if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver(entries => { + const visible = entries.filter(entry => entry.isIntersecting && !entry.target.hidden); + if (visible.length) highlightNavigation(visible[0].target.id); + }, {rootMargin:'-15% 0px -60% 0px',threshold:0}); + navLinks.forEach(link => observer.observe(document.querySelector(link.hash))); +} diff --git a/tests/browser/remote_control.cjs b/tests/browser/remote_control.cjs new file mode 100644 index 0000000..848803e --- /dev/null +++ b/tests/browser/remote_control.cjs @@ -0,0 +1,165 @@ +/* Isolated mobile/tablet UI tests: all requests are mocked, no Hub data is changed. + * BMC_PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/remote_control.cjs + * BMC_REMOTE_BROWSER=webkit tests the Safari/WebKit engine. + */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const pw = require(process.env.BMC_PLAYWRIGHT_MODULE || 'playwright'); +const engine = process.env.BMC_REMOTE_BROWSER || 'chromium'; + +(async () => { + const browser = await pw[engine].launch(engine === 'chromium' + ? {headless:true, executablePath:process.env.BMC_CHROME_EXECUTABLE || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'} + : {headless:true}); + try { + for (const viewport of [{width:390,height:844}, {width:820,height:1180}]) { + const context = await browser.newContext({viewport}); + const page = await context.newPage(); + const errors = [], writes = [], completed = new Map(); + let disconnected = false, dropReply = false; + const cases = [11,12].map(id => ({id,titel:'Testsag '+id,status:'Ny',deadline:null,deadline_state:'none'})); + let state = {user_id:1,user_name:'Testbruger',version:0,timer_version:'a'.repeat(64),active_case:cases[0],desktop_connected:false, + server_time:new Date().toISOString(), + timer:{active:{active:false,elapsed:0},paused:[]},today_minutes:0,messages:{count:1,list:[{id:1,from:'Kollega',text:'Testbesked',is_unread:true}]}}; + page.on('pageerror', e => errors.push(e.message)); + await page.route('**/*', async route => { + const req = route.request(), path = new URL(req.url()).pathname; + if (path === '/remote') return route.fulfill({contentType:'text/html',body:fs.readFileSync('app/modules/remote/mobile.html','utf8')}); + if (path.startsWith('/static/')) return route.fulfill({contentType:path.endsWith('.css')?'text/css':'application/javascript',body:fs.readFileSync('.'+path,'utf8')}); + if (disconnected) return route.abort('internetdisconnected'); + let result = {}; + if (path.endsWith('/bootstrap')) result = {csrf:'test-csrf',status:'connect'}; + if (path.endsWith('/connect')) {writes.push(req.postDataJSON());result={ok:true};} + if (path.endsWith('/state')) result = state; + if (path.endsWith('/telephony')) result = {allowed:true,incoming:[{call_id:'incoming-1',contact_name:'Indgående testkontakt',caller_number:'12345678',company_name:'Testfirma',ringing_phones:['Support']}]}; + if (path.endsWith('/contacts')) result = {items:[{id:1,first_name:'Test',last_name:'Kontakt',mobile:'87654321',company_names:['Testfirma']}],has_more:false}; + if (path.endsWith('/call')) {writes.push(req.postDataJSON());result={status:'ok',message:'Opkald startet på din VoIP-telefon'};} + if (path.endsWith('/cases')) result = {items:cases,has_more:false}; + if (path.includes('/command-result/')) result = {completed:completed.has(path.split('/').pop())}; + if (path.endsWith('/command')) { + const data = req.postDataJSON(); writes.push(data); + if (!completed.has(data.request_id)) { + state.version++; + if (data.action === 'start') state.timer.active={active:true,sag_id:data.case_id,sag_navn:'Testsag '+data.case_id,time_entry_id:1,elapsed:10}; + if (data.action === 'select') {state.active_case=cases.find(c=>c.id===data.case_id);if(data.switch_timer)state.timer.active.sag_id=data.case_id;} + if (data.action === 'pause') {state.timer.paused=[{sag_navn:'Testsag',time_entry_id:1,elapsed_hhmmss:'00:00:10'}];state.timer.active.active=false;} + if (data.action === 'resume') {state.timer.active.active=true;state.timer.paused=[];} + if (data.action === 'stop') state.timer.active.active=false; + if (data.action === 'manual') state.today_minutes+=data.minutes; + completed.set(data.request_id,{ok:true}); + } + result=completed.get(data.request_id); + if(dropReply){dropReply=false;return route.abort('internetdisconnected');} + } + return route.fulfill({contentType:'application/json',body:JSON.stringify(result)}); + }); + await page.goto('http://remote.test/remote'); + await page.locator('#code').fill('000000000042'); + await page.locator('#pin').fill('1234'); + await page.getByRole('button',{name:'Forbind',exact:true}).click(); + await page.locator('#controls').waitFor({state:'visible'}); + assert.equal(writes[0].code,'000000000042'); + assert.equal(await page.locator('#deadline').textContent(),'Ingen deadline'); + assert.equal(await page.locator('#messages').textContent().then(s=>s.includes('Testbesked')),true); + await page.getByRole('button',{name:'Start timer',exact:true}).evaluate(b=>{b.click();b.click();}); + await page.waitForFunction(()=>document.querySelector('[data-action="pause"]').disabled===false); + assert.equal(writes.filter(x=>x.action==='start').length,1); + await page.getByText('Indgående testkontakt',{exact:true}).waitFor(); + await page.locator('#contact-search').fill('Kontakt'); + await page.getByRole('button',{name:'Søg kontakter',exact:true}).click(); + await page.getByRole('button',{name:'Ring mobil · 87654321',exact:true}).evaluate(b=>{b.click();b.click();}); + await page.getByText('Opkald startet på din VoIP-telefon',{exact:true}).waitFor(); + assert.equal(writes.filter(x=>x.contact_id===1).length,1); + + await page.locator('.case').filter({hasText:'#12'}).click(); + await page.getByRole('button',{name:'Kun skift sag',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('#active-case').textContent.startsWith('#12')); + assert.equal(state.timer.active.sag_id,11); + await page.getByRole('button',{name:'Pause',exact:true}).click(); + await page.getByRole('button',{name:'Genoptag',exact:true}).waitFor(); + await page.getByRole('button',{name:'Genoptag',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('[data-action="pause"]').disabled===false); + await page.locator('#description').fill('Testarbejde'); + await page.getByRole('button',{name:'Stop og gem tid',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===false); + await page.locator('#description').fill('Manuel test');await page.locator('#minutes').fill('17'); + dropReply=true; + await page.getByRole('button',{name:'Gem tid med beskrivelsen ovenfor',exact:true}).click(); + await page.locator('#uncertain').waitFor({state:'visible'}); + await page.waitForFunction(()=>!document.querySelector('#check-command').disabled); + await page.getByRole('button',{name:'Kontrollér resultat',exact:true}).click(); + await page.locator('#uncertain').waitFor({state:'hidden'}); + assert.equal(state.today_minutes,17); + assert.equal(writes.filter(x=>x.action==='manual').length,1); + disconnected=true; + await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===true); + const count=writes.length; + await page.getByRole('button',{name:'Start timer',exact:true}).evaluate(b=>b.click()); + assert.equal(writes.length,count); + disconnected=false; + await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===false); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + assert.deepEqual(errors,[]); + await page.screenshot({path:`/tmp/bmc-remote-${engine}-${viewport.width}.png`,fullPage:true}); + await context.close(); + console.log(`${engine} ${viewport.width}px: login, timer, sagsvalg, manuel tid, tabt svar og forbindelse OK`); + } + // Normal Hub administration uses separate authenticated routes and clears codes. + const context = await browser.newContext({viewport:{width:820,height:1180}}); + const page = await context.newPage(); + const errors = [], adminWrites = []; + const admin = {pin_set:false,remotes:[],devices:[]}; + let follower = null, seq = 0; + await context.route('**/*', async route => { + const req=route.request(), path=new URL(req.url()).pathname; + if(path==='/remote/manage')return route.fulfill({contentType:'text/html',body:fs.readFileSync('app/modules/remote/manage.html','utf8')}); + if(path==='/' || path.startsWith('/sag/'))return route.fulfill({contentType:'text/html',body:'Test Hub'}); + if(path.startsWith('/static/'))return route.fulfill({contentType:path.endsWith('.css')?'text/css':'application/javascript',body:fs.readFileSync('.'+path,'utf8')}); + let result={}; + if(path.endsWith('/bootstrap'))result={csrf:'test-csrf',status:'connect'}; + if(path.endsWith('/admin'))result=admin; + if(req.method()!=='GET')adminWrites.push({path,data:req.postDataJSON()}); + if(path.endsWith('/admin/pin')){admin.pin_set=true;result={ok:true};} + if(path.endsWith('/admin/remotes')){admin.remotes=[{id:'test-id',name:'Arbejdsmobil'}];result={id:'test-id',code:'000000000042'};} + if(path.endsWith('/rotate'))result={id:'test-id',code:'000000000043'}; + if(path.endsWith('/follow')){ + const data=req.postDataJSON(); + if(data.enable){follower=data.tab_id;result={following:true,sequence:seq};} + else if(data.disable){if(follower===data.tab_id)follower=null;result={following:false,sequence:seq};} + else result={following:follower===data.tab_id,sequence:seq,selection:follower===data.tab_id&&data.aftererrors.push(e.message)); + await page.goto('http://remote.test/remote/manage'); + await page.locator('#pin').fill('1234');await page.getByRole('button',{name:'Gem PIN',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('#pin').value===''); + await page.locator('#name').fill('Arbejdsmobil');await page.getByRole('button',{name:'Opret og vis kode',exact:true}).click(); + await page.locator('#new-code').waitFor({state:'visible'}); + assert.equal(await page.locator('#code').textContent(),'000000000042'); + await page.getByRole('button',{name:'Skjul kode',exact:true}).click(); + assert.equal(await page.locator('#code').textContent(),''); + assert.equal(adminWrites.filter(x=>x.path.endsWith('/admin/pin')).length,1); + await page.goto('http://remote.test/'); + await page.waitForSelector('button', {timeout:5000}).catch(error => { console.error('Hub script errors:',errors); throw error; }); + assert.equal(await page.locator('button').first().textContent(),'Følg i denne fane', JSON.stringify(errors)); + await page.getByRole('button',{name:'Følg i denne fane',exact:true}).click(); + await page.getByRole('button',{name:'Stop med at følge',exact:true}).waitFor(); + const firstId=follower; + const second=await context.newPage();second.on('pageerror',e=>errors.push(e.message)); + await second.goto('http://remote.test/'); + assert.equal(follower,firstId); + await second.getByRole('button',{name:'Følg i denne fane',exact:true}).click(); + await second.getByRole('button',{name:'Stop med at følge',exact:true}).waitFor(); + assert.notEqual(follower,firstId); + await page.bringToFront(); + await page.getByRole('button',{name:'Følg i denne fane',exact:true}).waitFor(); + await second.bringToFront(); + seq++; + await second.waitForURL('**/sag/12'); + assert.equal(page.url(),'http://remote.test/'); + assert.deepEqual(errors,[]); + await context.close(); + console.log(`${engine}: administration, kode, PIN og kun valgt Hub-fane OK`); + } finally { await browser.close(); } +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/js/case-command-composer.test.cjs b/tests/js/case-command-composer.test.cjs index bc0b215..41eeff8 100644 --- a/tests/js/case-command-composer.test.cjs +++ b/tests/js/case-command-composer.test.cjs @@ -34,7 +34,7 @@ async function fixture(handler) { context.window=context;vm.createContext(context); vm.runInContext(fs.readFileSync('static/js/case-command-composer.js','utf8'),context); for(let i=0;i<10;i++)await Promise.resolve(); - const type=text=>{input.value=text;input.selectionStart=text.length;input.listeners.input();}; + const type=text=>{input.value=text;input.setSelectionRange(text.length,text.length);input.listeners.input();}; return {controller:context.CaseCommandComposer,input,button,type,calls,nodes:panelNodes,storage}; } const response=(status,data)=>({ok:status<400,status,json:async()=>data}); @@ -96,3 +96,63 @@ test('suggestion menu continues from command to minutes to category',async()=>{ assert.equal(f.input.value,'/tid 30m '); assert.ok(f.nodes.some(node=>node.tag==='button' && node.children[0]?.textContent==='research')); }); + +test('required quotes appear automatically after typing the preceding arguments',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + for(const text of ['/todo ', '/note ', '/tildel ct ', '/tid 30m research ', '/timer start ', '//gruppe Support ']) { + f.type(text); + assert.equal(f.input.value,text+'""'); + assert.equal(f.input.selectionStart,text.length+1); + } +}); +test('selecting a category inserts description quotes immediately',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + f.type('/tid 30m re'); + f.nodes.find(node=>node.tag==='button' && node.children[0]?.textContent==='research').click(); + assert.equal(f.input.value,'/tid 30m research ""'); + assert.equal(f.input.selectionStart,f.input.value.length-1); +}); + +test('Enter inside command quotes keeps the closing quote on the command line',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + for(const text of ['/todo ""','/note "Tekst"','/tid 30m research "Arbejde"','//todo "Opgave" @ct fredag']) { + f.type(text); + const caret=text.indexOf('"')+1; + f.input.setSelectionRange(caret,caret); + let prevented=false; + f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}}); + assert.equal(prevented,true); + assert.equal(f.input.value,text+'\n'); + assert.equal(f.input.selectionStart,text.length+1); + } +}); +test('Enter leaves ordinary prose and selections to native editing',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + f.type('Tekst "citat"');f.input.setSelectionRange(8,8); + f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('ordinary prose intercepted');}}); + f.type('/note "tekst"');f.input.setSelectionRange(8,10); + f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('selection intercepted');}}); +}); + +test('Enter chooses the first suggestion without arrow navigation',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + f.type('/ti'); + let prevented=false; + f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}}); + assert.equal(prevented,true); + assert.equal(f.input.value,'/tid '); + assert.equal(f.calls.length,0); +}); +test('Enter honours an explicitly selected suggestion',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + f.type('/ti'); + const press=key=>f.input.listeners.keydown({key,preventDefault(){}}); + press('ArrowDown');press('ArrowDown');press('Enter'); + assert.equal(f.input.value,'/tildel '); +}); +test('Shift Enter does not accept a suggestion',async()=>{ + const f=await fixture((url,body)=>response(200,{commands:body.commands})); + f.type('/ti'); + f.input.listeners.keydown({key:'Enter',shiftKey:true,preventDefault(){assert.fail('Shift Enter intercepted');}}); + assert.equal(f.input.value,'/ti'); +}); diff --git a/tests/js/case-command-suggestions.test.cjs b/tests/js/case-command-suggestions.test.cjs index 904edc9..e00f6d0 100644 --- a/tests/js/case-command-suggestions.test.cjs +++ b/tests/js/case-command-suggestions.test.cjs @@ -1,7 +1,12 @@ const test=require('node:test');const assert=require('node:assert/strict');const {suggest}=require('../../static/js/case-command-suggestions.js'); const users=[{user_id:3,username:'ct',full_name:'Christian Thomas'}]; test('command choice continues with values rather than a full canned example',()=>{const rows=suggest('/ti',3);assert.equal(rows.find(r=>r.label==='/tid').value,'/tid ');assert.ok(suggest('/tid ',5).some(r=>r.value==='30m '));}); -test('completion follows time category and description',()=>{assert.ok(suggest('/tid 30m re',11).some(r=>r.value==='research '));assert.ok(suggest('/tid 30m research ',18).some(r=>r.value==='"Beskrivelse" '));}); -test('employee completion filters initials and uses an unambiguous identifier',()=>{const row=suggest('/tildel ct',10,{users})[0];assert.equal(row.label,'Christian Thomas');assert.equal(row.value,'3 ');}); -test('replacement preserves existing explanation and handles mid-command caret',()=>{let line='/tildel ct "Bestil varer"';const row=suggest(line,10,{users})[0];assert.equal(line.slice(0,row.from)+row.value+line.slice(row.to),'/tildel 3 "Bestil varer"');const command=suggest('/tildel',3)[0];assert.equal(command.to,7);}); -test('subcase groups, dates and todo employee choices',()=>{assert.equal(suggest('//gruppe Sup',12,{groups:[{name:'Support'}]})[0].value,'"Support" ');assert.equal(suggest('/udsat i m',10)[0].value,'i morgen');assert.equal(suggest('/todo "Bestil" @ct',18,{users})[0].value,'@3 ');}); +test('completion follows time category and description',()=>{assert.ok(suggest('/tid 30m re',11).some(r=>r.value==='research '));assert.ok(suggest('/tid 30m research ',18).some(r=>r.value==='"" ' && r.caret===1));}); +test('employee completion filters initials and uses an unambiguous identifier',()=>{const row=suggest('/tildel ct',10,{users})[0];assert.equal(row.label,'Christian Thomas');assert.equal(row.value,'ct ');}); +test('replacement preserves existing explanation and handles mid-command caret',()=>{let line='/tildel ct "Bestil varer"';const row=suggest(line,10,{users})[0];assert.equal(line.slice(0,row.from)+row.value+line.slice(row.to),'/tildel ct "Bestil varer"');const command=suggest('/tildel',3)[0];assert.equal(command.to,7);}); +test('subcase groups, dates and todo employee choices',()=>{assert.equal(suggest('//gruppe Sup',12,{groups:[{name:'Support'}]})[0].value,'"Support" ');assert.equal(suggest('/udsat i m',10)[0].value,'i morgen');assert.equal(suggest('/todo "Bestil" @ct',18,{users})[0].value,'@ct ');}); + +test('text commands insert quotes with the caret between them, including child scope',()=>{for(const prefix of ['/', '//'])for(const name of ['todo','undersag','note','mail','løsning']){const row=suggest(prefix+name,(prefix+name).length).find(r=>r.label===prefix+name);assert.equal(row.value,prefix+name+' ""');assert.equal(row.value[row.caret-1],'"');assert.equal(row.value[row.caret],'"');}}); + +test('child assignment inserts initials, never the numeric user ID',()=>{const row=suggest('//tildel ct',11,{users})[0];assert.equal(row.value,'ct ');}); +test('duplicate initials use a unique username',()=>{const people=[...users,{user_id:4,username:'carlt',full_name:'Carl Thomsen'}];const row=suggest('/tildel Carl',12,{users:people})[0];assert.equal(row.value,'carlt ');}); diff --git a/tests/js/case-create-commands.test.cjs b/tests/js/case-create-commands.test.cjs new file mode 100644 index 0000000..cfecbf6 --- /dev/null +++ b/tests/js/case-create-commands.test.cjs @@ -0,0 +1,55 @@ +const test=require('node:test'),assert=require('node:assert/strict'); +const {CommandRun,init}=require('../../static/js/case-create-commands.js'); +const parser=require('../../static/js/case-commands.js'); +const completion=require('../../static/js/case-command-suggestions.js'); +test('creation commands target the new case and retry with the same UUID and payload',async()=>{ + const calls=[];let fail=true; + const run=new CommandRun(async(url,body)=>{calls.push({url,body:JSON.stringify(body)});if(fail){fail=false;throw new Error('offline');}return {};},()=> 'same-uuid'); + const parsed=parser.parse('Sagens beskrivelse\n/undersag "Router"\n//tildel ct "Opsæt router"\n//tid 30m support "Opsætning"'); + assert.equal(parsed.comment,'Sagens beskrivelse');assert.equal(parsed.errors.length,0); + run.attach(198,parsed.commands); + await assert.rejects(run.execute(),/offline/);assert.equal(run.pending.caseId,198); + await run.execute();assert.equal(run.pending,null);assert.deepEqual(calls[0],calls[1]); + assert.equal(calls[0].url,'/api/v1/sag/198/command-execute'); +}); +class Element { + constructor(){this.children=[];this.listeners={};this.value='';this.selectionStart=0;this.selectionEnd=0;this.classList={toggle(){}};} + append(...nodes){this.children.push(...nodes);}after(){}replaceChildren(){this.children=[];}setAttribute(){} + addEventListener(type,fn){this.listeners[type]=fn;}focus(){} + setSelectionRange(start,end){this.selectionStart=start;this.selectionEnd=end;} + setRangeText(text,start,end){this.value=this.value.slice(0,start)+text+this.value.slice(end);this.setSelectionRange(start+text.length,start+text.length);} + dispatchEvent(event){this.listeners[event.type]?.(event);} +} +async function fixture(handler){ + const nodes=[],input=new Element(),button=new Element(); + const root={document:{getElementById:id=>id==='beskrivelse'?input:id==='submitBtn'?button:{textContent:'{"users":[{"user_id":3,"username":"ct","full_name":"Christian Thomas"}],"groups":[]}'},createElement:()=>{const node=new Element();nodes.push(node);return node;},createTextNode:text=>({textContent:text})},CaseCommands:parser,CaseCommandSuggestions:completion,Event:class{constructor(type){this.type=type;}},crypto:{randomUUID:(()=>{let n=0;return ()=>`uuid-${++n}`;})()},sessionStorage:{getItem(){return null;},setItem(){},removeItem(){}},fetch:async(url,options)=>options&&handler?handler(url,JSON.parse(options.body)):({ok:true,json:async()=>({id:17})})}; + init(root);await root.caseCreateCommands.ready; + const type=text=>{input.value=text;input.setSelectionRange(text.length,text.length);input.dispatchEvent(new root.Event('input'));}; + const press=key=>input.listeners.keydown({key,preventDefault(){}}); + return {root,input,nodes,type,press}; +} +test('new case supports first-suggestion Enter, initials and automatic description quotes',async()=>{ + const f=await fixture();f.type('Beskrivelse\n/ti');f.press('Enter');assert.equal(f.input.value,'Beskrivelse\n/tid '); + f.type('Beskrivelse\n/tid 30m support ');assert.equal(f.input.value,'Beskrivelse\n/tid 30m support ""'); + f.type('Beskrivelse\n/undersag "Router"\n//tildel ct');f.press('Enter'); + assert.equal(f.input.value,'Beskrivelse\n/undersag "Router"\n//tildel ct ""'); +}); +test('Enter preserves closing quotes and prose stays separate from commands',async()=>{ + const f=await fixture();f.type('Beskrivelse\n/todo "Opgave"');f.input.setSelectionRange(f.input.value.length-1,f.input.value.length-1);f.press('Enter'); + assert.equal(f.input.value,'Beskrivelse\n/todo "Opgave"\n'); + const parsed=f.root.caseCreateCommands.prepare();assert.equal(parsed.comment,'Beskrivelse');assert.equal(parsed.commands[0].title,'Opgave'); + f.type('Beskrivelse\n/ukendt');assert.throws(()=>f.root.caseCreateCommands.prepare(),/Ukendt kommando/); +}); + +test('a rejected command can be corrected on the same created case',async()=>{ + const calls=[];let reject=true; + const f=await fixture(async(url,body)=>{calls.push({url,body});return reject?{ok:false,status:400,json:async()=>({detail:'Initialerne er tvetydige'})}:{ok:true,json:async()=>({results:[]})};}); + f.type('Beskrivelse\n/tildel ct "Overdrag"');f.root.caseCreateCommands.prepare(); + await assert.rejects(f.root.caseCreateCommands.created({id:99}),/Sag #99 er oprettet/); + assert.equal(f.input.readOnly,false);assert.equal(f.root.caseCreateCommands.pendingCaseId(),99); + reject=false;f.type('Beskrivelse\n/tildel carlt "Overdrag"');await f.root.caseCreateCommands.retry(); + assert.equal(calls[1].url,'/api/v1/sag/99/command-execute'); + assert.equal(calls[1].body.commands[0].assignee,'carlt'); + assert.notEqual(calls[0].body.request_id,calls[1].body.request_id); + assert.equal(f.root.caseCreateCommands.hasPending(),false); +}); diff --git a/tests/js/case-v5-assignment.test.cjs b/tests/js/case-v5-assignment.test.cjs new file mode 100644 index 0000000..f359dd5 --- /dev/null +++ b/tests/js/case-v5-assignment.test.cjs @@ -0,0 +1,27 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const vm=require('node:vm'); +const source=fs.readFileSync('static/js/case-v5.js','utf8'); +function fixture(groupId){ + const nodes={v5UserSearch:{value:''},v5UserOptions:{children:[],replaceChildren(){this.children=[];},append(button){this.children.push(button);}}}; + const context={members:[{user_id:7,display_name:'Christian Thomas',group_ids:[2,3]},{user_id:8,display_name:'Mette Jensen',group_ids:[4]}],groupId,userId:null,busy:false,pending:null,byId:id=>nodes[id],renderGroups(){},renderActions(){},document:{createElement(){return {setAttribute(key,value){this[key]=value;},addEventListener(type,fn){this[type]=fn;}};}}}; + vm.createContext(context); + vm.runInContext(source.slice(source.indexOf(' function employeeInitials('),source.indexOf(' async function loadMembers()')),context); + context.renderUsers(); + return {context,nodes}; +} +test('initial buttons identify employees and infer their assignment group',()=>{ + const {context,nodes}=fixture(2); + const buttons=nodes.v5UserOptions.children; + assert.deepEqual(buttons.map(b=>b.textContent),['CT','MJ']); + assert.equal(buttons[0].title,'Christian Thomas'); + buttons[0].click();assert.equal(context.userId,7);assert.equal(context.groupId,2); + nodes.v5UserOptions.children[1].click();assert.equal(context.userId,8);assert.equal(context.groupId,4); +}); +test('initials can be searched and an unrelated group is replaced',()=>{ + const {context,nodes}=fixture(99); + nodes.v5UserSearch.value='ct';context.renderUsers(); + assert.equal(nodes.v5UserOptions.children.length,1); + nodes.v5UserOptions.children[0].click();assert.equal(context.groupId,2); +}); diff --git a/tests/js/remote-recovery.test.cjs b/tests/js/remote-recovery.test.cjs new file mode 100644 index 0000000..52d28ec --- /dev/null +++ b/tests/js/remote-recovery.test.cjs @@ -0,0 +1,25 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'); +function client(fetch){ + const context={fetch,AbortController,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,Uint8Array}; + vm.createContext(context);vm.runInContext(fs.readFileSync('static/js/remote-api.js','utf8').replaceAll('export ',''),context); + return context; +} +const response=(data,type='application/json',extra={})=>({ok:true,headers:{get:key=>key==='Content-Type'?type:null},json:async()=>data,...extra}); +test('HTML or redirected login responses cannot replace the remote snapshot',async()=>{ + for(const result of [response({},'text/html'),response({},'application/json',{redirected:true})]){ + const c=client(async()=>result);await assert.rejects(c.api('/state'),/forventede svar/); + } +}); +test('network failure is recoverable and later state can be read',async()=>{ + let offline=true; + const c=client(async(url,options)=>{assert.ok(options.signal);if(offline)throw new Error('offline');return response({timer:{active:{active:false}}});}); + await assert.rejects(c.api('/state'),/Prøver igen/);offline=false;assert.equal((await c.api('/state')).timer.active.active,false); +}); +test('unchanged list data preserves its DOM, changed data triggers a render',()=>{ + const source=fs.readFileSync('static/js/remote-mobile.js','utf8'); + const start=source.indexOf('const renderedLists =');const end=source.indexOf('try { pendingCall',start); + const c={Map};vm.createContext(c);vm.runInContext(source.slice(start,end),c); + assert.equal(c.listChanged('messages',[{id:1,text:'Hej'}]),true); + assert.equal(c.listChanged('messages',[{id:1,text:'Hej'}]),false); + assert.equal(c.listChanged('messages',[{id:1,text:'Ændret'}]),true); +}); diff --git a/tests/test_hardware_labels.py b/tests/test_hardware_labels.py new file mode 100644 index 0000000..dab9dcd --- /dev/null +++ b/tests/test_hardware_labels.py @@ -0,0 +1,50 @@ +import asyncio +import xml.etree.ElementTree as ET + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.modules.hardware.labels import hardware_label_url, hardware_label_qr +from app.modules.hardware.frontend import views + + +def test_label_links_to_exact_asset(): + assert hardware_label_url('https://hub.example.dk/', 42) == 'https://hub.example.dk/hardware/42' + assert hardware_label_url('http://192.168.1.10:8001', 42) == 'http://192.168.1.10:8001/hardware/42' + + +@pytest.mark.parametrize('url', ['javascript:alert(1)', 'https://user:secret@hub.dk', 'https://hub.dk?token=secret', 'https://hub.dk/#secret', '']) +def test_invalid_label_url_is_rejected(url): + with pytest.raises(HTTPException): + hardware_label_url(url, 1) + + +def test_qr_is_a_local_vector_with_quiet_zone(): + xml = ET.fromstring(hardware_label_qr('https://hub.example.dk/hardware/42')) + assert xml.tag == '{http://www.w3.org/2000/svg}svg' + assert xml.find('{http://www.w3.org/2000/svg}path') is not None + assert 'http' not in xml.find('{http://www.w3.org/2000/svg}path').attrib['d'] + + +def test_label_route_renders_asset_and_escapes_text(monkeypatch): + monkeypatch.setattr(views, 'execute_query', lambda sql, params: [{'id':42, 'brand':'BMC', 'model':'', 'serial_number':'ABC-123'}]) + request = Request({'type':'http','method':'GET','path':'/hardware/42/label','headers':[],'scheme':'http','server':('localhost',8001),'query_string':b''}) + response = asyncio.run(views.hardware_label(request,42,size='62x40',base_url='https://hub.example.dk')) + body=response.body.decode() + assert 'BMC-00042' in body + assert 'https://hub.example.dk/hardware/42' in body + assert '' not in body + assert '<script>bad</script>' in body + assert 'size:62mm 40mm' in body + assert response.headers['cache-control']=='no-store' + + +def test_missing_asset_and_invalid_size(monkeypatch): + monkeypatch.setattr(views,'execute_query',lambda *args: []) + with pytest.raises(HTTPException) as error: + asyncio.run(views.hardware_label(None,42,size='62x40',base_url='https://hub.example.dk')) + assert error.value.status_code==404 + with pytest.raises(HTTPException) as error: + asyncio.run(views.hardware_label(None,42,size='bad',base_url='https://hub.example.dk')) + assert error.value.status_code==400 diff --git a/tests/test_remote_control.py b/tests/test_remote_control.py new file mode 100644 index 0000000..da1ef7a --- /dev/null +++ b/tests/test_remote_control.py @@ -0,0 +1,469 @@ +"""Security and real PostgreSQL remote tests in a disposable isolated schema. + +Run integration cases with BMC_REMOTE_DB_TESTS=1; only localhost is accepted. +No production schema, customer record or existing time entry is modified. +""" +import asyncio +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor +from datetime import datetime, timezone, timedelta +from pathlib import Path +from uuid import uuid4 +from urllib.parse import urlsplit + +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.testclient import TestClient +from psycopg2.pool import ThreadedConnectionPool +import psycopg2 + +from app.core.config import settings +from app.core import database +from app.core.auth_dependencies import get_current_user +from app.core.auth_service import AuthService +from app.modules.remote import security, service +from app.modules.remote.router import router, views, Command + + +def test_code_preserves_leading_zeroes(monkeypatch): + monkeypatch.setattr(security.secrets, 'randbelow', lambda maximum: 42) + assert security.new_code() == '000000000042' + + +def test_day_is_copenhagen_calendar_day(): + assert str(security.today(datetime(2026, 10, 13, 22, 30, tzinfo=timezone.utc))) == '2026-10-14' + assert str(security.today(datetime(2026, 1, 13, 22, 30, tzinfo=timezone.utc))) == '2026-01-13' + + +@pytest.mark.parametrize('seconds,status', [(3601, 'normal'), (3600, 'warning'), (0, 'warning'), (-1, 'overdue')]) +def test_deadline_thresholds(seconds, status): + now = datetime(2026, 10, 13, 12, tzinfo=timezone.utc) + row = service.case_display({'deadline': now + timedelta(seconds=seconds)}, now) + assert row['deadline_state'] == status + + +def test_naive_deadline_uses_danish_timezone(): + row = service.case_display({'deadline': datetime(2026, 10, 13, 14)}, datetime(2026, 10, 13, 12, tzinfo=timezone.utc)) + assert row['deadline_seconds'] == 0 + assert service.case_display({'deadline': None})['deadline_state'] == 'none' + + +@pytest.mark.parametrize("scheme", ["http", "https"]) +def test_flag_origin_and_csrf(monkeypatch, scheme): + app = FastAPI(); app.include_router(router) + client = TestClient(app, base_url=f'{scheme}://hub.test') + monkeypatch.setattr(settings, 'REMOTE_ENABLED', False) + assert client.get('/api/v1/remote/bootstrap').status_code == 404 + monkeypatch.setattr(settings, 'REMOTE_ENABLED', True) + monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', '') + assert client.get('/api/v1/remote/bootstrap').status_code == 503 + monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'test-key-only-' * 4) + response = client.get('/api/v1/remote/bootstrap') + assert response.status_code == 200 + assert ('Secure' in response.headers['set-cookie']) == (scheme == 'https') + assert 'HttpOnly' in response.headers['set-cookie'] + assert client.post('/api/v1/remote/connect', json={'code': '0'*12, 'pin': '1234'}).status_code == 403 + assert client.post('/api/v1/remote/connect', headers={'Origin': 'https://other.test'}, json={'pin': '1234'}).status_code == 403 + + +def test_remote_token_is_not_hub_jwt(): + assert AuthService.verify_token('remote-device-random-token') is None + + +@pytest.fixture +def db(monkeypatch): + import os + if os.environ.get('BMC_REMOTE_DB_TESTS') != '1': + pytest.skip('Set BMC_REMOTE_DB_TESTS=1 for isolated local PostgreSQL integration tests') + assert urlsplit(settings.DATABASE_URL).hostname in {'localhost', '127.0.0.1', '::1'} + schema = 'remote_test_' + uuid4().hex + root = psycopg2.connect(settings.DATABASE_URL) + root.autocommit = True + with root.cursor() as cur: + cur.execute('CREATE SCHEMA ' + schema) + pool = ThreadedConnectionPool(1, 12, dsn=settings.DATABASE_URL, options='-c search_path=' + schema) + monkeypatch.setattr(database, 'connection_pool', pool) + monkeypatch.setattr(settings, 'REMOTE_ENABLED', True) + monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'isolated-test-key-only-' * 3) + monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', False) + permissions = {1: ['cases.view'], 2: ['cases.view']} + monkeypatch.setattr(AuthService, 'get_user_permissions', lambda uid: permissions.get(uid, [])) + monkeypatch.setattr(service, 'get_user_messages_summary', lambda uid: {'count': 0, 'list': []}) + try: + with database.atomic_transaction(): + database.execute_query('''CREATE TABLE users(user_id INTEGER PRIMARY KEY,username TEXT,full_name TEXT,email TEXT,is_2fa_enabled BOOLEAN DEFAULT FALSE,is_superadmin BOOLEAN DEFAULT FALSE,is_active BOOLEAN DEFAULT TRUE); + CREATE TABLE sessions(user_id INTEGER,token_jti TEXT,expires_at TIMESTAMP,revoked BOOLEAN DEFAULT FALSE); + CREATE TABLE customers(id INTEGER PRIMARY KEY,name TEXT); + CREATE TABLE sag_sager(id INTEGER PRIMARY KEY,titel TEXT,status TEXT,deadline TIMESTAMP,customer_id INTEGER,ansvarlig_bruger_id INTEGER,deleted_at TIMESTAMP,created_at TIMESTAMP DEFAULT NOW()); + CREATE TABLE contacts(id INTEGER PRIMARY KEY,first_name TEXT,last_name TEXT,email TEXT,phone TEXT,mobile TEXT,title TEXT,department TEXT,user_company TEXT,is_active BOOLEAN,created_at TIMESTAMP,updated_at TIMESTAMP); + CREATE TABLE contact_companies(contact_id INTEGER,customer_id INTEGER); + INSERT INTO contacts(id,first_name,last_name,phone,mobile,is_active) VALUES(1,'Test','Kontakt','+45 12345678','87654321',TRUE),(2,'Inaktiv','Kontakt','12345678',NULL,FALSE); + CREATE TABLE settings(key TEXT,value TEXT); + CREATE TABLE tmodule_times (LIKE public.tmodule_times INCLUDING DEFAULTS); + ALTER TABLE tmodule_times ALTER COLUMN id DROP DEFAULT; + ALTER TABLE tmodule_times ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY; + CREATE FUNCTION remote_test_update() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN NEW.updated_at=clock_timestamp();RETURN NEW;END$$; + CREATE TRIGGER remote_test_update BEFORE UPDATE ON tmodule_times FOR EACH ROW EXECUTE FUNCTION remote_test_update(); + INSERT INTO users(user_id,username,full_name) VALUES(1,'test1','Test One'),(2,'test2','Test Two'); + INSERT INTO sag_sager(id,titel,status,ansvarlig_bruger_id) VALUES(11,'Ny testsag','Ny',1),(12,'Anden sag','I gang',1),(13,'Lukket sag','Lukket',1),(14,'Andens sag','Ny',2);''') + database.execute_query(Path('migrations/1069_remote_control.sql').read_text()) + yield permissions + finally: + pool.closeall() + with root.cursor() as cur: + cur.execute('DROP SCHEMA ' + schema + ' CASCADE') + root.close() + + +@pytest.fixture(params=["http", "https"]) +def web(db, request): + app = FastAPI(); app.include_router(router); app.include_router(views) + async def hub_user(request: Request): + uid = request.cookies.get('access_token') + if uid not in {'hub1', 'hub2'}: + raise HTTPException(401, 'Hub login required') + uid = int(uid[-1]) + return {'id': uid, 'username': 'test' + str(uid), 'full_name': 'Test', 'permissions': db[uid]} + app.dependency_overrides[get_current_user] = hub_user + @app.get('/ordinary') + async def ordinary(user=__import__('fastapi').Depends(get_current_user)): + return {'id': user['id']} + client = TestClient(app, base_url=f'{request.param}://hub.test') + client.cookies.set('access_token', 'hub1') + csrf = client.get('/api/v1/remote/bootstrap').json()['csrf'] + headers = {'Origin': str(client.base_url).rstrip('/'), 'X-Remote-CSRF': csrf} + return client, headers + + +def connect(web): + client, headers = web + assert client.put('/api/v1/remote/admin/pin', json={'pin': '1234'}, headers=headers).status_code == 200 + remote = client.post('/api/v1/remote/admin/remotes', json={'name': 'Test remote'}, headers=headers).json() + response = client.post('/api/v1/remote/connect', json={'code': remote['code'], 'pin': '1234'}, headers=headers) + assert response.status_code == 200, response.text + assert ('Secure' in response.headers['set-cookie']) == (client.base_url.scheme == 'https') + assert 'HttpOnly' in response.headers['set-cookie'] + return remote + + +def post_command(web, action, **kwargs): + client, headers = web + snapshot = client.get('/api/v1/remote/state').json() + payload = {'request_id': str(uuid4()), 'version': snapshot['version'], 'timer_version': snapshot['timer_version'], 'action': action, **kwargs} + return client.post('/api/v1/remote/command', json=payload, headers=headers), payload + + +def test_connection_isolation_pin_rotation_expiry(web, db): + client, headers = web; remote = connect(web) + assert client.get('/api/v1/remote/state').status_code == 200 + # A remote cookie alone grants no full Hub or management access. + client.cookies.delete('access_token') + assert client.get('/ordinary').status_code == 401 + assert client.get('/api/v1/remote/admin').status_code == 401 + client.cookies.set('access_token', 'hub2') + assert client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers).status_code == 404 + assert client.get('/api/v1/remote/admin').json()['remotes'] == [] + client.cookies.set('access_token', 'hub1') + assert client.put('/api/v1/remote/admin/pin', json={'pin': '5678'}, headers=headers).status_code == 200 + assert client.get('/api/v1/remote/state').status_code == 423 + database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") + assert client.post('/api/v1/remote/unlock', json={'pin': '5678'}, headers=headers).status_code == 200 + database.execute_query("UPDATE remote_devices SET unlocked_date=unlocked_date-1") + assert client.get('/api/v1/remote/state').status_code == 423 + database.execute_query("UPDATE remote_devices SET expires_at=NOW()-INTERVAL '1 second'") + assert client.get('/api/v1/remote/state').status_code == 401 + + +def test_failed_attempts_are_committed_and_shared_between_connections(web): + client, headers = web + assert client.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers).status_code == 401 + second = TestClient(client.app, base_url=str(client.base_url)); second.cookies.update(client.cookies) + result = second.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers) + assert result.status_code == 429 and 1 <= int(result.headers['Retry-After']) <= 30 + assert database.execute_query_single('SELECT COUNT(*) AS count FROM remote_attempts')['count'] == 1 + + +def test_rate_limit_is_atomic_across_concurrent_db_connections(db): + def attempt(): + try: + security.reserve_attempt('isolated-ip', 1, uuid4()) + return 200 + except HTTPException as exc: + return exc.status_code + with ThreadPoolExecutor(max_workers=4) as executor: + outcomes = list(executor.map(lambda _: attempt(), range(4))) + assert sorted(outcomes) == [200, 429, 429, 429] + + +def process_attempt(options): + """Separate spawned worker: no shared Python lock, pool, or rate-limit memory.""" + settings.REMOTE_LOOKUP_KEY = 'isolated-test-key-only-' * 3 + database.connection_pool = ThreadedConnectionPool(1, 2, dsn=settings.DATABASE_URL, options=options) + try: + try: + security.reserve_attempt('cross-process-ip', 1) + return 200 + except HTTPException as exc: + return exc.status_code + finally: + database.connection_pool.closeall() + + +def test_rate_limit_is_shared_between_processes(db): + import multiprocessing + conn = database.get_db_connection() + options = conn.get_dsn_parameters()['options'] + database.release_db_connection(conn) + with ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as executor: + assert sorted(executor.map(process_attempt, [options, options])) == [200, 429] + + +def test_timer_actions_manual_time_idempotency_and_conflict(web): + client, headers = web; connect(web) + result, payload = post_command(web, 'start', case_id=11) + assert result.status_code == 200, result.text + assert client.post('/api/v1/remote/command', json=payload, headers=headers).json() == result.json() + assert client.get('/api/v1/remote/command-result/' + payload['request_id']).json()['completed'] is True + assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times')['count'] == 1 + payload['case_id'] = 12 + assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409 + assert post_command(web, 'select', case_id=12)[0].status_code == 409 + assert post_command(web, 'select', case_id=12, switch_timer=False)[0].status_code == 200 + assert client.get('/api/v1/remote/state').json()['timer']['active']['sag_id'] == 11 + assert post_command(web, 'select', case_id=12, switch_timer=True)[0].status_code == 200 + snapshot = client.get('/api/v1/remote/state').json() + assert snapshot['timer']['active']['sag_id'] == 12 + assert post_command(web, 'pause')[0].status_code == 200 + tid = snapshot['timer']['active']['time_entry_id'] + assert post_command(web, 'resume', time_id=tid)[0].status_code == 200 + assert post_command(web, 'stop', time_id=tid, description='Test arbejde')[0].status_code == 200 + assert post_command(web, 'manual', case_id=12, description='Test manuel tid', minutes=17)[0].status_code == 200 + assert client.get('/api/v1/remote/state').json()['today_minutes'] >= 17 + # Desktop modifications invalidate an already prepared remote command. + snapshot = client.get('/api/v1/remote/state').json() + payload = dict(request_id=str(uuid4()), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) + import importlib + timers = importlib.import_module('app.timetracking.backend.router') + asyncio.run(timers.start_live_timer_v1({'sag_id': 12, 'billing_mode': 'automatic'}, {'id': 1, 'username': 'test1'})) + assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409 + + +def test_concurrent_commands_cannot_create_duplicate_timers(web): + client, headers = web; connect(web) + snapshot = client.get('/api/v1/remote/state').json() + def start(_): + payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) + try: + asyncio.run(service.command(payload, {'id': 1, 'username': 'test1', 'permissions': ['cases.view']})) + return 200 + except HTTPException as exc: + return exc.status_code + with ThreadPoolExecutor(max_workers=2) as executor: + assert sorted(executor.map(start, range(2))) == [200, 409] + assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1 + + +def test_only_selected_follower_and_no_stale_selection(web): + client, headers = web; connect(web) + first, second = str(uuid4()), str(uuid4()) + client.post('/api/v1/remote/follow', json={'tab_id': first, 'enable': True}, headers=headers) + assert post_command(web, 'select', case_id=11)[0].status_code == 200 + event = client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json() + assert event['selection'] == {'case_id': 11} + client.post('/api/v1/remote/follow', json={'tab_id': second, 'enable': True}, headers=headers) + assert client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json()['following'] is False + assert post_command(web, 'select', case_id=12)[0].status_code == 200 + database.execute_query("UPDATE remote_user_state SET follower_seen_at=NOW()-INTERVAL '15 seconds'") + event = client.post('/api/v1/remote/follow', json={'tab_id': second}, headers=headers).json() + assert event['selection'] is None + + +def test_case_filters_permissions_and_revocation(web, db): + client, headers = web; remote = connect(web) + assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=new').json()['items']} == {11,14} + assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=own').json()['items']} == {11,12} + assert client.get('/api/v1/remote/cases?q=11').json()['items'][0]['id'] == 11 + db[1] = [] + assert client.get('/api/v1/remote/cases').status_code == 403 + assert post_command(web, 'start', case_id=11)[0].status_code == 403 + db[1] = ['cases.view'] + assert client.request('DELETE', f"/api/v1/remote/admin/remotes/{remote['id']}", json={}, headers=headers).status_code == 200 + assert client.get('/api/v1/remote/state').status_code == 401 + + +def test_leading_zero_code_and_no_hashes_in_administration(web, monkeypatch): + client, headers = web + monkeypatch.setattr('app.modules.remote.router.new_code', lambda: '000000000042') + remote = connect(web) + assert remote['code'] == '000000000042' + admin = client.get('/api/v1/remote/admin').json() + assert not {'code', 'code_hash', 'code_lookup', 'pin_hash', 'token_hash'}.intersection(admin['remotes'][0]) + assert not {'token_hash', 'pin_hash'}.intersection(admin['devices'][0]) + device_id = admin['devices'][0]['id'] + assert client.request('DELETE', '/api/v1/remote/admin/devices/' + device_id, json={}, headers=headers).status_code == 200 + assert client.get('/api/v1/remote/state').status_code == 401 + + +def test_rotate_code_revokes_devices_and_inactive_user_is_denied(web): + client, headers = web; remote = connect(web) + result = client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers) + assert result.status_code == 200 and result.json()['code'] != remote['code'] + assert client.get('/api/v1/remote/state').status_code == 401 + database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") + assert client.post('/api/v1/remote/connect', json={'code': result.json()['code'], 'pin': '1234'}, headers=headers).status_code == 200 + database.execute_query('UPDATE users SET is_active=FALSE WHERE user_id=1') + assert client.get('/api/v1/remote/state').status_code == 401 + + +def test_wrong_pin_and_reconnect_do_not_bypass_cooldown(web): + client, headers = web; connect(web) + database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") + database.execute_query('UPDATE remote_devices SET unlocked_date=unlocked_date-1') + assert client.post('/api/v1/remote/unlock', json={'pin': '9999'}, headers=headers).status_code == 401 + assert client.post('/api/v1/remote/unlock', json={'pin': '1234'}, headers=headers).status_code == 429 + assert client.get('/api/v1/remote/state').status_code == 423 + + +def test_message_read_is_explicit_and_uses_current_user(web, monkeypatch): + client, _ = web; connect(web) + calls = [] + monkeypatch.setattr(service, 'mark_user_messages_read', lambda uid, partner_user_id=None: calls.append((uid,partner_user_id)) or 1) + assert client.get('/api/v1/remote/state').status_code == 200 + assert calls == [] + assert post_command(web, 'read', partner_user_id=2)[0].status_code == 200 + assert calls == [(1,2)] + + +def test_desktop_start_and_remote_start_share_user_lock(web): + client, _ = web; connect(web) + snapshot = client.get('/api/v1/remote/state').json() + user = {'id': 1, 'username': 'test1', 'permissions': ['cases.view']} + def start_remote(): + payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) + try: + asyncio.run(service.command(payload,user)) + return 200 + except HTTPException as exc: + return exc.status_code + def start_desktop(): + import importlib + timers = importlib.import_module('app.timetracking.backend.router') + asyncio.run(timers.start_live_timer_v1({'sag_id':12,'billing_mode':'automatic'},user)) + return 200 + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = [executor.submit(start_remote),executor.submit(start_desktop)] + assert all(f.result() in {200,409} for f in outcomes) + assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1 + + +def test_real_hub_jwt_remains_separate_and_revocable(web): + client, _ = web; connect(web) + client.app.dependency_overrides.clear() + token = AuthService.create_access_token(1, 'test1') + client.cookies.set('access_token', token) + assert client.get('/ordinary').json() == {'id': 1} + assert client.get('/api/v1/remote/admin').status_code == 200 + client.cookies.set('access_token', client.cookies.get(security.COOKIE)) + assert client.get('/ordinary').status_code == 401 + client.cookies.set('access_token', token) + database.execute_query('UPDATE sessions SET revoked=TRUE') + assert client.get('/ordinary').status_code == 401 + assert client.get('/api/v1/remote/admin').status_code == 401 + assert client.get('/api/v1/remote/state').status_code == 200 + + +def test_remote_contacts_and_owner_only_call(web, db, monkeypatch): + from app.modules.telefoni.backend import router as phone_router + client, headers = web; connect(web) + assert client.get('/api/v1/remote/contacts').status_code == 403 + db[1].append('customers.view') + result = client.get('/api/v1/remote/contacts?q=Kontakt').json() + assert len(result['items']) == 1 and result['items'][0]['id'] == 1 + calls = [] + async def fake_call(payload, request): + calls.append((payload.model_dump(), request.state.user_id)) + return {'action_url': 'secret-must-not-be-returned'} + monkeypatch.setattr(phone_router, 'click_to_call', fake_call) + payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'mobile'} + first = client.post('/api/v1/remote/call', json=payload, headers=headers) + assert first.status_code == 200 and first.json()['status'] == 'ok' + assert 'secret' not in first.text + assert client.post('/api/v1/remote/call', json=payload, headers=headers).json() == first.json() + assert len(calls) == 1 and calls[0][0]['user_id'] == 1 and calls[0][1] == 1 + assert calls[0][0]['number'] == '87654321' and calls[0][0]['extension'] is None + assert client.get('/api/v1/remote/call/' + payload['request_id']).json() == first.json() + assert client.post('/api/v1/remote/call', json={**payload, 'user_id': 2}, headers=headers).status_code == 422 + assert client.post('/api/v1/remote/call', json={**payload, 'field': 'phone'}, headers=headers).status_code == 409 + assert client.post('/api/v1/remote/call', json={**payload, 'request_id': str(uuid4()), 'contact_id': 2}, headers=headers).status_code == 404 + + +def test_uncertain_call_is_not_repeated(web, db, monkeypatch): + from app.modules.telefoni.backend import router as phone_router + client, headers = web; connect(web); db[1].append('customers.view') + calls = [] + async def failed_call(payload, request): + calls.append(payload) + raise HTTPException(502, 'timeout') + monkeypatch.setattr(phone_router, 'click_to_call', failed_call) + payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'} + assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain' + assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain' + assert len(calls) == 1 + assert client.get('/api/v1/remote/call/' + str(uuid4())).json()['status'] == 'unknown' + assert len(calls) == 1 + + +def test_incoming_calls_use_shared_state_and_ignore_stale(monkeypatch): + from app.modules.remote import telephony + now = datetime.now(timezone.utc) + monkeypatch.setattr(telephony.MissionService, 'get_active_calls', lambda: [ + {'call_id':'fresh','updated_at':now,'caller_number':'12345678','ringing_phones':['Support']}, + {'call_id':'stale','updated_at':now-timedelta(minutes=10)}]) + user = {'id': 1, 'permissions': ['customers.view']} + assert [c['call_id'] for c in telephony.status(user)['incoming']] == ['fresh'] + assert telephony.status({'id':2,'permissions':[]}) == {'allowed':False,'incoming':[]} + + +def test_concurrent_same_call_reserves_before_external_action(web, db, monkeypatch): + from app.modules.telefoni.backend import router as phone_router + client, headers = web; connect(web); db[1].append('customers.view') + calls = [] + async def slow_call(payload, request): + calls.append(payload.user_id) + await asyncio.sleep(0.1) + return {'status': 'ok'} + monkeypatch.setattr(phone_router, 'click_to_call', slow_call) + payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'} + def send(_): + with TestClient(client.app, base_url=str(client.base_url)) as other: + other.cookies.update(client.cookies) + return other.post('/api/v1/remote/call', json=payload, headers=headers) + with ThreadPoolExecutor(max_workers=2) as workers: + responses = list(workers.map(send, range(2))) + assert all(response.status_code == 200 for response in responses) + assert calls == [1] + assert client.get('/api/v1/remote/call/' + payload['request_id']).json()['status'] == 'ok' + + +def test_remote_planned_day_is_scoped_to_user_and_danish_day(monkeypatch): + from datetime import date + captured = [] + monkeypatch.setattr(service, 'case_permission', lambda user: None) + monkeypatch.setattr(service, 'execute_query', lambda sql, params: captured.append((sql, params)) or []) + result = service.planned_day({'id': 7}, date(2026, 10, 14)) + assert result == {'date': '2026-10-14', 'items': []} + sql, params = captured[0] + assert params[0] == params[3] == 7 + assert params[1].hour == params[2].hour == 0 + assert params[1].day == 15 and params[2].day == 14 + assert params[2].utcoffset() == timedelta(hours=2) + assert "q.state='planned'" in sql and 'a.deleted_at IS NULL' in sql + + +def test_remote_planned_day_uses_dst_boundaries(monkeypatch): + from datetime import date + calls = [] + monkeypatch.setattr(service, 'case_permission', lambda user: None) + monkeypatch.setattr(service, 'execute_query', lambda sql, params: calls.append(params) or []) + service.planned_day({'id': 7}, date(2026, 10, 25)) + end, start = calls[0][1:3] + assert (end.astimezone(timezone.utc) - start.astimezone(timezone.utc)) == timedelta(hours=25) diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index 5af08dc..c08804d 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -270,7 +270,7 @@ def test_case_create_uses_simple_type_first_flow_with_required_description(): script = Path("static/js/case-create.js").read_text(encoding="utf-8") assert 'id="beskrivelse"' in template - assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template + assert 'placeholder="Beskriv sagen. Skriv / på en ny linje for handlinger…" required' in template assert 'id="advancedCaseFields"' in template assert "keys.map(button).join('')" in script assert "keys.slice(0,4)" not in script diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index c3c2e93..e5b24ab 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -82,3 +82,10 @@ def test_missing_customer_mapping_never_becomes_internal_time(): result=evaluate([rule('internal')],{'customer_id':None,'customer_mapping_missing':True},'time.created') assert result['status']=='manual_review' assert result['reason']=='missing_customer_mapping' + +def test_case_title_condition_is_distinct_from_case_id(): + definition={'trigger':'case.updated','branches':[{'condition':{'field':'case_title','operator':'contains','value':'TEST'},'actions':[{'type':'notify','title':'Matched','recipient_user_ids':[1]}]}]} + validate_definition(definition) + result=evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'TEST tid'},'case.updated') + assert result['actions'][0]['title']=='Matched' + assert not evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'Printer'},'case.updated')['actions'] diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index 24281f6..993476f 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -38,3 +38,30 @@ def test_resource_audit_and_explicit_null_monthly_price(monkeypatch): assert update.args[1][1] is None assert update.args[1][0].adapted['auto_billing'] is True assert any('INSERT INTO bmc_workflow_resource_history' in call.args[0] for call in cursor.execute.call_args_list) + +def test_customer_card_coverage_saves_profile_and_audit(monkeypatch): + cursor=MagicMock() + cursor.fetchone.side_effect=[{'id':24}, None] + cursor.fetchall.return_value=[] + @contextmanager + def transaction(): yield cursor + monkeypatch.setattr(resources,'transaction',transaction) + result=resources.update_customer_card_coverage(24,resources.Coverage(),{'id':4}) + assert result['workflow_coverage']['auto_billing'] is True + calls=[c.args for c in cursor.execute.call_args_list] + assert any('INSERT INTO bmc_workflow_customer_card_coverage' in c[0] and c[1][0]==24 for c in calls) + assert any('INSERT INTO bmc_workflow_resource_history' in c[0] and c[1][0]=='customer_card' for c in calls) + assert not any('UPDATE tticket_prepaid_cards' in c[0] for c in calls) + + +def test_customer_card_coverage_rejects_inactive_tags(monkeypatch): + cursor=MagicMock() + cursor.fetchone.return_value={'id':24} + cursor.fetchall.return_value=[] + @contextmanager + def transaction(): yield cursor + monkeypatch.setattr(resources,'transaction',transaction) + with pytest.raises(HTTPException) as error: + resources.update_customer_card_coverage(24,resources.Coverage(tag_ids=[9]),{'id':4}) + assert error.value.status_code==422 + assert not any(c.args[0].startswith(('INSERT','UPDATE')) for c in cursor.execute.call_args_list) diff --git a/tests/test_workflow_timer_registration.py b/tests/test_workflow_timer_registration.py new file mode 100644 index 0000000..7b0478f --- /dev/null +++ b/tests/test_workflow_timer_registration.py @@ -0,0 +1,41 @@ +import asyncio +from datetime import datetime +import pytest +from fastapi import HTTPException +import importlib +r = importlib.import_module('app.timetracking.backend.router') +from app.core.config import settings +from app.workflows import integration, settlement + + +def setup(monkeypatch, **changes): + entry = dict(id=7, medarbejder_id=2, status='pending', aktiv_timer=False, slut_tid=datetime.now(), work_type='support') + entry.update(changes) + monkeypatch.setattr(r, 'execute_query_single', lambda *a: entry) + monkeypatch.setattr(r, '_resolve_current_user_id', lambda user: 2) + monkeypatch.setattr(r, '_can_view_all_team_timers', lambda user: False) + monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', True) + monkeypatch.setattr(settings, 'WORKFLOWS_MODE', 'active') + monkeypatch.setattr(integration, 'active_for_customer', lambda customer: True) + monkeypatch.setattr(settlement, 'workflow_customer', lambda time_id: 17) + return entry + + +def test_register_preserves_pending_and_billing(monkeypatch): + entry = setup(monkeypatch) + calls = [] + monkeypatch.setattr(r, 'execute_query', lambda sql, params: calls.append((sql, params)) or [entry]) + result = asyncio.run(r.register_workflow_timer_v1(7, {'work_type':'meeting','fakturerbar_tid_min':30}, {'id':2})) + sql, params = calls[0] + assert params == ('meeting',30,7) + assert 'approved_hours' not in sql and 'billing_method' not in sql + assert "status='pending'" in sql and "kilde='workflow_registered'" in sql + assert result['status'] == 'pending' + + +@pytest.mark.parametrize('changes', [{'medarbejder_id':3}, {'status':'approved'}, {'aktiv_timer':True}, {'economy_order_draft_id':9}]) +def test_register_rejects_other_owner_or_processed_timer(monkeypatch, changes): + setup(monkeypatch, **changes) + monkeypatch.setattr(r, 'execute_query', lambda *a: pytest.fail('Must not write')) + with pytest.raises(HTTPException): + asyncio.run(r.register_workflow_timer_v1(7, {'fakturerbar_tid_min':30}, {'id':2}))