diff --git a/app.py b/app.py index a69e9a7e..ac390317 100644 --- a/app.py +++ b/app.py @@ -564,6 +564,7 @@ from modules.auth import ( # without having to thread them through every call site. from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag from modules.shared.timeUtils import setRequestTimezone +from modules.shared.requestFrontendUrl import resolveFrontendUrlFromRequest, setRequestFrontendUrl @app.middleware("http") async def _requestContextMiddleware(request: Request, call_next): @@ -571,6 +572,7 @@ async def _requestContextMiddleware(request: Request, call_next): lang = normalizePrimaryLanguageTag(acceptLang, "de") setLanguage(lang) setRequestTimezone(request.headers.get("X-User-Timezone", "")) + setRequestFrontendUrl(resolveFrontendUrlFromRequest(request)) return await call_next(request) app.add_middleware(CSRFMiddleware) diff --git a/env-dev.env b/env-dev.env index 467f70b4..d8a1e9d5 100644 --- a/env-dev.env +++ b/env-dev.env @@ -4,7 +4,12 @@ APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick APP_API_URL = http://localhost:8000 +<<<<<<< Updated upstream APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt +======= +APP_FRONTEND_URL = http://localhost:5176 +APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt +>>>>>>> Stashed changes APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 diff --git a/env-int.env b/env-int.env index 72099314..1ce618f9 100644 --- a/env-int.env +++ b/env-int.env @@ -4,6 +4,7 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://api-int.poweron.swiss +APP_FRONTEND_URL = https://nyla-int.poweron.swiss # Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// APP_COOKIE_SECURE = true APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt diff --git a/env-prod.env b/env-prod.env index d268450c..6f97cee8 100644 --- a/env-prod.env +++ b/env-prod.env @@ -7,6 +7,7 @@ APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://api.poweron.swiss +APP_FRONTEND_URL = https://nyla.poweron.swiss # PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud) DB_HOST=db.poweron.swiss diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index d8f2acc4..b5ab9e53 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging import time -from typing import Dict +from typing import Dict, Optional logger = logging.getLogger(__name__) @@ -27,6 +27,7 @@ def maybeEmailMandatePoolExhausted( currentBalance: float, requiredAmount: float, cooldownSec: float = _DEFAULT_COOLDOWN_SEC, + frontendUrl: Optional[str] = None, ) -> None: """ Send one notification per mandate per cooldown window when the pool is exhausted. @@ -38,6 +39,7 @@ def maybeEmailMandatePoolExhausted( currentBalance: Pool balance (CHF). requiredAmount: Minimum required (CHF). cooldownSec: Minimum seconds between emails for this mandate. + frontendUrl: Optional frontend base URL for /billing/admin link (defaults to request Origin). """ if not mandateId: return @@ -67,6 +69,9 @@ def maybeEmailMandatePoolExhausted( "Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, " "damit Benutzer wieder AI-Funktionen nutzen können.", ], + includeBillingAdminLink=True, + billingAdminButtonText="Guthaben aufladen", + frontendUrl=frontendUrl, ) if sent > 0: _poolExhaustedEmailLastSent[mandateId] = now diff --git a/modules/shared/notifyMandateAdmins.py b/modules/shared/notifyMandateAdmins.py index 6ac6fa53..081ebdda 100644 --- a/modules/shared/notifyMandateAdmins.py +++ b/modules/shared/notifyMandateAdmins.py @@ -128,6 +128,57 @@ def resolveMandateName(mandateId: str) -> str: # ============================================================================ +def buildBillingAdminUrl(frontendUrl: str) -> str: + """Absolute URL to /billing/admin for the given frontend base (no trailing slash on base).""" + base = (frontendUrl or "").strip().rstrip("/") + if not base: + return "" + return f"{base}/billing/admin" + + +def buildActionLinkHtmlBlock(url: str, buttonText: str) -> str: + """Render a CTA button plus the same URL as a plain link (auth-email style).""" + if not url or not buttonText: + return "" + escaped_url = html.escape(url) + escaped_label = html.escape(buttonText) + return f'''
++ {escaped_url} +
''' + + +def buildBillingAdminActionBlock( + frontendUrl: str, + buttonText: str = "Billing-Verwaltung öffnen", +) -> str: + """CTA block linking to /billing/admin (auth-email style button + plain link).""" + admin_url = buildBillingAdminUrl(frontendUrl) + if not admin_url: + return "" + return buildActionLinkHtmlBlock(admin_url, buttonText) + + +def _resolveBillingAdminFrontendUrl(frontendUrl: Optional[str]) -> str: + """Explicit frontendUrl param, else per-request value from middleware.""" + explicit = (frontendUrl or "").strip().rstrip("/") + if explicit: + return explicit + try: + from modules.shared.requestFrontendUrl import getRequestFrontendUrl + + return getRequestFrontendUrl() + except Exception: + return "" + + def _getOperatorInfo() -> Dict[str, str]: """Load operator company data from config.ini.""" try: @@ -147,11 +198,13 @@ def renderHtmlEmail( mandateName: str, footerNote: Optional[str] = None, rawHtmlBlock: Optional[str] = None, + actionHtmlBlock: Optional[str] = None, ) -> str: """Render a clean, professional HTML notification email. Args: rawHtmlBlock: Optional pre-formatted HTML inserted after bodyParagraphs (e.g. invoice table). + actionHtmlBlock: Optional CTA block (e.g. button + link to billing admin). """ hl = html.escape(headline) mn = html.escape(mandateName) @@ -165,6 +218,10 @@ def renderHtmlEmail( if rawHtmlBlock: rawBlock = f'