"""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