diff --git a/.env.demo b/.env.demo index a2f567b5..f33432a0 100644 --- a/.env.demo +++ b/.env.demo @@ -478,3 +478,15 @@ EMBEDDING_MODEL=text-embedding-3-small # Maximum tokens to send to the embedding model. Set below the model's # context window (e.g. 8000 for an 8192-token model). EMBEDDING_MAX_TOKENS=8000 + +# **Support / Help Center – Zammad Integration** +# Base URL of your Zammad instance (required for chat and ticket form). +# ZAMMAD_URL=https://zammad.example.com +# Show a live-chat widget on the Help Center page (requires an online Zammad agent). +# ZAMMAD_CHAT_ENABLED=false +# Zammad chat topic ID (see Zammad → Channels → Chat → Topics). +# ZAMMAD_CHAT_ID=1 +# Show a "Submit a Ticket" feedback form on the Help Center page. +# ZAMMAD_FORM_ENABLED=false +# Support e-mail address displayed on the Help Center page. +# SUPPORT_EMAIL=support@example.com diff --git a/app/config.py b/app/config.py index 8c9b4a9c..68297ed9 100644 --- a/app/config.py +++ b/app/config.py @@ -914,6 +914,33 @@ class Settings(BaseSettings): description="Allowed request headers for CORS. Use ['*'] to allow all headers.", ) + # --------------------------------------------------------------------------- + # Support / Help Center – Zammad integration + # --------------------------------------------------------------------------- + zammad_url: Optional[str] = Field( + default=None, + description=( + "Base URL of your Zammad instance (e.g. https://zammad.example.com). " + "Required for the chat widget and feedback form on the Help Center page." + ), + ) + zammad_chat_enabled: bool = Field( + default=False, + description="Show the Zammad live-chat widget on the Help Center page.", + ) + zammad_chat_id: int = Field( + default=1, + description="Zammad chat topic ID to use for the live-chat widget.", + ) + zammad_form_enabled: bool = Field( + default=False, + description="Show the Zammad feedback / ticket form on the Help Center page.", + ) + support_email: Optional[str] = Field( + default=None, + description="Support e-mail address displayed on the Help Center page.", + ) + @model_validator(mode="before") @classmethod def strip_outer_quotes(cls, data: Any) -> Any: diff --git a/app/main.py b/app/main.py index c03cdd1f..71507011 100644 --- a/app/main.py +++ b/app/main.py @@ -137,7 +137,12 @@ async def lifespan(app: FastAPI): notify_shutdown() -app = FastAPI(title="DocuElevate", lifespan=lifespan) +app = FastAPI( + title="DocuElevate", + lifespan=lifespan, + docs_url="/admin/api-docs", + redoc_url="/admin/api-redoc", +) # Initialize rate limiter and attach to app state limiter = create_limiter(redis_url=settings.redis_url, enabled=settings.rate_limiting_enabled) @@ -202,14 +207,16 @@ if os.path.exists(static_dir): else: print(f"WARNING: Static directory not found at {static_dir}. Static files will not be served.") -# Mount the built MkDocs documentation site at /help/ +# Mount the built MkDocs developer documentation at /developer-docs/ +# These docs target administrators and developers, not end-users. +# The user-facing Help Center is served by the /help view instead. # The docs are pre-built into docs_build/ during the Docker image build. # When running locally, run `mkdocs build` from the repo root first. docs_build_dir = pathlib.Path(__file__).parents[1] / "docs_build" if os.path.exists(docs_build_dir): - app.mount("/help", StaticFiles(directory=str(docs_build_dir), html=True), name="help_docs") + app.mount("/developer-docs", StaticFiles(directory=str(docs_build_dir), html=True), name="developer_docs") else: - print(f"INFO: Help docs not found at {docs_build_dir}. Run 'mkdocs build' to generate them.") + print(f"INFO: Developer docs not found at {docs_build_dir}. Run 'mkdocs build' to generate them.") # Custom exception handlers that return JSON for API routes and HTML for frontend routes diff --git a/app/views/help.py b/app/views/help.py index 1df1c205..eff08b1b 100644 --- a/app/views/help.py +++ b/app/views/help.py @@ -1,27 +1,42 @@ """ -Help documentation view routes. +Help Center view routes. -Serves the built MkDocs documentation site at /help. -The static site is built during the Docker image build and placed at docs_build/. +Renders a user-facing, SEO-optimised Help Center page at ``/help``. +The page is designed for SaaS end-users and includes guidance on +features, integrations, workflows, and optional Zammad support widgets +(live-chat and feedback form). + +The developer-oriented MkDocs documentation is served separately at +``/docs`` and is intentionally **not** cross-linked from this page. """ import logging import pathlib from fastapi import Request -from fastapi.responses import RedirectResponse -from app.views.base import APIRouter +from app.config import settings +from app.views.base import APIRouter, templates logger = logging.getLogger(__name__) router = APIRouter() -# Path to the built MkDocs documentation +# Path to the built MkDocs documentation (kept for reference / backwards compat) _DOCS_BUILD_DIR = pathlib.Path(__file__).parents[2] / "docs_build" @router.get("/help", include_in_schema=False) -async def help_redirect(request: Request) -> RedirectResponse: - """Redirect /help to /help/ so the MkDocs index is served correctly.""" - return RedirectResponse(url="/help/", status_code=301) +async def help_center(request: Request): + """Render the end-user Help Center page.""" + return templates.TemplateResponse( + "help.html", + { + "request": request, + "zammad_url": settings.zammad_url, + "zammad_chat_enabled": settings.zammad_chat_enabled, + "zammad_chat_id": settings.zammad_chat_id, + "zammad_form_enabled": settings.zammad_form_enabled, + "support_email": settings.support_email, + }, + ) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index ef52229a..b33a22a8 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -1118,6 +1118,30 @@ Administrators can set the **site-wide default** colour scheme that is applied w UI_DEFAULT_COLOR_SCHEME=dark ``` +### Support / Help Center – Zammad Integration + +The Help Center page (`/help`) can optionally integrate with a [Zammad](https://zammad.com/) instance to offer live chat and a ticket-creation form directly within DocuElevate. + +| **Variable** | **Description** | **Default** | +|-------------------------|-------------------------------------------------------------------------------------------------------|-------------| +| `ZAMMAD_URL` | Base URL of your Zammad instance (e.g. `https://zammad.example.com`). Required for chat and form. | *(unset)* | +| `ZAMMAD_CHAT_ENABLED` | Show a Zammad live-chat widget on the Help Center page. | `false` | +| `ZAMMAD_CHAT_ID` | Zammad chat topic ID (see *Channels → Chat → Topics* in Zammad admin). | `1` | +| `ZAMMAD_FORM_ENABLED` | Show a "Submit a Ticket" feedback form on the Help Center page. | `false` | +| `SUPPORT_EMAIL` | Support e-mail address displayed on the Help Center page. | *(unset)* | + +**Example:** + +```dotenv +ZAMMAD_URL=https://zammad.example.com +ZAMMAD_CHAT_ENABLED=true +ZAMMAD_CHAT_ID=1 +ZAMMAD_FORM_ENABLED=true +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. + ## 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/base.html b/frontend/templates/base.html index 7b0d9d14..ffb7f7c6 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -172,6 +172,13 @@ {% if request and request.url.path == '/status' %}aria-current="page"{% endif %}> Status +
+ + API Docs + + + Developer Docs + @@ -179,11 +186,11 @@ {% endif %}{# end multi_user_enabled / is_logged_in check #} - + aria-label="Help Center" + title="Help Center" + {% if request and request.url.path == '/help' %}aria-current="page"{% endif %}> Help @@ -314,16 +321,22 @@ {% if request and request.url.path == '/status' %}aria-current="page"{% endif %}> Status + + API Docs + + + Developer Docs + {% endif %}{# end multi_user_enabled / is_logged_in check #} - + aria-label="Help Center" + {% if request and request.url.path == '/help' %}aria-current="page"{% endif %}> Help diff --git a/frontend/templates/help.html b/frontend/templates/help.html new file mode 100644 index 00000000..b96ea5f6 --- /dev/null +++ b/frontend/templates/help.html @@ -0,0 +1,510 @@ +{% extends "base.html" %} +{% block title %}Help Center – DocuElevate{% endblock %} + +{% block head_extra %} + + + + + + + + + + + + + + +{% endblock %} + +{% block content %} +
+ + +
+

+ Help Center +

+

+ Everything you need to get the most out of DocuElevate. + Browse topics below or search for what you need. +

+
+ + +
+

+ Quick Start +

+
+ + +
+
+ +

Upload Documents

+
+

+ Drag & drop files onto the Upload page or click Choose File. + DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically. +

+
+ + +
+
+ +

Connect Storage

+
+

+ Head to Settings → Integrations and link your cloud accounts. + Processed documents are automatically routed to every destination you configure. +

+
+ + +
+
+ +

Automate Workflows

+
+

+ Create Pipelines to define multi-step processing and routing rules. + Combine OCR, AI extraction, format conversion, and delivery in a single flow. +

+
+ +
+
+ + +
+

+ Sources – Getting Documents In +

+
+ +
+

Web Upload

+

+ The fastest way to get started. Open the Upload page, drop one or more files, and DocuElevate takes care of the rest. + Supported formats include PDF, JPEG, PNG, TIFF, DOCX, XLSX, and more. +

+
+ +
+

Email Ingestion (IMAP)

+

+ Forward documents to a dedicated mailbox. Under Email Ingestion, add one or more IMAP accounts. + DocuElevate polls for new messages and processes attachments automatically. +

+
+ +
+

REST API

+

+ Integrate programmatically by POST-ing files to /api/upload. + Ideal for scripts, watched folders, scanners, or third-party tools like Zapier and n8n. +

+
+ +
+

Scanner & Mobile

+

+ Point network scanners (HP, Fujitsu ScanSnap, Brother) at DocuElevate's upload endpoint or + use any mobile scanning app that supports custom HTTP destinations. +

+
+ +
+
+ + +
+

+ Destinations – Where Documents Go +

+
+ +
+ +
+

Dropbox

+

OAuth-linked. Files land in your chosen folder.

+
+
+ +
+ +
+

Google Drive

+

Service account or OAuth. Supports shared drives.

+
+
+ +
+ +
+

OneDrive

+

Microsoft Graph API integration.

+
+
+ +
+ +
+

Amazon S3

+

Any S3-compatible bucket (AWS, MinIO, Wasabi).

+
+
+ +
+ +
+

Nextcloud / WebDAV

+

Self-hosted cloud storage via WebDAV.

+
+
+ +
+ +
+

Paperless-ngx

+

Push documents straight into Paperless for archival.

+
+
+ +
+ +
+

SFTP / FTP

+

Secure file transfer to any server.

+
+
+ +
+ +
+

Email Forwarding

+

Processed files sent as SMTP attachments.

+
+
+ +
+ +
+

Webhook

+

POST metadata to any external endpoint.

+
+
+ +
+
+ + +
+

+ Workflows & Pipelines +

+
+
+
+

What is a Pipeline?

+

+ A Pipeline is a series of processing steps that run automatically whenever a document is ingested. + Each step can transform, enrich, or route the document. +

+

Typical Steps

+
    +
  1. Convert to PDF (Gotenberg)
  2. +
  3. OCR – extract text (Azure, Tesseract, Mistral …)
  4. +
  5. AI metadata extraction (OpenAI, Claude, Gemini …)
  6. +
  7. Deliver to one or more destinations
  8. +
+
+
+

Creating a Pipeline

+
    +
  1. Go to Pipelines in the main menu.
  2. +
  3. Click New Pipeline and give it a name.
  4. +
  5. Add the processing steps you need.
  6. +
  7. Choose one or more delivery destinations.
  8. +
  9. Save – new documents will be processed through this pipeline automatically.
  10. +
+
+
+
+
+ + +
+

+ Ingesting Data with Standard Tools +

+
+ +
+

cURL

+

Upload a file from any terminal:

+
curl -X POST https://your-instance/api/upload \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -F "file=@/path/to/document.pdf"
+
+ +
+

Watched Folders

+

+ Use inotifywait (Linux), fswatch (macOS), or any file-watcher script to + POST new files as soon as they appear in a local directory. +

+
+ +
+

Network Scanners

+

+ Configure your HP, Fujitsu ScanSnap, or Brother scanner to use DocuElevate's upload URL as a + Scan-to-Network destination. Scans are processed the moment they arrive. +

+
+ +
+

Mobile Scanning Apps

+

+ Apps like Adobe Scan, Microsoft Lens, or Genius Scan + can export to a custom HTTP endpoint. Point them at /api/upload. +

+
+ +
+

Zapier / n8n / Make

+

+ Connect DocuElevate to thousands of other apps via integration platforms. + Use the HTTP/Webhook module to POST files to the upload API. +

+
+ +
+
+ + +
+

+ Frequently Asked Questions +

+
+ +
+ +
+ Navigate to the Upload page, drag-and-drop your files or click Choose File. + DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically. +
+
+ +
+ +
+ PDF, JPEG, PNG, TIFF, BMP, GIF, DOCX, XLSX, PPTX, ODT, ODS, TXT, RTF, and HTML. + Non-PDF files are automatically converted to PDF before processing. +
+
+ +
+ +
+ Yes. Go to Email Ingestion, add an IMAP account, and DocuElevate will poll for new + messages and process attachments automatically. +
+
+ +
+ +
+ Pipelines let you chain processing steps – OCR, AI extraction, format conversion – and route the + result to one or more destinations. Create and manage them from the Pipelines page. +
+
+ +
+ +
+ DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents + longer than necessary. See the Privacy Notice for full details. +
+
+ +
+
+ + +
+

+ Contact Support +

+ +
+ + +
+

+ Can't find what you're looking for? Our support team is here to help. +

+ + {% if support_email %} + + {% endif %} + + {% if zammad_chat_enabled and zammad_url %} +
+ + Live chat available – look for the chat bubble in the bottom-right corner. +
+ {% endif %} +
+ + + {% if zammad_form_enabled and zammad_url %} +
+

+ Open a Support Ticket +

+

+ Describe your issue below and we'll get back to you as soon as possible. +

+ +
+ {% elif not zammad_form_enabled and not support_email and not zammad_chat_enabled %} +
+ +

+ Contact your administrator for support information. +

+
+ {% endif %} + +
+
+ +
+{% endblock %} + +{% block scripts %} +{% if zammad_url and zammad_form_enabled %} + + + + +{% endif %} + +{% if zammad_url and zammad_chat_enabled %} + + + +{% endif %} +{% endblock %} diff --git a/tests/test_views_help.py b/tests/test_views_help.py index df9082fb..7f3509f8 100644 --- a/tests/test_views_help.py +++ b/tests/test_views_help.py @@ -1,33 +1,50 @@ -"""Tests for app/views/help.py module.""" +"""Tests for app/views/help.py module – user-facing Help Center.""" import pytest @pytest.mark.integration class TestHelpViews: - """Tests for the help documentation view routes.""" + """Tests for the Help Center view routes.""" - def test_help_redirect(self, client): - """GET /help should redirect permanently to /help/.""" - response = client.get("/help", follow_redirects=False) - assert response.status_code == 301 - assert response.headers["location"] in ("/help/", "http://testserver/help/") + def test_help_returns_200(self, client): + """GET /help should return the Help Center page.""" + response = client.get("/help") + assert response.status_code == 200 - def test_help_redirect_follows(self, client): - """Following /help redirect leads to /help/ (docs served or 404 if not built).""" - # In test environments docs_build/ is not present, so /help/ may 404. - # We only verify the initial redirect works; the final landing page depends - # on whether the docs have been built (they are built only in Docker images). - response = client.get("/help", follow_redirects=False) - assert response.status_code == 301 - # Redirect target must be /help/ - location = response.headers.get("location", "") - assert location.endswith("/help/") + def test_help_contains_help_center_title(self, client): + """The page should contain the Help Center heading.""" + response = client.get("/help") + assert b"Help Center" in response.content + + def test_help_contains_quick_start_section(self, client): + """The page should contain the Quick Start section.""" + response = client.get("/help") + assert b"Quick Start" in response.content + + def test_help_contains_sources_section(self, client): + """The page should contain the Sources section.""" + response = client.get("/help") + assert b"Sources" in response.content + + def test_help_contains_destinations_section(self, client): + """The page should contain the Destinations section.""" + response = client.get("/help") + assert b"Destinations" in response.content + + def test_help_contains_faq_section(self, client): + """The page should contain the FAQ section.""" + response = client.get("/help") + assert b"Frequently Asked Questions" in response.content + + def test_help_contains_support_section(self, client): + """The page should contain the Contact Support section.""" + response = client.get("/help") + assert b"Contact Support" in response.content def test_help_route_is_registered(self, client): """Verify the /help route exists in the app router.""" - # A GET to /help must not return 405 Method Not Allowed - response = client.get("/help", follow_redirects=False) + response = client.get("/help") assert response.status_code != 405 @@ -35,31 +52,52 @@ class TestHelpViews: class TestHelpViewUnit: """Unit tests for the help view module.""" - def test_help_redirect_returns_301(self): - """Verify the redirect is HTTP 301 (permanent).""" + def test_help_returns_200(self): + """Verify the help center returns HTTP 200.""" 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) with TestClient(app) as tc: - resp = tc.get("/help", follow_redirects=False) - assert resp.status_code == 301 + resp = tc.get("/help") + assert resp.status_code == 200 - def test_help_redirect_target(self): - """Verify the redirect points to /help/.""" + def test_help_page_has_seo_meta(self): + """Verify SEO meta tags are present in the response.""" 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) with TestClient(app) as tc: - resp = tc.get("/help", follow_redirects=False) - assert resp.headers["location"].rstrip("/").endswith("help") or resp.headers["location"].endswith("/help/") + resp = tc.get("/help") + assert b'name="description"' in resp.content + assert b'name="robots"' in resp.content + + def test_help_page_has_structured_data(self): + """Verify JSON-LD structured data is present.""" + 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) + with TestClient(app) as tc: + resp = tc.get("/help") + assert b"application/ld+json" in resp.content + assert b"FAQPage" in resp.content def test_docs_build_path_constant(self): """Verify _DOCS_BUILD_DIR is resolved relative to the app package.""" @@ -71,6 +109,38 @@ class TestHelpViewUnit: # Should point to /docs_build assert _DOCS_BUILD_DIR.name == "docs_build" + def test_zammad_chat_hidden_when_disabled(self): + """Chat widget markup should not appear when Zammad chat is disabled.""" + 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) + with TestClient(app) as tc: + resp = tc.get("/help") + # Default settings: zammad_chat_enabled=False → no chat script + assert b"ZammadChat" not in resp.content + + def test_zammad_form_hidden_when_disabled(self): + """Ticket form markup should not appear when Zammad form is disabled.""" + 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) + with TestClient(app) as tc: + resp = tc.get("/help") + # Default settings: zammad_form_enabled=False → no form script + assert b"ZammadForm" not in resp.content + @pytest.mark.integration class TestHelpNavigationLink: @@ -81,12 +151,29 @@ class TestHelpNavigationLink: response = client.get("/about") assert response.status_code == 200 # The Help link should be present somewhere in the rendered page - assert b"/help/" in response.content + assert b"/help" in response.content def test_help_link_has_accessible_text(self, client): """The Help link should have visible text for accessibility.""" response = client.get("/about") assert response.status_code == 200 content = response.text - # Should include the word "Help" associated with /help/ + # Should include the word "Help" associated with /help assert "Help" in content + + +@pytest.mark.integration +class TestAdminDocsLinks: + """Tests that admin doc links are in the admin menu markup.""" + + def test_api_docs_link_in_admin_menu(self, client): + """The admin menu HTML should contain the API Docs link.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"/admin/api-docs" in response.content + + def test_developer_docs_link_in_admin_menu(self, client): + """The admin menu HTML should contain the Developer Docs link.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"/developer-docs/" in response.content