diff --git a/app/views/help.py b/app/views/help.py index 9f3cc8fc..ce1cdf7a 100644 --- a/app/views/help.py +++ b/app/views/help.py @@ -29,6 +29,19 @@ _DOCS_BUILD_DIR = pathlib.Path(__file__).parents[2] / "docs_build" @router.get("/help", include_in_schema=False) async def help_center(request: Request): """Render the end-user Help Center page.""" + # Extract user context for Zammad widget pre-fill ---------------------- + session_user = None + if hasattr(request, "session"): + session_user = request.session.get("user") + + user_name = "" + user_email = "" + user_id = "" + if session_user and isinstance(session_user, dict): + user_name = session_user.get("name") or session_user.get("display_name") or "" + user_email = session_user.get("email") or "" + user_id = session_user.get("preferred_username") or session_user.get("email") or session_user.get("id") or "" + return templates.TemplateResponse( "help.html", { @@ -39,5 +52,8 @@ async def help_center(request: Request): "zammad_chat_id": settings.zammad_chat_id, "zammad_form_enabled": settings.zammad_form_enabled, "support_email": settings.support_email, + "user_name": user_name, + "user_email": user_email, + "user_id": user_id, }, ) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index b33a22a8..a2f5f8ca 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -1142,6 +1142,15 @@ SUPPORT_EMAIL=support@example.com > **Note:** The live-chat widget requires at least one Zammad agent to be online. If no agent is available, the widget will not appear. Enable Zammad's debug mode (`debug: true`) for troubleshooting. +#### Automatic User Context (Auto-Fill) + +When a user is logged in, DocuElevate automatically passes their identity to the Zammad widgets: + +- **Ticket form:** The user's **name** and **email** are pre-filled in the form fields. A *DocuElevate User Context* block containing the user's name, email, and username is appended to the ticket body so the support agent can immediately identify the requester. +- **Live chat:** The user's **name** and **email** are passed to the Zammad chat widget constructor. Depending on your Zammad version, the agent may see this information in the chat session details. + +No additional configuration is required — the auto-fill uses the authenticated session data (OAuth, local login, or admin credentials). Anonymous visitors see the standard Zammad widgets without pre-filled data. + ## Duplicate Document Detection DocuElevate detects and flags documents that share the same content, even if they arrive as separate uploads. diff --git a/frontend/templates/help.html b/frontend/templates/help.html index 03bc64d5..bfec5db0 100644 --- a/frontend/templates/help.html +++ b/frontend/templates/help.html @@ -491,6 +491,50 @@ $(function() { messageThankYou: 'Thank you for your request (#%s)! We will get back to you shortly.', modal: true }); + + {% if user_name or user_email %} + {# ── Auto-fill Zammad form fields from the authenticated user context ── #} + $('#zammad-feedback-form').on('click', function() { + var maxAttempts = 30; /* ~3 s total wait for modal to appear */ + var retryDelayMs = 100; + var attempts = 0; + var prefillForm = function() { + var $modal = $('.zammad-form-modal, .js-zammad-form-modal'); + if ($modal.length === 0 && attempts < maxAttempts) { + attempts++; + setTimeout(prefillForm, retryDelayMs); + return; + } + var $name = $modal.find('input[name="name"]'); + var $email = $modal.find('input[name="email"]'); + {% if user_name %} + if ($name.length && !$name.val()) $name.val({{ user_name | tojson }}); + {% endif %} + {% if user_email %} + if ($email.length && !$email.val()) $email.val({{ user_email | tojson }}); + {% endif %} + }; + var initialDelayMs = 200; /* wait for modal DOM to start rendering */ + setTimeout(prefillForm, initialDelayMs); + }); + + {# ── Append user metadata to the ticket body before Zammad posts it ── #} + $.ajaxPrefilter(function(options) { + if (options.url && options.url.indexOf('/api/v1/form_submit') !== -1 && options.data) { + try { + var data = JSON.parse(options.data); + if (data.body && data.body.indexOf('\n--- DocuElevate User Context ---') === -1) { + var ctx = '\n\n--- DocuElevate User Context ---'; + {% if user_name %}ctx += '\nName: ' + {{ user_name | tojson }};{% endif %} + {% if user_email %}ctx += '\nEmail: ' + {{ user_email | tojson }};{% endif %} + {% if user_id %}ctx += '\nUsername: ' + {{ user_id | tojson }};{% endif %} + data.body += ctx; + options.data = JSON.stringify(data); + } + } catch(e) { /* ignore parse errors from non-JSON payloads */ } + } + }); + {% endif %} }); {% endif %} @@ -504,7 +548,9 @@ $(function() { fontSize: '12px', flat: true, chatId: {{ zammad_chat_id | int }}, - title: 'Chat with us!' + title: 'Chat with us!'{% if user_name or user_email %}, + name: {{ user_name | tojson }}, + email: {{ user_email | tojson }}{% endif %} }); })(); diff --git a/tests/test_views_help.py b/tests/test_views_help.py index 7f3509f8..21a297b6 100644 --- a/tests/test_views_help.py +++ b/tests/test_views_help.py @@ -142,6 +142,141 @@ class TestHelpViewUnit: assert b"ZammadForm" not in resp.content +@pytest.mark.unit +class TestHelpViewUserContext: + """Tests that user context is passed to Zammad widgets.""" + + @staticmethod + def _make_app_with_session(user_data: dict | None = None): + """Build a minimal FastAPI app with session middleware and optional user session.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + from starlette.middleware.sessions import SessionMiddleware + + from app.views.help import router + + app = FastAPI() + app.add_middleware(SessionMiddleware, secret_key="test-secret") + app.include_router(router) + tc = TestClient(app) + + if user_data is not None: + # Seed a session by setting the cookie through a helper endpoint + from fastapi import Request as _Req + from fastapi.responses import JSONResponse + + @app.get("/_test_set_session") + async def _set_session(request: _Req): + request.session["user"] = user_data + return JSONResponse({"ok": True}) + + tc.get("/_test_set_session") + + return tc + + def test_user_context_not_in_page_when_anonymous(self): + """Anonymous visitors should not see user context variables in the output.""" + tc = self._make_app_with_session(user_data=None) + resp = tc.get("/help") + assert resp.status_code == 200 + # No user context metadata should appear + assert b"DocuElevate User Context" not in resp.content + + def test_user_context_rendered_in_zammad_form_when_logged_in(self): + """With Zammad form enabled, logged-in user's name/email should appear in the script block.""" + from unittest.mock import patch + + tc = self._make_app_with_session( + user_data={ + "name": "Test User", + "email": "test@example.com", + "preferred_username": "testuser", + } + ) + with patch("app.views.help.settings") as mock_settings: + mock_settings.external_hostname = "localhost" + mock_settings.zammad_url = "https://zammad.example.com" + mock_settings.zammad_form_enabled = True + mock_settings.zammad_chat_enabled = False + mock_settings.zammad_chat_id = 1 + mock_settings.support_email = None + resp = tc.get("/help") + assert resp.status_code == 200 + assert b"Test User" in resp.content + assert b"test@example.com" in resp.content + assert b"testuser" in resp.content + assert b"DocuElevate User Context" in resp.content + + def test_user_context_rendered_in_zammad_chat_when_logged_in(self): + """With Zammad chat enabled, user's name/email should appear in the ZammadChat constructor.""" + from unittest.mock import patch + + tc = self._make_app_with_session( + user_data={ + "name": "Chat User", + "email": "chat@example.com", + "preferred_username": "chatuser", + } + ) + with patch("app.views.help.settings") as mock_settings: + mock_settings.external_hostname = "localhost" + mock_settings.zammad_url = "https://zammad.example.com" + mock_settings.zammad_form_enabled = False + mock_settings.zammad_chat_enabled = True + mock_settings.zammad_chat_id = 1 + mock_settings.support_email = None + resp = tc.get("/help") + assert resp.status_code == 200 + assert b"Chat User" in resp.content + assert b"chat@example.com" in resp.content + + def test_user_id_falls_back_to_email(self): + """When preferred_username is absent, user_id should resolve to email.""" + from unittest.mock import patch + + tc = self._make_app_with_session( + user_data={ + "email": "only-email@example.com", + } + ) + with patch("app.views.help.settings") as mock_settings: + mock_settings.external_hostname = "localhost" + mock_settings.zammad_url = "https://zammad.example.com" + mock_settings.zammad_form_enabled = True + mock_settings.zammad_chat_enabled = False + mock_settings.zammad_chat_id = 1 + mock_settings.support_email = None + resp = tc.get("/help") + assert resp.status_code == 200 + # user_id falls back to email; check it appears in the Username metadata line + content = resp.text + assert "only-email@example.com" in content + assert "DocuElevate User Context" in content + + def test_user_name_falls_back_to_display_name(self): + """When 'name' is absent, display_name should be used as the user_name fallback.""" + from unittest.mock import patch + + tc = self._make_app_with_session( + user_data={ + "display_name": "Display Only", + "email": "display@example.com", + "id": "user-123", + } + ) + with patch("app.views.help.settings") as mock_settings: + mock_settings.external_hostname = "localhost" + mock_settings.zammad_url = "https://zammad.example.com" + mock_settings.zammad_form_enabled = True + mock_settings.zammad_chat_enabled = False + mock_settings.zammad_chat_id = 1 + mock_settings.support_email = None + resp = tc.get("/help") + assert resp.status_code == 200 + # display_name used as fallback for user_name + assert b"Display Only" in resp.content + + @pytest.mark.integration class TestHelpNavigationLink: """Tests that the Help link appears in the navigation."""