From e3094d7ed04e69d32af36cc5bffe8fd775170d74 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 7 Mar 2026 02:39:57 +0100 Subject: [PATCH] Fix user admin actions on v2 + add archived sync monitor in settings --- app/auth/backend/admin.py | 51 +++++ app/settings/backend/router.py | 8 +- app/settings/frontend/settings.html | 276 +++++++++++++++++++++++++++- app/system/backend/sync_router.py | 14 +- app/ticket/backend/router.py | 128 ++++++++++++- 5 files changed, 453 insertions(+), 24 deletions(-) diff --git a/app/auth/backend/admin.py b/app/auth/backend/admin.py index 1d0b8b1..0a53cef 100644 --- a/app/auth/backend/admin.py +++ b/app/auth/backend/admin.py @@ -2,6 +2,7 @@ Auth Admin API - Users, Groups, Permissions management """ from fastapi import APIRouter, HTTPException, status, Depends +from pydantic import BaseModel, Field from app.core.auth_dependencies import require_permission from app.core.auth_service import AuthService from app.core.database import execute_query, execute_query_single, execute_insert, execute_update @@ -13,6 +14,14 @@ logger = logging.getLogger(__name__) router = APIRouter() +class UserStatusUpdateRequest(BaseModel): + is_active: bool + + +class UserPasswordResetRequest(BaseModel): + new_password: str = Field(..., min_length=8, max_length=128) + + @router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))]) async def list_users(): users = execute_query( @@ -94,6 +103,48 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate): return {"message": "Groups updated"} +@router.patch("/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))]) +async def update_user_status(user_id: int, payload: UserStatusUpdateRequest): + user = execute_query_single( + "SELECT user_id, username FROM users WHERE user_id = %s", + (user_id,) + ) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + execute_update( + "UPDATE users SET is_active = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (payload.is_active, user_id) + ) + + logger.info("✅ Updated user status via admin: %s -> active=%s", user.get("username"), payload.is_active) + return {"message": "User status updated", "user_id": user_id, "is_active": payload.is_active} + + +@router.post("/admin/users/{user_id}/reset-password", dependencies=[Depends(require_permission("users.manage"))]) +async def admin_reset_user_password(user_id: int, payload: UserPasswordResetRequest): + user = execute_query_single( + "SELECT user_id, username FROM users WHERE user_id = %s", + (user_id,) + ) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + try: + password_hash = AuthService.hash_password(payload.new_password) + except Exception as exc: + logger.error("❌ Password hash failed for user_id=%s: %s", user_id, exc) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kunne ikke hashe adgangskoden") from exc + + execute_update( + "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (password_hash, user_id) + ) + + logger.info("✅ Password reset via admin for user: %s", user.get("username")) + return {"message": "Password reset", "user_id": user_id} + + @router.post("/admin/users/{user_id}/2fa/reset") async def reset_user_2fa( user_id: int, diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index 00d3912..afa46a8 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -180,7 +180,7 @@ async def sync_settings_from_env(): @router.get("/users", response_model=List[User], tags=["Users"]) async def get_users(is_active: Optional[bool] = None): """Get all users""" - query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users" + query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users" params = [] if is_active is not None: @@ -195,7 +195,7 @@ async def get_users(is_active: Optional[bool] = None): @router.get("/users/{user_id}", response_model=User, tags=["Users"]) async def get_user(user_id: int): """Get user by ID""" - query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users WHERE user_id = %s" + query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users WHERE user_id = %s" result = execute_query(query, (user_id,)) if not result: @@ -219,7 +219,7 @@ async def create_user(user: UserCreate): query = """ INSERT INTO users (username, email, password_hash, full_name, is_active) VALUES (%s, %s, %s, %s, true) - RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at + RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at """ result = execute_query(query, (user.username, user.email, password_hash, user.full_name)) @@ -260,7 +260,7 @@ async def update_user(user_id: int, user: UserUpdate): UPDATE users SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s - RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at + RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at """ result = execute_query(query, tuple(params)) diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index f5c6956..3042139 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -764,6 +764,62 @@ + +
+
+
+
Archived Tickets Sync
+ Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB) +
+
+ Status ukendt + +
+
+
+
+
+
+
+
Simply archived
+ Ukendt +
+
Remote: - | Lokal: - | Diff: -
+
Beskeder lokalt: -
+
+ +
+
+
+ +
+
+
+
vTiger Cases archived
+ Ukendt +
+
Remote: - | Lokal: - | Diff: -
+
Beskeder lokalt: -
+
+ +
+
+
+
+ +
+ Sidst tjekket: Aldrig + Polling aktiv naar Sync-fanen er aaben. +
+
+
+
@@ -2473,17 +2529,21 @@ async function createUser() { async function toggleUserActive(userId, isActive) { try { - const response = await fetch(`/api/v1/users/${userId}`, { - method: 'PUT', + const response = await fetch(`/api/v1/admin/users/${userId}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: isActive }) }); - if (response.ok) { - loadUsers(); + if (!response.ok) { + alert(await getErrorMessage(response, 'Kunne ikke opdatere brugerstatus')); + return; } + + loadUsers(); } catch (error) { console.error('Error toggling user:', error); + alert('Kunne ikke opdatere brugerstatus'); } } @@ -2666,13 +2726,18 @@ async function resetPassword(userId) { if (!newPassword) return; try { - const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, { - method: 'POST' + const response = await fetch(`/api/v1/admin/users/${userId}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_password: newPassword }) }); - if (response.ok) { - alert('Adgangskode nulstillet!'); + if (!response.ok) { + alert(await getErrorMessage(response, 'Kunne ikke nulstille adgangskode')); + return; } + + alert('Adgangskode nulstillet!'); } catch (error) { console.error('Error resetting password:', error); alert('Kunne ikke nulstille adgangskode'); @@ -3313,6 +3378,7 @@ if (tagsNavLink) { // ====== SYNC MANAGEMENT ====== let syncLog = []; +let archivedSyncPollInterval = null; async function loadSyncStats() { try { @@ -3397,9 +3463,195 @@ async function parseApiError(response, fallbackMessage) { return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.'; } + if (response.status === 403) { + if (String(detailMessage).includes('Missing required permission') || String(detailMessage).includes('Superadmin access required')) { + return 'Kun admin/superadmin må starte eller overvåge sync.'; + } + } + return detailMessage; } +function updateArchivedSourceBadge(sourceKey, isSynced, hasError) { + const badgeId = sourceKey === 'simplycrm' ? 'archivedSimplyBadge' : 'archivedVtigerBadge'; + const badge = document.getElementById(badgeId); + if (!badge) return; + + if (hasError) { + badge.className = 'badge bg-danger'; + badge.textContent = 'Fejl'; + return; + } + + if (isSynced === true) { + badge.className = 'badge bg-success'; + badge.textContent = 'Synket'; + return; + } + + badge.className = 'badge bg-warning text-dark'; + badge.textContent = 'Mangler'; +} + +function startArchivedSyncPolling() { + if (archivedSyncPollInterval) return; + archivedSyncPollInterval = setInterval(() => { + loadArchivedSyncStatus(); + }, 15000); +} + +function stopArchivedSyncPolling() { + if (!archivedSyncPollInterval) return; + clearInterval(archivedSyncPollInterval); + archivedSyncPollInterval = null; +} + +async function loadArchivedSyncStatus() { + const overallBadge = document.getElementById('archivedOverallBadge'); + const lastChecked = document.getElementById('archivedLastChecked'); + const hint = document.getElementById('archivedStatusHint'); + + try { + const response = await fetch('/api/v1/ticket/archived/status'); + if (!response.ok) { + const errorMessage = await parseApiError(response, 'Kunne ikke hente archived status'); + throw new Error(errorMessage); + } + + const status = await response.json(); + const simply = (status.sources || {}).simplycrm || {}; + const vtiger = (status.sources || {}).vtiger || {}; + + const setText = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = value === null || value === undefined ? '-' : value; + }; + + setText('archivedSimplyRemoteCount', simply.remote_total_tickets); + setText('archivedSimplyLocalCount', simply.local_total_tickets); + setText('archivedSimplyDiff', simply.diff); + setText('archivedSimplyMessagesCount', simply.local_total_messages); + + setText('archivedVtigerRemoteCount', vtiger.remote_total_tickets); + setText('archivedVtigerLocalCount', vtiger.local_total_tickets); + setText('archivedVtigerDiff', vtiger.diff); + setText('archivedVtigerMessagesCount', vtiger.local_total_messages); + + updateArchivedSourceBadge('simplycrm', simply.is_synced, !!simply.error); + updateArchivedSourceBadge('vtiger', vtiger.is_synced, !!vtiger.error); + + if (overallBadge) { + if (status.overall_synced === true) { + overallBadge.className = 'badge bg-success'; + overallBadge.textContent = 'Alt synced'; + } else { + overallBadge.className = 'badge bg-warning text-dark'; + overallBadge.textContent = 'Ikke fuldt synced'; + } + } + + if (lastChecked) { + lastChecked.textContent = new Date().toLocaleString('da-DK'); + } + + if (hint) { + const errors = [simply.error, vtiger.error].filter(Boolean); + hint.textContent = errors.length > 0 + ? `Statusfejl: ${errors.join(' | ')}` + : 'Polling aktiv mens Sync-fanen er åben.'; + } + } catch (error) { + if (overallBadge) { + overallBadge.className = 'badge bg-danger'; + overallBadge.textContent = 'Statusfejl'; + } + if (hint) { + hint.textContent = error.message; + } + console.error('Error loading archived sync status:', error); + } +} + +async function syncArchivedSimply() { + const btn = document.getElementById('btnSyncArchivedSimply'); + if (!btn) return; + + btn.disabled = true; + btn.innerHTML = 'Synkroniserer...'; + + try { + addSyncLogEntry('Simply Archived Sync Startet', 'Importerer archived tickets fra Simply...', 'info'); + + const response = await fetch('/api/v1/ticket/archived/simply/import?limit=5000&include_messages=true&force=false', { + method: 'POST' + }); + + if (!response.ok) { + const errorMessage = await parseApiError(response, 'Simply archived sync fejlede'); + throw new Error(errorMessage); + } + + const result = await response.json(); + const details = [ + `Importeret: ${result.imported || 0}`, + `Opdateret: ${result.updated || 0}`, + `Sprunget over: ${result.skipped || 0}`, + `Fejl: ${result.errors || 0}`, + `Beskeder: ${result.messages_imported || 0}` + ].join(' | '); + addSyncLogEntry('Simply Archived Sync Fuldført', details, 'success'); + + await loadArchivedSyncStatus(); + showNotification('Simply archived sync fuldført!', 'success'); + } catch (error) { + addSyncLogEntry('Simply Archived Sync Fejl', error.message, 'error'); + showNotification('Fejl: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Sync Simply Archived'; + } +} + +async function syncArchivedVtiger() { + const btn = document.getElementById('btnSyncArchivedVtiger'); + if (!btn) return; + + btn.disabled = true; + btn.innerHTML = 'Synkroniserer...'; + + try { + addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info'); + + const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', { + method: 'POST' + }); + + if (!response.ok) { + const errorMessage = await parseApiError(response, 'vTiger archived sync fejlede'); + throw new Error(errorMessage); + } + + const result = await response.json(); + const details = [ + `Importeret: ${result.imported || 0}`, + `Opdateret: ${result.updated || 0}`, + `Sprunget over: ${result.skipped || 0}`, + `Fejl: ${result.errors || 0}`, + `Beskeder: ${result.messages_imported || 0}` + ].join(' | '); + addSyncLogEntry('vTiger Archived Sync Fuldført', details, 'success'); + + await loadArchivedSyncStatus(); + showNotification('vTiger archived sync fuldført!', 'success'); + } catch (error) { + addSyncLogEntry('vTiger Archived Sync Fejl', error.message, 'error'); + showNotification('Fejl: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Sync vTiger Archived'; + } +} + async function syncFromVtiger() { const btn = document.getElementById('btnSyncVtiger'); btn.disabled = true; @@ -3578,9 +3830,17 @@ if (syncNavLink) { syncNavLink.addEventListener('click', () => { loadSyncStats(); loadSyncLog(); + loadArchivedSyncStatus(); + startArchivedSyncPolling(); }); } +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopArchivedSyncPolling(); + } +}); + // Notification helper function showNotification(message, type = 'info') { // Create toast notification diff --git a/app/system/backend/sync_router.py b/app/system/backend/sync_router.py index b04d532..f8683d8 100644 --- a/app/system/backend/sync_router.py +++ b/app/system/backend/sync_router.py @@ -31,15 +31,17 @@ SYNC RULES: """ import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any from app.core.database import execute_query +from app.core.auth_dependencies import require_any_permission from app.services.vtiger_service import get_vtiger_service import re logger = logging.getLogger(__name__) router = APIRouter() +sync_admin_access = require_any_permission("users.manage", "system.admin") def normalize_name(name: str) -> str: @@ -53,7 +55,7 @@ def normalize_name(name: str) -> str: @router.post("/sync/vtiger") -async def sync_from_vtiger() -> Dict[str, Any]: +async def sync_from_vtiger(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: """ Link vTiger accounts to existing Hub customers Matches by CVR or normalized name, updates vtiger_id @@ -186,7 +188,7 @@ async def sync_from_vtiger() -> Dict[str, Any]: @router.post("/sync/vtiger-contacts") -async def sync_vtiger_contacts() -> Dict[str, Any]: +async def sync_vtiger_contacts(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: """ SIMPEL TILGANG - Sync contacts from vTiger and link to customers Step 1: Fetch all contacts from vTiger @@ -446,7 +448,7 @@ async def sync_vtiger_contacts() -> Dict[str, Any]: @router.post("/sync/economic") -async def sync_from_economic() -> Dict[str, Any]: +async def sync_from_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: """ Sync customers from e-conomic (PRIMARY SOURCE) Creates/updates Hub customers with e-conomic data @@ -606,7 +608,7 @@ async def sync_from_economic() -> Dict[str, Any]: @router.post("/sync/cvr-to-economic") -async def sync_cvr_to_economic() -> Dict[str, Any]: +async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: """ Find customers in Hub with CVR but without e-conomic customer number Search e-conomic for matching CVR and update Hub @@ -668,7 +670,7 @@ async def sync_cvr_to_economic() -> Dict[str, Any]: @router.get("/sync/diagnostics") -async def sync_diagnostics() -> Dict[str, Any]: +async def sync_diagnostics(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: """ Diagnostics: Check contact linking coverage Shows why contacts aren't linking to customers diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py index 12f496c..0a644e0 100644 --- a/app/ticket/backend/router.py +++ b/app/ticket/backend/router.py @@ -11,7 +11,7 @@ import json import re import asyncio from typing import List, Optional -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import JSONResponse from app.ticket.backend.ticket_service import TicketService @@ -55,11 +55,13 @@ from app.ticket.backend.models import ( TicketDeadlineUpdateRequest ) from app.core.database import execute_query, execute_insert, execute_update, execute_query_single +from app.core.auth_dependencies import require_any_permission from datetime import date, datetime logger = logging.getLogger(__name__) router = APIRouter() +sync_admin_access = require_any_permission("users.manage", "system.admin") def _get_first_value(data: dict, keys: List[str]) -> Optional[str]: @@ -127,6 +129,31 @@ def _escape_simply_value(value: str) -> str: return value.replace("'", "''") +def _extract_count_value(rows: List[dict]) -> Optional[int]: + if not rows: + return None + + row = rows[0] or {} + if not isinstance(row, dict): + return None + + for key in ("total_count", "count", "count(*)", "COUNT(*)"): + value = row.get(key) + if value is not None: + try: + return int(value) + except (TypeError, ValueError): + continue + + for value in row.values(): + try: + return int(value) + except (TypeError, ValueError): + continue + + return None + + async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]: """Run vTiger query with exponential backoff on rate-limit responses.""" for attempt in range(retries + 1): @@ -1825,7 +1852,8 @@ async def import_simply_archived_tickets( limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"), include_messages: bool = Query(True, description="Include comments and emails"), ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"), - force: bool = Query(False, description="Update even if sync hash matches") + force: bool = Query(False, description="Update even if sync hash matches"), + current_user: dict = Depends(sync_admin_access) ): """ One-time import of archived tickets from Simply-CRM. @@ -2157,7 +2185,8 @@ async def import_vtiger_archived_tickets( limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"), include_messages: bool = Query(True, description="Include comments and emails"), ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"), - force: bool = Query(False, description="Update even if sync hash matches") + force: bool = Query(False, description="Update even if sync hash matches"), + current_user: dict = Depends(sync_admin_access) ): """ One-time import of archived tickets from vTiger (Cases module). @@ -2493,8 +2522,93 @@ async def import_vtiger_archived_tickets( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/archived/status", tags=["Archived Tickets"]) +async def get_archived_sync_status(current_user: dict = Depends(sync_admin_access)): + """ + Return archived sync parity status for Simply-CRM and vTiger. + """ + source_keys = ("simplycrm", "vtiger") + sources: dict[str, dict] = {} + + for source_key in source_keys: + local_ticket_row = execute_query_single( + """ + SELECT COUNT(*) AS total_tickets, + MAX(last_synced_at) AS last_synced_at + FROM tticket_archived_tickets + WHERE source_system = %s + """, + (source_key,) + ) or {} + + local_message_row = execute_query_single( + """ + SELECT COUNT(*) AS total_messages + FROM tticket_archived_messages m + INNER JOIN tticket_archived_tickets t ON t.id = m.archived_ticket_id + WHERE t.source_system = %s + """, + (source_key,) + ) or {} + + local_tickets = int(local_ticket_row.get("total_tickets") or 0) + local_messages = int(local_message_row.get("total_messages") or 0) + last_synced_value = local_ticket_row.get("last_synced_at") + if isinstance(last_synced_value, (datetime, date)): + last_synced_at_iso = last_synced_value.isoformat() + else: + last_synced_at_iso = None + + sources[source_key] = { + "remote_total_tickets": None, + "local_total_tickets": local_tickets, + "local_total_messages": local_messages, + "last_synced_at": last_synced_at_iso, + "diff": None, + "is_synced": False, + "error": None, + } + + try: + async with SimplyCRMService() as service: + module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets") + simply_rows = await service.query(f"SELECT count(*) AS total_count FROM {module_name};") + simply_remote_count = _extract_count_value(simply_rows) + sources["simplycrm"]["remote_total_tickets"] = simply_remote_count + if simply_remote_count is not None: + sources["simplycrm"]["diff"] = simply_remote_count - sources["simplycrm"]["local_total_tickets"] + sources["simplycrm"]["is_synced"] = sources["simplycrm"]["diff"] == 0 + elif service.last_query_error: + sources["simplycrm"]["error"] = service.last_query_error.get("message") or str(service.last_query_error) + except Exception as e: + logger.warning("⚠️ Simply-CRM archived status check failed: %s", e) + sources["simplycrm"]["error"] = str(e) + + try: + vtiger = get_vtiger_service() + vtiger_rows = await _vtiger_query_with_retry(vtiger, "SELECT count(*) AS total_count FROM Cases;") + vtiger_remote_count = _extract_count_value(vtiger_rows) + sources["vtiger"]["remote_total_tickets"] = vtiger_remote_count + if vtiger_remote_count is not None: + sources["vtiger"]["diff"] = vtiger_remote_count - sources["vtiger"]["local_total_tickets"] + sources["vtiger"]["is_synced"] = sources["vtiger"]["diff"] == 0 + elif vtiger.last_query_error: + sources["vtiger"]["error"] = vtiger.last_query_error.get("message") or str(vtiger.last_query_error) + except Exception as e: + logger.warning("⚠️ vTiger archived status check failed: %s", e) + sources["vtiger"]["error"] = str(e) + + overall_synced = all(sources[key].get("is_synced") is True for key in source_keys) + + return { + "checked_at": datetime.utcnow().isoformat(), + "overall_synced": overall_synced, + "sources": sources, + } + + @router.get("/archived/simply/modules", tags=["Archived Tickets"]) -async def list_simply_modules(): +async def list_simply_modules(current_user: dict = Depends(sync_admin_access)): """ List available Simply-CRM modules (debug helper). """ @@ -2510,7 +2624,8 @@ async def list_simply_modules(): @router.get("/archived/simply/ticket", tags=["Archived Tickets"]) async def fetch_simply_ticket( ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"), - external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234") + external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234"), + current_user: dict = Depends(sync_admin_access) ): """ Fetch a single HelpDesk ticket from Simply-CRM by ticket number or record id. @@ -2544,7 +2659,8 @@ async def fetch_simply_ticket( @router.get("/archived/simply/record", tags=["Archived Tickets"]) async def fetch_simply_record( record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"), - module: Optional[str] = Query(None, description="Optional module name for context") + module: Optional[str] = Query(None, description="Optional module name for context"), + current_user: dict = Depends(sync_admin_access) ): """ Fetch a single record from Simply-CRM by record id.