Fix user admin actions on v2 + add archived sync monitor in settings

This commit is contained in:
Christian 2026-03-07 02:39:57 +01:00
parent 959c9b4401
commit e3094d7ed0
5 changed files with 453 additions and 24 deletions

View File

@ -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,

View File

@ -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))

View File

@ -764,6 +764,62 @@
</div>
</div>
<!-- Archived Ticket Sync + Monitor -->
<div class="card mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadArchivedSyncStatus()" id="btnCheckArchivedSync">
<i class="bi bi-arrow-repeat me-1"></i>Tjek nu
</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">Simply archived</h6>
<span class="badge bg-secondary" id="archivedSimplyBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedSimplyRemoteCount">-</span> | Lokal: <span id="archivedSimplyLocalCount">-</span> | Diff: <span id="archivedSimplyDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedSimplyMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedSimply()" id="btnSyncArchivedSimply">
<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">vTiger Cases archived</h6>
<span class="badge bg-secondary" id="archivedVtigerBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedVtigerRemoteCount">-</span> | Lokal: <span id="archivedVtigerLocalCount">-</span> | Diff: <span id="archivedVtigerDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived
</button>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
</div>
</div>
</div>
<!-- Sync Log -->
<div class="card">
<div class="card-header bg-white">
@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span>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 = '<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived';
}
}
async function syncArchivedVtiger() {
const btn = document.getElementById('btnSyncArchivedVtiger');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>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 = '<i class="bi bi-cloud-download me-2"></i>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

View File

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

View File

@ -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.