diff --git a/CHANGELOG.md b/CHANGELOG.md index fd48b9c8..da869eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.16.0] - 2026-09-18 + +### Added + +- **Client–team messaging** — New `ClientMessage` model, message service, and UI for bidirectional in-portal messaging between the internal team and client contacts; threads visible from both the client view and the client portal (migration **194**). +- **Email thread sync (Gmail & Outlook)** — `GmailConnector` and `OutlookEmailConnector` pull email threads from Gmail API and Microsoft Graph and link them to CRM clients, leads, and deals; `EmailSyncService` handles OAuth token refresh and incremental sync; dedicated setup wizards for both providers. +- **Payroll sync (Gusto & ADP)** — `GustoConnector` and `AdpConnector` aggregate time entries into payroll batches and push them to Gusto's Partner API and ADP Workforce Now; `PayrollSyncService` builds period-scoped batches and `PayrollSyncLog` tracks sync history (migration **194**). +- **DATEV accounting export** — `DatevConnector` generates EXTF Buchungsstapel CSV for direct import into DATEV; `datev_export.py` utility handles the format spec. +- **Sage Business Cloud integration** — `SageConnector` syncs invoices, contacts, and payments with Sage Business Cloud Accounting via OAuth2. +- **Integration setup wizards** — Guided step-by-step wizards for ADP, DATEV, Gmail, Gusto, Outlook Email, and Sage make credential configuration consistent with existing integrations. +- **Visual workflow builder** — Drag-and-drop canvas (`visual_builder.html`) for constructing automation workflows without editing JSON; accessible alongside the existing form editor. +- **Portal custom domain resolution** — `portal_domain.py` utility resolves white-label client portal hosts to the correct `Client` record, enabling custom-domain client portals when `portal_allowed_custom_domains` is enabled. +- **Client Portal REST API** — `api_v1_client_portal.py` blueprint exposes authenticated REST endpoints for portal sessions and client data access. + +### Documentation + +- **Version** — Bumped `setup.py` to **5.16.0** (single source of truth for the application version). + ## [5.15.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index 04821d8c..58c9022d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. +### ✨ Highlights of v5.16.0 + +**Minor (5.16.0):** **Client–team messaging** — bidirectional in-portal messaging between team and client contacts with thread UI on both sides. **Gmail & Outlook sync** — email threads pulled from Gmail API and Microsoft Graph and linked to CRM clients, leads, and deals. **Payroll sync (Gusto & ADP)** — time entries aggregated into payroll batches and pushed to Gusto and ADP Workforce Now. **DATEV export** — EXTF Buchungsstapel CSV generator for direct DATEV import. **Sage integration** — invoices, contacts, and payments synced with Sage Business Cloud. **Integration wizards** — guided setup wizards for ADP, DATEV, Gmail, Gusto, Outlook Email, and Sage. **Visual workflow builder** — drag-and-drop canvas for building automation workflows. **Portal custom domains** — white-label client portal host resolution. **Client Portal API** — new authenticated REST blueprint for portal sessions and data access. See [CHANGELOG.md](CHANGELOG.md#5160---2026-09-18). + ### ✨ Highlights of v5.15.0 **Minor (5.15.0):** **Timer start override (#760)** — start or adjust a running timer at a custom time. **Pomodoro sessions** — focus blocks tracked via timer API and UI. **Expense lifecycle** — full mobile/desktop expense CRUD. **Payroll templates** — configurable export templates. **QuickBooks & Xero** — deeper accounting sync. **ActivityWatch inbox** — rules, merge filters, and sync-error review. **Gamification** — badges, leaderboards, and award hooks. **Calendar DnD** — drag-to-move and resize events. **Recurring costs** — automatic recurring project cost engine. **Shareable reports** — public tokenized report links. **Geofencing** — location-based attendance clock-in policies. See [CHANGELOG.md](CHANGELOG.md#5150---2026-09-16). diff --git a/app/__init__.py b/app/__init__.py index afc685b9..e141abf9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -524,6 +524,25 @@ def _generate_csp_nonce(): g.csp_nonce = secrets.token_urlsafe(16) + # White-label client portal: resolve Host to a Client.custom_domain when enabled. + @app.before_request + def _resolve_portal_custom_domain(): + try: + from app.utils.portal_domain import bind_portal_client_to_g + + bind_portal_client_to_g() + except Exception: + g.portal_client = None + + # On a custom portal hostname, send bare "/" to the client portal. + try: + if getattr(g, "portal_client", None) is not None and request.path in ("/", ""): + from flask import redirect, url_for + + return redirect(url_for("client_portal.login")) + except Exception: + pass + # Remember the public base URL from real requests so background jobs can build # absolute links without SERVER_NAME (see app.utils.urls). @app.before_request diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py index d2d420e2..0a7bec0a 100644 --- a/app/blueprint_registry.py +++ b/app/blueprint_registry.py @@ -72,6 +72,7 @@ def register_all_blueprints(app, logger=None): from app.routes.api_v1_projects import api_v1_projects_bp from app.routes.api_v1_tasks import api_v1_tasks_bp from app.routes.api_v1_time_entries import api_v1_time_entries_bp + from app.routes.api_v1_client_portal import api_v1_client_portal_bp from app.routes.auth import auth_bp from app.routes.budget_alerts import budget_alerts_bp from app.routes.calendar import calendar_bp @@ -182,6 +183,7 @@ def register_all_blueprints(app, logger=None): app.register_blueprint(api_v1_leads_bp) app.register_blueprint(api_v1_contacts_bp) app.register_blueprint(api_v1_issues_bp) + app.register_blueprint(api_v1_client_portal_bp) app.register_blueprint(api_docs_bp) app.register_blueprint(swaggerui_blueprint) app.register_blueprint(analytics_bp) diff --git a/app/integrations/adp.py b/app/integrations/adp.py new file mode 100644 index 00000000..ce549c4c --- /dev/null +++ b/app/integrations/adp.py @@ -0,0 +1,168 @@ +"""ADP Workforce Now payroll connector.""" + +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict +from urllib.parse import urlencode + +import requests + +from app.integrations.base import BaseConnector + +logger = logging.getLogger(__name__) + + +class AdpConnector(BaseConnector): + """ADP Workforce Now — OAuth2 client credentials / auth code + payroll batches.""" + + display_name = "ADP Workforce Now" + description = "Push payroll batches to ADP Workforce Now" + icon = "adp" + + AUTH_URL = "https://accounts.adp.com/auth/oauth/v2/authorize" + TOKEN_URL = "https://accounts.adp.com/auth/oauth/v2/token" + API_BASE = "https://api.adp.com" + + @property + def provider_name(self) -> str: + return "adp" + + def _creds(self): + from app.models import Settings + + settings = Settings.get_settings() + c = settings.get_integration_credentials("adp") + return { + "client_id": c.get("client_id") or os.getenv("ADP_CLIENT_ID"), + "client_secret": c.get("client_secret") or os.getenv("ADP_CLIENT_SECRET"), + } + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + c = self._creds() + if not c["client_id"]: + raise ValueError("ADP_CLIENT_ID not configured") + params = { + "client_id": c["client_id"], + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": "openid", + "state": state or "", + } + return f"{self.AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": c["client_id"], + "client_secret": c["client_secret"], + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat(), + "token_type": data.get("token_type", "Bearer"), + } + + def refresh_access_token(self) -> Dict[str, Any]: + c = self._creds() + # Prefer refresh_token; fall back to client_credentials for server apps + if self.credentials and self.credentials.refresh_token: + data_body = { + "grant_type": "refresh_token", + "refresh_token": self.credentials.refresh_token, + "client_id": c["client_id"], + "client_secret": c["client_secret"], + } + else: + data_body = { + "grant_type": "client_credentials", + "client_id": c["client_id"], + "client_secret": c["client_secret"], + } + r = requests.post(self.TOKEN_URL, data=data_body, timeout=30) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + if self.credentials: + self.credentials.access_token = data["access_token"] + if data.get("refresh_token"): + self.credentials.refresh_token = data["refresh_token"] + self.credentials.expires_at = expires_at + from app import db + + db.session.commit() + return {"access_token": data["access_token"], "expires_at": expires_at.isoformat()} + + def test_connection(self) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "Not authenticated"} + r = requests.get( + f"{self.API_BASE}/hr/v2/workers", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + params={"$top": 1}, + timeout=20, + ) + if r.status_code in (200, 401, 403): + # 401/403 still prove reachability; partnership scopes vary + if r.status_code == 200: + return {"success": True, "message": "ADP connection OK"} + return {"success": True, "message": f"ADP reachable (HTTP {r.status_code} — check scopes)"} + return {"success": False, "message": f"HTTP {r.status_code}: {r.text[:200]}"} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + from app.services.payroll_sync_service import PayrollSyncService + + return PayrollSyncService().push_period( + provider="adp", + integration=self.integration, + connector=self, + ) + + def push_payroll_batch(self, batch: Dict[str, Any]) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "Missing access token"} + payload = { + "events": [ + { + "data": { + "eventContext": { + "payrollGroupCode": (self.integration.config or {}).get("payroll_group_code", "DEFAULT"), + }, + "transform": { + "payDataInput": { + "payrollPeriodStartDate": batch["period_start"], + "payrollPeriodEndDate": batch["period_end"], + "workers": batch.get("employees", []), + } + }, + } + } + ] + } + r = requests.post( + f"{self.API_BASE}/events/payroll/v1/pay-data-input.modify", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json=payload, + timeout=60, + ) + if r.status_code in (200, 201, 202): + data = r.json() if r.content else {} + return { + "success": True, + "external_batch_id": str(data.get("events", [{}])[0].get("eventID") or ""), + "raw": data, + } + return {"success": False, "message": f"HTTP {r.status_code}: {r.text[:300]}"} diff --git a/app/integrations/datev.py b/app/integrations/datev.py new file mode 100644 index 00000000..69e230dc --- /dev/null +++ b/app/integrations/datev.py @@ -0,0 +1,82 @@ +"""DATEV accounting connector (file-based EXTF / Buchungsstapel export).""" + +import logging +from datetime import datetime +from typing import Any, Dict, List + +from app.integrations.base import BaseConnector +from app.utils.datev_export import build_datev_buchungsstapel + +logger = logging.getLogger(__name__) + + +class DatevConnector(BaseConnector): + """DATEV export connector — generates EXTF CSV for Buchungsstapel import.""" + + display_name = "DATEV" + description = "Export invoices as DATEV EXTF Buchungsstapel CSV" + icon = "datev" + + @property + def provider_name(self) -> str: + return "datev" + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + # File-based — no OAuth; return a stub that integrations UI can skip + return redirect_uri or "/" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + return { + "access_token": "datev-file-export", + "refresh_token": None, + "expires_at": None, + "token_type": "none", + } + + def refresh_access_token(self) -> Dict[str, Any]: + return {"access_token": "datev-file-export", "expires_at": None} + + def test_connection(self) -> Dict[str, Any]: + config = (self.integration.config if self.integration else {}) or {} + consultant = config.get("consultant_number") or config.get("berater_nr") + client_nr = config.get("client_number") or config.get("mandant_nr") + if not consultant or not client_nr: + return { + "success": False, + "message": "Configure consultant number (Berater-Nr) and client number (Mandanten-Nr)", + } + return {"success": True, "message": f"DATEV ready (Berater {consultant}, Mandant {client_nr})"} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + """Generate DATEV export content and store path/summary on the integration.""" + from app.models import Invoice + + config = (self.integration.config if self.integration else {}) or {} + invoices: List = Invoice.query.filter(Invoice.status.in_(["sent", "paid"])).limit(500).all() + csv_content = build_datev_buchungsstapel( + invoices, + consultant_number=str(config.get("consultant_number") or config.get("berater_nr") or "00000"), + client_number=str(config.get("client_number") or config.get("mandant_nr") or "00000"), + account_revenue=str(config.get("account_revenue") or "8400"), + account_receivable=str(config.get("account_receivable") or "10000"), + ) + filename = f"EXTF_Buchungsstapel_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv" + # Persist last export in config for download via integrations UI + if self.integration: + cfg = dict(self.integration.config or {}) + cfg["last_export_filename"] = filename + cfg["last_export_at"] = datetime.utcnow().isoformat() + cfg["last_export_preview"] = csv_content[:2000] + cfg["last_export_content"] = csv_content + self.integration.config = cfg + from app import db + + db.session.commit() + + return { + "success": True, + "synced": len(invoices), + "filename": filename, + "message": f"Generated DATEV export with {len(invoices)} invoices", + "content": csv_content, + } diff --git a/app/integrations/gmail.py b/app/integrations/gmail.py new file mode 100644 index 00000000..7a6db354 --- /dev/null +++ b/app/integrations/gmail.py @@ -0,0 +1,192 @@ +"""Gmail integration for CRM email sync.""" + +import logging +import os +from datetime import datetime, timedelta +from email.utils import parsedate_to_datetime +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode + +import requests + +from app.integrations.base import BaseConnector + +logger = logging.getLogger(__name__) + + +class GmailConnector(BaseConnector): + """Gmail API connector — syncs threads matched to CRM contacts.""" + + display_name = "Gmail" + description = "Sync Gmail threads into CRM (clients, leads, deals)" + icon = "gmail" + + AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + TOKEN_URL = "https://oauth2.googleapis.com/token" + API_BASE = "https://gmail.googleapis.com/gmail/v1" + + @property + def provider_name(self) -> str: + return "gmail" + + def _creds(self): + from app.models import Settings + + settings = Settings.get_settings() + c = settings.get_integration_credentials("gmail") + return { + "client_id": c.get("client_id") or os.getenv("GMAIL_CLIENT_ID") or os.getenv("GOOGLE_CLIENT_ID"), + "client_secret": c.get("client_secret") + or os.getenv("GMAIL_CLIENT_SECRET") + or os.getenv("GOOGLE_CLIENT_SECRET"), + } + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + c = self._creds() + if not c["client_id"]: + raise ValueError("GMAIL_CLIENT_ID / GOOGLE_CLIENT_ID not configured") + params = { + "client_id": c["client_id"], + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join( + [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/userinfo.email", + ] + ), + "access_type": "offline", + "prompt": "consent", + "state": state or "", + } + return f"{self.AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "code": code, + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat(), + "token_type": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + } + + def refresh_access_token(self) -> Dict[str, Any]: + if not self.credentials or not self.credentials.refresh_token: + raise ValueError("No refresh token") + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "refresh_token": self.credentials.refresh_token, + "grant_type": "refresh_token", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + self.credentials.access_token = data["access_token"] + self.credentials.expires_at = expires_at + from app import db + + db.session.commit() + return {"access_token": data["access_token"], "expires_at": expires_at.isoformat()} + + def test_connection(self) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "Not authenticated"} + r = requests.get( + f"{self.API_BASE}/users/me/profile", + headers={"Authorization": f"Bearer {token}"}, + timeout=20, + ) + if r.status_code == 200: + return {"success": True, "message": f"Gmail OK ({r.json().get('emailAddress', '')})"} + return {"success": False, "message": f"HTTP {r.status_code}"} + + def list_recent_threads(self, max_results: int = 25) -> List[Dict[str, Any]]: + token = self.get_access_token() + if not token: + return [] + headers = {"Authorization": f"Bearer {token}"} + r = requests.get( + f"{self.API_BASE}/users/me/threads", + headers=headers, + params={"maxResults": max_results}, + timeout=30, + ) + r.raise_for_status() + threads = [] + for item in r.json().get("threads", []): + detail = requests.get( + f"{self.API_BASE}/users/me/threads/{item['id']}", + headers=headers, + params={"format": "metadata", "metadataHeaders": ["From", "To", "Subject", "Date"]}, + timeout=30, + ) + if detail.status_code != 200: + continue + data = detail.json() + messages_out = [] + participants = set() + subject = "" + for msg in data.get("messages", []): + headers_map = {h["name"].lower(): h["value"] for h in msg.get("payload", {}).get("headers", [])} + subject = headers_map.get("subject") or subject + frm = headers_map.get("from", "") + to = headers_map.get("to", "") + participants.add(frm) + for addr in to.split(","): + if addr.strip(): + participants.add(addr.strip()) + sent_at = None + try: + if headers_map.get("date"): + sent_at = parsedate_to_datetime(headers_map["date"]) + except Exception: + pass + messages_out.append( + { + "id": msg.get("id"), + "from": frm, + "to": [a.strip() for a in to.split(",") if a.strip()], + "subject": headers_map.get("subject"), + "snippet": msg.get("snippet"), + "sent_at": sent_at, + } + ) + threads.append( + { + "id": item["id"], + "subject": subject, + "snippet": data.get("messages", [{}])[-1].get("snippet") if data.get("messages") else "", + "participants": list(participants), + "messages": messages_out, + } + ) + return threads + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + from app.services.email_sync_service import EmailSyncService + + threads = self.list_recent_threads() + result = EmailSyncService().ingest_threads("gmail", threads) + return {"success": True, "synced": result.get("created", 0) + result.get("updated", 0), **result} diff --git a/app/integrations/gusto.py b/app/integrations/gusto.py new file mode 100644 index 00000000..87c86f49 --- /dev/null +++ b/app/integrations/gusto.py @@ -0,0 +1,147 @@ +"""Gusto payroll connector.""" + +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict +from urllib.parse import urlencode + +import requests + +from app.integrations.base import BaseConnector + +logger = logging.getLogger(__name__) + + +class GustoConnector(BaseConnector): + """Gusto Partner API — push payroll hours / pay runs.""" + + display_name = "Gusto" + description = "Push time entries as payroll hours to Gusto" + icon = "gusto" + + AUTH_URL = "https://api.gusto.com/oauth/authorize" + TOKEN_URL = "https://api.gusto.com/oauth/token" + API_BASE = "https://api.gusto.com/v1" + + @property + def provider_name(self) -> str: + return "gusto" + + def _creds(self): + from app.models import Settings + + settings = Settings.get_settings() + c = settings.get_integration_credentials("gusto") + return { + "client_id": c.get("client_id") or os.getenv("GUSTO_CLIENT_ID"), + "client_secret": c.get("client_secret") or os.getenv("GUSTO_CLIENT_SECRET"), + } + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + c = self._creds() + if not c["client_id"]: + raise ValueError("GUSTO_CLIENT_ID not configured") + params = { + "client_id": c["client_id"], + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state or "", + } + return f"{self.AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "redirect_uri": redirect_uri, + "code": code, + "grant_type": "authorization_code", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 7200))) + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat(), + "token_type": data.get("token_type", "Bearer"), + "extra_data": {"company_uuid": data.get("company_uuid") or (self.integration.config or {}).get("company_uuid")}, + } + + def refresh_access_token(self) -> Dict[str, Any]: + if not self.credentials or not self.credentials.refresh_token: + raise ValueError("No refresh token") + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "refresh_token": self.credentials.refresh_token, + "grant_type": "refresh_token", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 7200))) + self.credentials.access_token = data["access_token"] + if data.get("refresh_token"): + self.credentials.refresh_token = data["refresh_token"] + self.credentials.expires_at = expires_at + from app import db + + db.session.commit() + return {"access_token": data["access_token"], "expires_at": expires_at.isoformat()} + + def test_connection(self) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "Not authenticated"} + company = (self.integration.config or {}).get("company_uuid") + if not company: + return {"success": False, "message": "Set company_uuid in integration config"} + r = requests.get( + f"{self.API_BASE}/companies/{company}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=20, + ) + if r.status_code == 200: + return {"success": True, "message": f"Gusto company: {r.json().get('name', company)}"} + return {"success": False, "message": f"HTTP {r.status_code}: {r.text[:200]}"} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + from app.services.payroll_sync_service import PayrollSyncService + + return PayrollSyncService().push_period( + provider="gusto", + integration=self.integration, + connector=self, + ) + + def push_payroll_batch(self, batch: Dict[str, Any]) -> Dict[str, Any]: + token = self.get_access_token() + company = (self.integration.config or {}).get("company_uuid") + if not token or not company: + return {"success": False, "message": "Missing token or company_uuid"} + # Gusto payrolls API varies by partnership; post a summary payload for partner sandbox + r = requests.post( + f"{self.API_BASE}/companies/{company}/payrolls", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={ + "start_date": batch["period_start"], + "end_date": batch["period_end"], + "employee_compensations": batch.get("employees", []), + }, + timeout=60, + ) + if r.status_code in (200, 201): + data = r.json() if r.content else {} + return {"success": True, "external_batch_id": str(data.get("uuid") or data.get("id") or ""), "raw": data} + return {"success": False, "message": f"HTTP {r.status_code}: {r.text[:300]}"} diff --git a/app/integrations/outlook_email.py b/app/integrations/outlook_email.py new file mode 100644 index 00000000..2eec907e --- /dev/null +++ b/app/integrations/outlook_email.py @@ -0,0 +1,196 @@ +"""Outlook / Microsoft Graph email sync for CRM.""" + +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List +from urllib.parse import urlencode + +import requests + +from app.integrations.base import BaseConnector + +logger = logging.getLogger(__name__) + + +class OutlookEmailConnector(BaseConnector): + """Microsoft Graph mail connector.""" + + display_name = "Outlook Email" + description = "Sync Outlook / Microsoft 365 mail into CRM" + icon = "outlook" + + AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + API_BASE = "https://graph.microsoft.com/v1.0" + + @property + def provider_name(self) -> str: + return "outlook_email" + + def _creds(self): + from app.models import Settings + + settings = Settings.get_settings() + c = settings.get_integration_credentials("outlook_email") + # Reuse Teams / Outlook Calendar app registration when dedicated creds missing + fallback = settings.get_integration_credentials("microsoft_teams") or {} + return { + "client_id": c.get("client_id") + or os.getenv("OUTLOOK_EMAIL_CLIENT_ID") + or os.getenv("MICROSOFT_CLIENT_ID") + or fallback.get("client_id"), + "client_secret": c.get("client_secret") + or os.getenv("OUTLOOK_EMAIL_CLIENT_SECRET") + or os.getenv("MICROSOFT_CLIENT_SECRET") + or fallback.get("client_secret"), + } + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + c = self._creds() + if not c["client_id"]: + raise ValueError("OUTLOOK_EMAIL_CLIENT_ID / MICROSOFT_CLIENT_ID not configured") + params = { + "client_id": c["client_id"], + "response_type": "code", + "redirect_uri": redirect_uri, + "response_mode": "query", + "scope": " ".join( + [ + "offline_access", + "User.Read", + "Mail.Read", + ] + ), + "state": state or "", + } + return f"{self.AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat(), + "token_type": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + } + + def refresh_access_token(self) -> Dict[str, Any]: + if not self.credentials or not self.credentials.refresh_token: + raise ValueError("No refresh token") + c = self._creds() + r = requests.post( + self.TOKEN_URL, + data={ + "client_id": c["client_id"], + "client_secret": c["client_secret"], + "refresh_token": self.credentials.refresh_token, + "grant_type": "refresh_token", + }, + timeout=30, + ) + r.raise_for_status() + data = r.json() + expires_at = datetime.utcnow() + timedelta(seconds=int(data.get("expires_in", 3600))) + self.credentials.access_token = data["access_token"] + if data.get("refresh_token"): + self.credentials.refresh_token = data["refresh_token"] + self.credentials.expires_at = expires_at + from app import db + + db.session.commit() + return {"access_token": data["access_token"], "expires_at": expires_at.isoformat()} + + def test_connection(self) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "Not authenticated"} + r = requests.get( + f"{self.API_BASE}/me", + headers={"Authorization": f"Bearer {token}"}, + timeout=20, + ) + if r.status_code == 200: + return {"success": True, "message": f"Outlook OK ({r.json().get('mail') or r.json().get('userPrincipalName')})"} + return {"success": False, "message": f"HTTP {r.status_code}"} + + def list_recent_threads(self, max_results: int = 25) -> List[Dict[str, Any]]: + token = self.get_access_token() + if not token: + return [] + headers = {"Authorization": f"Bearer {token}"} + r = requests.get( + f"{self.API_BASE}/me/messages", + headers=headers, + params={ + "$top": max_results, + "$orderby": "receivedDateTime desc", + "$select": "id,conversationId,subject,bodyPreview,from,toRecipients,receivedDateTime,body", + }, + timeout=30, + ) + r.raise_for_status() + by_conv: Dict[str, Dict[str, Any]] = {} + for msg in r.json().get("value", []): + conv = msg.get("conversationId") or msg.get("id") + frm = (msg.get("from") or {}).get("emailAddress", {}).get("address", "") + to_list = [ + t.get("emailAddress", {}).get("address", "") + for t in (msg.get("toRecipients") or []) + if t.get("emailAddress") + ] + sent_at = None + if msg.get("receivedDateTime"): + try: + sent_at = datetime.fromisoformat(msg["receivedDateTime"].replace("Z", "+00:00")) + except Exception: + pass + entry = { + "id": msg.get("id"), + "from": frm, + "to": to_list, + "subject": msg.get("subject"), + "snippet": msg.get("bodyPreview"), + "body_text": (msg.get("body") or {}).get("content") if (msg.get("body") or {}).get("contentType") == "text" else None, + "sent_at": sent_at, + } + if conv not in by_conv: + by_conv[conv] = { + "id": conv, + "subject": msg.get("subject"), + "snippet": msg.get("bodyPreview"), + "participants": set(), + "messages": [], + } + by_conv[conv]["participants"].add(frm) + for a in to_list: + by_conv[conv]["participants"].add(a) + by_conv[conv]["messages"].append(entry) + + result = [] + for t in by_conv.values(): + t["participants"] = list(t["participants"]) + result.append(t) + return result + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + from app.services.email_sync_service import EmailSyncService + + threads = self.list_recent_threads() + result = EmailSyncService().ingest_threads("outlook", threads) + return {"success": True, "synced": result.get("created", 0) + result.get("updated", 0), **result} diff --git a/app/integrations/registry.py b/app/integrations/registry.py index 9e3b10be..f523e045 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -4,16 +4,22 @@ """ from app.integrations.activitywatch import ActivityWatchConnector +from app.integrations.adp import AdpConnector from app.integrations.asana import AsanaConnector from app.integrations.caldav_calendar import CalDAVCalendarConnector +from app.integrations.datev import DatevConnector from app.integrations.github import GitHubConnector from app.integrations.gitlab import GitLabConnector +from app.integrations.gmail import GmailConnector from app.integrations.google_calendar import GoogleCalendarConnector +from app.integrations.gusto import GustoConnector from app.integrations.jira import JiraConnector from app.integrations.linear import LinearConnector from app.integrations.microsoft_teams import MicrosoftTeamsConnector from app.integrations.outlook_calendar import OutlookCalendarConnector +from app.integrations.outlook_email import OutlookEmailConnector from app.integrations.quickbooks import QuickBooksConnector +from app.integrations.sage import SageConnector from app.integrations.slack import SlackConnector from app.integrations.trello import TrelloConnector from app.integrations.xero import XeroConnector @@ -36,6 +42,12 @@ def register_connectors(): IntegrationService.register_connector("gitlab", GitLabConnector) IntegrationService.register_connector("quickbooks", QuickBooksConnector) IntegrationService.register_connector("xero", XeroConnector) + IntegrationService.register_connector("sage", SageConnector) + IntegrationService.register_connector("datev", DatevConnector) + IntegrationService.register_connector("gmail", GmailConnector) + IntegrationService.register_connector("outlook_email", OutlookEmailConnector) + IntegrationService.register_connector("gusto", GustoConnector) + IntegrationService.register_connector("adp", AdpConnector) # Auto-register on import diff --git a/app/integrations/sage.py b/app/integrations/sage.py new file mode 100644 index 00000000..03dcce90 --- /dev/null +++ b/app/integrations/sage.py @@ -0,0 +1,172 @@ +"""Sage Business Cloud accounting connector.""" + +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, Optional +from urllib.parse import urlencode + +import requests + +from app.integrations.base import BaseConnector + +logger = logging.getLogger(__name__) + + +class SageConnector(BaseConnector): + """Sage Business Cloud Accounting (OAuth2 + REST).""" + + display_name = "Sage Business Cloud" + description = "Sync invoices, contacts, and payments with Sage" + icon = "sage" + + AUTH_URL = "https://www.sageone.com/oauth2/auth/central" + TOKEN_URL = "https://oauth.accounting.sage.com/token" + API_BASE = "https://api.accounting.sage.com/v3.1" + + @property + def provider_name(self) -> str: + return "sage" + + def _creds(self): + from app.models import Settings + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("sage") + return { + "client_id": creds.get("client_id") or os.getenv("SAGE_CLIENT_ID"), + "client_secret": creds.get("client_secret") or os.getenv("SAGE_CLIENT_SECRET"), + } + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + c = self._creds() + if not c["client_id"]: + raise ValueError("SAGE_CLIENT_ID not configured") + params = { + "response_type": "code", + "client_id": c["client_id"], + "redirect_uri": redirect_uri, + "scope": "full_access", + "state": state or "", + "filter": "apiv3.1", + } + return f"{self.AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: + c = self._creds() + if not c["client_id"] or not c["client_secret"]: + raise ValueError("Sage OAuth credentials not configured") + response = requests.post( + self.TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": c["client_id"], + "client_secret": c["client_secret"], + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + expires_at = None + if data.get("expires_in"): + expires_at = datetime.utcnow() + timedelta(seconds=int(data["expires_in"])) + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat() if expires_at else None, + "token_type": data.get("token_type", "Bearer"), + } + + def refresh_access_token(self) -> Dict[str, Any]: + if not self.credentials or not self.credentials.refresh_token: + raise ValueError("No refresh token available") + c = self._creds() + response = requests.post( + self.TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": self.credentials.refresh_token, + "client_id": c["client_id"], + "client_secret": c["client_secret"], + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + expires_at = None + if data.get("expires_in"): + expires_at = datetime.utcnow() + timedelta(seconds=int(data["expires_in"])) + self.credentials.access_token = data.get("access_token") + if data.get("refresh_token"): + self.credentials.refresh_token = data["refresh_token"] + self.credentials.expires_at = expires_at + from app import db + + db.session.commit() + return { + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at.isoformat() if expires_at else None, + } + + def test_connection(self) -> Dict[str, Any]: + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token"} + try: + r = requests.get( + f"{self.API_BASE}/businesses", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=20, + ) + if r.status_code == 200: + return {"success": True, "message": "Connected to Sage Business Cloud"} + return {"success": False, "message": f"HTTP {r.status_code}: {r.text[:200]}"} + except Exception as exc: + return {"success": False, "message": str(exc)} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + """Push open invoices to Sage as sales invoices.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "Not authenticated", "synced": 0} + + from app.models import Invoice + + invoices = Invoice.query.filter(Invoice.status.in_(["sent", "paid", "draft"])).limit(50).all() + synced = 0 + errors = [] + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json"} + + for inv in invoices: + try: + payload = { + "sales_invoice": { + "reference": inv.invoice_number, + "date": (inv.issue_date or datetime.utcnow().date()).isoformat(), + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "contact_name": inv.client_name, + "notes": inv.notes or "", + "invoice_lines": [ + { + "description": item.description or "Service", + "quantity": float(item.quantity or 1), + "unit_price": float(item.unit_price or 0), + } + for item in (inv.items or []) + ] + or [{"description": "Invoice total", "quantity": 1, "unit_price": float(inv.total_amount or 0)}], + } + } + r = requests.post(f"{self.API_BASE}/sales_invoices", headers=headers, json=payload, timeout=30) + if r.status_code in (200, 201): + synced += 1 + else: + errors.append(f"{inv.invoice_number}: {r.status_code}") + except Exception as exc: + errors.append(f"{inv.invoice_number}: {exc}") + logger.exception("Sage sync failed for invoice %s", inv.id) + + return {"success": len(errors) == 0, "synced": synced, "errors": errors, "message": f"Synced {synced} invoices"} diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e963ebd..ebdae658 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -22,6 +22,8 @@ from .client_portal_customization import ClientPortalCustomization from .client_portal_dashboard_preference import DEFAULT_WIDGET_ORDER, VALID_WIDGET_IDS, ClientPortalDashboardPreference from .client_prepaid_consumption import ClientPrepaidConsumption +from .client_survey import ClientSurvey +from .client_message import ClientMessage from .client_time_approval import ClientApprovalPolicy, ClientApprovalStatus, ClientTimeApproval from .comment import Comment from .comment_attachment import CommentAttachment @@ -34,6 +36,7 @@ from .deal_activity import DealActivity from .deleted_username import DeletedUsername from .donation_interaction import DonationInteraction +from .email_thread import EmailMessage, EmailThread from .expense import Expense from .expense_category import ExpenseCategory from .expense_gps import MileageTrack @@ -62,6 +65,7 @@ from .payment_gateway import PaymentGateway, PaymentTransaction from .payments import CreditNote, InvoiceReminderSchedule, Payment from .payroll_export_template import PayrollExportTemplate +from .payroll_sync_log import PayrollSyncLog from .per_diem import PerDiem, PerDiemRate from .permission import Permission, Role from .project import Project @@ -145,6 +149,7 @@ "TaxRule", "Payment", "PayrollExportTemplate", + "PayrollSyncLog", "CreditNote", "InvoiceReminderSchedule", "SavedReportView", @@ -159,6 +164,7 @@ "UserFavoriteProject", "UserClient", "ClientNote", + "ClientMessage", "WeeklyTimeGoal", "WorkdaySession", "WorkingTimeViolation", @@ -267,4 +273,7 @@ "ClientNotification", "ClientNotificationPreferences", "NotificationType", + "ClientSurvey", + "EmailThread", + "EmailMessage", ] diff --git a/app/models/api_token.py b/app/models/api_token.py index 532bd5ec..dbd517a4 100644 --- a/app/models/api_token.py +++ b/app/models/api_token.py @@ -23,8 +23,11 @@ class ApiToken(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) user = relationship("User", backref="api_tokens") + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + client = relationship("Client", backref="portal_api_tokens") + # Scopes for fine-grained permissions (comma-separated) - # Examples: read:projects, write:time_entries, admin:all + # Examples: read:projects, write:time_entries, admin:all, portal:all scopes = db.Column(db.Text, default="") # Token lifecycle @@ -57,7 +60,7 @@ def hash_token(token): return hashlib.sha256(token.encode()).hexdigest() @classmethod - def create_token(cls, user_id, name, description="", scopes="", expires_days=None): + def create_token(cls, user_id, name, description="", scopes="", expires_days=None, client_id=None): """Create a new API token Args: @@ -66,6 +69,7 @@ def create_token(cls, user_id, name, description="", scopes="", expires_days=Non description: Optional description scopes: Comma-separated list of scopes expires_days: Number of days until expiration (None = never expires) + client_id: Optional client ID for portal-scoped tokens Returns: tuple: (ApiToken instance, plain_token) @@ -86,6 +90,7 @@ def create_token(cls, user_id, name, description="", scopes="", expires_days=Non user_id=user_id, scopes=scopes, expires_at=expires_at, + client_id=client_id, ) return api_token, plain_token diff --git a/app/models/client.py b/app/models/client.py index 3dc5e642..1e694a1a 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -40,6 +40,7 @@ class Client(db.Model): portal_issues_enabled = db.Column( db.Boolean, default=True, nullable=False ) # Enable/disable issue reporting in portal + custom_domain = db.Column(db.String(255), unique=True, nullable=True, index=True) # e.g. portal.client.com # Custom fields for flexible data storage (e.g., debtor_number, ERP IDs, etc.) custom_fields = db.Column(db.JSON, nullable=True) diff --git a/app/models/client_message.py b/app/models/client_message.py new file mode 100644 index 00000000..4496bfdb --- /dev/null +++ b/app/models/client_message.py @@ -0,0 +1,51 @@ +"""Client–team messaging models for the communication hub.""" + +from datetime import datetime + +from sqlalchemy import Index + +from app import db + + +class ClientMessage(db.Model): + """A message between the internal team and a client portal user.""" + + __tablename__ = "client_messages" + + id = db.Column(db.Integer, primary_key=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + + # 'team' | 'client' + sender_type = db.Column(db.String(20), nullable=False) + # User.id when sender_type=team; Contact.id or Client.id when client + sender_id = db.Column(db.Integer, nullable=True) + sender_name = db.Column(db.String(200), nullable=True) + + body = db.Column(db.Text, nullable=False) + # Optional JSON list of attachment metadata: [{name, url, size}] + attachments = db.Column(db.JSON, nullable=True) + + read_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + client = db.relationship("Client", backref=db.backref("messages", lazy="dynamic", cascade="all, delete-orphan")) + + __table_args__ = (Index("ix_client_messages_client_created", "client_id", "created_at"),) + + def mark_read(self): + if self.read_at is None: + self.read_at = datetime.utcnow() + + def to_dict(self): + return { + "id": self.id, + "client_id": self.client_id, + "sender_type": self.sender_type, + "sender_id": self.sender_id, + "sender_name": self.sender_name, + "body": self.body, + "attachments": self.attachments or [], + "read_at": self.read_at.isoformat() if self.read_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + } diff --git a/app/models/client_survey.py b/app/models/client_survey.py new file mode 100644 index 00000000..2deb883d --- /dev/null +++ b/app/models/client_survey.py @@ -0,0 +1,75 @@ +"""Client satisfaction / NPS survey models.""" + +import secrets +from datetime import datetime, timedelta + +from app import db + + +class ClientSurvey(db.Model): + """Token-based NPS / satisfaction survey sent to clients.""" + + __tablename__ = "client_surveys" + + id = db.Column(db.Integer, primary_key=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True, index=True) + + trigger = db.Column(db.String(40), nullable=False, index=True) # project_close | invoice_paid + token = db.Column(db.String(64), unique=True, nullable=False, index=True) + + nps_score = db.Column(db.Integer, nullable=True) # 0–10 + comment = db.Column(db.Text, nullable=True) + + sent_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + responded_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + client = db.relationship("Client", backref=db.backref("surveys", lazy="dynamic")) + project = db.relationship("Project", backref=db.backref("surveys", lazy="dynamic")) + invoice = db.relationship("Invoice", backref=db.backref("surveys", lazy="dynamic")) + + def __init__(self, client_id, trigger, project_id=None, invoice_id=None, expires_days=30): + self.client_id = client_id + self.trigger = trigger + self.project_id = project_id + self.invoice_id = invoice_id + self.token = secrets.token_urlsafe(32) + self.sent_at = datetime.utcnow() + self.expires_at = datetime.utcnow() + timedelta(days=expires_days) + + @property + def is_expired(self): + return bool(self.expires_at and self.expires_at < datetime.utcnow()) + + @property + def is_completed(self): + return self.responded_at is not None + + def submit(self, nps_score, comment=None): + score = int(nps_score) + if score < 0 or score > 10: + raise ValueError("NPS score must be between 0 and 10") + self.nps_score = score + self.comment = (comment or "").strip() or None + self.responded_at = datetime.utcnow() + self.updated_at = datetime.utcnow() + + def to_dict(self): + return { + "id": self.id, + "client_id": self.client_id, + "project_id": self.project_id, + "invoice_id": self.invoice_id, + "trigger": self.trigger, + "nps_score": self.nps_score, + "comment": self.comment, + "sent_at": self.sent_at.isoformat() if self.sent_at else None, + "responded_at": self.responded_at.isoformat() if self.responded_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "is_completed": self.is_completed, + } diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py index d1695b95..d037adfe 100644 --- a/app/models/donation_interaction.py +++ b/app/models/donation_interaction.py @@ -34,7 +34,7 @@ class DonationInteraction(db.Model): # Placement/source: header | banner | banner_bmc | banner_paypal | banner_key | dashboard_widget | donate_page_* | about_page | help_page source = db.Column(db.String(100), nullable=True) - # A/B test variant for experiments (e.g. control | key_first | cta_alt) + # A/B test variant for experiments (e.g. control | key_first | cta_alt | never_see) variant = db.Column(db.String(50), nullable=True) # User metrics at time of interaction (for smart prompts) diff --git a/app/models/email_thread.py b/app/models/email_thread.py new file mode 100644 index 00000000..6455a38f --- /dev/null +++ b/app/models/email_thread.py @@ -0,0 +1,72 @@ +"""Email thread models for Gmail / Outlook CRM sync.""" + +from datetime import datetime + +from app import db + + +class EmailThread(db.Model): + """A synced email conversation linked to CRM entities.""" + + __tablename__ = "email_threads" + + id = db.Column(db.Integer, primary_key=True) + provider = db.Column(db.String(40), nullable=False) # gmail | outlook + external_thread_id = db.Column(db.String(255), nullable=False, index=True) + subject = db.Column(db.String(500), nullable=True) + snippet = db.Column(db.Text, nullable=True) + participants = db.Column(db.JSON, nullable=True) # list of email addresses + + client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + lead_id = db.Column(db.Integer, db.ForeignKey("leads.id", ondelete="SET NULL"), nullable=True, index=True) + deal_id = db.Column(db.Integer, db.ForeignKey("deals.id", ondelete="SET NULL"), nullable=True, index=True) + + last_message_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + messages = db.relationship("EmailMessage", backref="thread", lazy="dynamic", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "provider": self.provider, + "external_thread_id": self.external_thread_id, + "subject": self.subject, + "snippet": self.snippet, + "participants": self.participants or [], + "client_id": self.client_id, + "lead_id": self.lead_id, + "deal_id": self.deal_id, + "last_message_at": self.last_message_at.isoformat() if self.last_message_at else None, + "message_count": self.messages.count(), + } + + +class EmailMessage(db.Model): + """Individual email within a synced thread.""" + + __tablename__ = "email_messages" + + id = db.Column(db.Integer, primary_key=True) + thread_id = db.Column(db.Integer, db.ForeignKey("email_threads.id", ondelete="CASCADE"), nullable=False, index=True) + external_message_id = db.Column(db.String(255), nullable=False, index=True) + from_address = db.Column(db.String(320), nullable=True) + to_addresses = db.Column(db.JSON, nullable=True) + subject = db.Column(db.String(500), nullable=True) + body_text = db.Column(db.Text, nullable=True) + body_html = db.Column(db.Text, nullable=True) + sent_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + "id": self.id, + "thread_id": self.thread_id, + "external_message_id": self.external_message_id, + "from_address": self.from_address, + "to_addresses": self.to_addresses or [], + "subject": self.subject, + "body_text": self.body_text, + "sent_at": self.sent_at.isoformat() if self.sent_at else None, + } diff --git a/app/models/payroll_sync_log.py b/app/models/payroll_sync_log.py new file mode 100644 index 00000000..d7c8d7fa --- /dev/null +++ b/app/models/payroll_sync_log.py @@ -0,0 +1,42 @@ +"""Payroll sync log for Gusto / ADP connectors.""" + +from datetime import datetime + +from app import db + + +class PayrollSyncLog(db.Model): + """Track payroll batch pushes to external providers.""" + + __tablename__ = "payroll_sync_logs" + + id = db.Column(db.Integer, primary_key=True) + provider = db.Column(db.String(40), nullable=False) # gusto | adp + integration_id = db.Column(db.Integer, db.ForeignKey("integrations.id", ondelete="SET NULL"), nullable=True) + period_start = db.Column(db.Date, nullable=False) + period_end = db.Column(db.Date, nullable=False) + status = db.Column(db.String(40), nullable=False, default="pending") # pending|success|failed|partial + employee_count = db.Column(db.Integer, default=0, nullable=False) + hours_total = db.Column(db.Float, default=0.0, nullable=False) + external_batch_id = db.Column(db.String(255), nullable=True) + error_message = db.Column(db.Text, nullable=True) + payload_summary = db.Column(db.JSON, nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + completed_at = db.Column(db.DateTime, nullable=True) + + def to_dict(self): + return { + "id": self.id, + "provider": self.provider, + "integration_id": self.integration_id, + "period_start": self.period_start.isoformat() if self.period_start else None, + "period_end": self.period_end.isoformat() if self.period_end else None, + "status": self.status, + "employee_count": self.employee_count, + "hours_total": self.hours_total, + "external_batch_id": self.external_batch_id, + "error_message": self.error_message, + "created_at": self.created_at.isoformat() if self.created_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + } diff --git a/app/models/settings.py b/app/models/settings.py index 4ad03ae3..079141d4 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -166,6 +166,8 @@ class Settings(db.Model): ai_base_url = db.Column(db.String(500), default="", nullable=True) ai_model = db.Column(db.String(120), default="", nullable=True) ai_api_key = db.Column(db.String(500), default="", nullable=True) + ai_routing_strategy = db.Column(db.String(20), default="", nullable=True) + portal_allowed_custom_domains = db.Column(db.Boolean, default=False, nullable=True) ai_timeout_seconds = db.Column(db.Integer, default=None, nullable=True) ai_context_limit = db.Column(db.Integer, default=None, nullable=True) ai_system_prompt = db.Column(db.Text, default="", nullable=True) @@ -354,6 +356,7 @@ def __init__(self, **kwargs): self.ai_base_url = kwargs.get("ai_base_url", "") self.ai_model = kwargs.get("ai_model", "") self.ai_api_key = kwargs.get("ai_api_key", "") + self.ai_routing_strategy = kwargs.get("ai_routing_strategy", "") self.ai_timeout_seconds = kwargs.get("ai_timeout_seconds", None) self.ai_context_limit = kwargs.get("ai_context_limit", None) self.ai_system_prompt = kwargs.get("ai_system_prompt", "") @@ -445,8 +448,23 @@ def cfg(name, default=None): return getattr(Config, name, default) provider = (getattr(self, "ai_provider", "") or cfg("AI_PROVIDER", "ollama") or "ollama").strip().lower() - base_url = (getattr(self, "ai_base_url", "") or cfg("AI_BASE_URL", "http://127.0.0.1:11434") or "").strip() - model = (getattr(self, "ai_model", "") or cfg("AI_MODEL", "llama3.1") or "").strip() + if provider == "custom": + provider = "openai_compatible" + + from app.services.llm_service import NAMED_PROVIDERS, PROVIDER_PRESETS, ROUTING_STRATEGIES + + if provider not in NAMED_PROVIDERS: + provider = "ollama" + + preset = PROVIDER_PRESETS.get(provider) or {} + stored_base = (getattr(self, "ai_base_url", "") or "").strip() + env_base = (cfg("AI_BASE_URL", "") or "").strip() + base_url = stored_base or env_base or (preset.get("base_url") or "http://127.0.0.1:11434") + + stored_model = (getattr(self, "ai_model", "") or "").strip() + env_model = (cfg("AI_MODEL", "") or "").strip() + model = stored_model or env_model or (preset.get("default_model") or "llama3.1") + timeout = getattr(self, "ai_timeout_seconds", None) or cfg("AI_TIMEOUT_SECONDS", 30) context_limit = getattr(self, "ai_context_limit", None) or cfg("AI_CONTEXT_LIMIT", 40) system_prompt = (getattr(self, "ai_system_prompt", "") or cfg("AI_SYSTEM_PROMPT", "") or "").strip() @@ -456,6 +474,10 @@ def cfg(name, default=None): if enabled is None: enabled = bool(cfg("AI_ENABLED", False)) + routing_strategy = (getattr(self, "ai_routing_strategy", "") or cfg("AI_ROUTING_STRATEGY", "") or "").strip().lower() + if routing_strategy not in ROUTING_STRATEGIES: + routing_strategy = "" + try: timeout = max(1, int(timeout)) except (TypeError, ValueError): @@ -467,7 +489,7 @@ def cfg(name, default=None): return { "enabled": bool(enabled), - "provider": provider if provider in {"ollama", "openai_compatible", "orcarouter"} else "ollama", + "provider": provider, "base_url": base_url.rstrip("/"), "model": model, "api_key": api_key if include_secrets else "", @@ -475,6 +497,10 @@ def cfg(name, default=None): "timeout_seconds": timeout, "context_limit": context_limit, "system_prompt": system_prompt, + "routing_strategy": routing_strategy, + "suggested_models": list(preset.get("suggested_models") or []), + "requires_key": bool(preset.get("requires_key")), + "preset_base_url": (preset.get("base_url") or ""), } def get_integration_credentials(self, provider: str, *, include_secrets: bool = True) -> dict: @@ -679,6 +705,7 @@ def to_dict(self): "ai_provider": getattr(self, "ai_provider", "") or "", "ai_base_url": getattr(self, "ai_base_url", "") or "", "ai_model": getattr(self, "ai_model", "") or "", + "ai_routing_strategy": getattr(self, "ai_routing_strategy", "") or "", "ai_api_key_set": bool(getattr(self, "ai_api_key", "")), "ai_timeout_seconds": getattr(self, "ai_timeout_seconds", None), "ai_context_limit": getattr(self, "ai_context_limit", None), diff --git a/app/routes/admin.py b/app/routes/admin.py index d6e16fd2..b9b394f0 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -829,6 +829,26 @@ def admin_dashboard_alias(): return redirect(url_for("admin.admin_dashboard")) +@admin_bp.route("/admin/client-surveys") +@login_required +@admin_or_permission_required("access_admin") +def client_surveys(): + """Admin view of client NPS / satisfaction survey responses.""" + from sqlalchemy import func + + from app.models.client_survey import ClientSurvey + + surveys = ClientSurvey.query.order_by(ClientSurvey.sent_at.desc()).limit(200).all() + completed = [s for s in surveys if s.nps_score is not None] + responses = len(completed) + avg_score = (sum(s.nps_score for s in completed) / responses) if responses else None + promoters = sum(1 for s in completed if s.nps_score >= 9) + detractors = sum(1 for s in completed if s.nps_score <= 6) + nps = int(round(((promoters - detractors) / responses) * 100)) if responses else None + stats = {"responses": responses, "avg_score": avg_score, "nps": nps} + return render_template("admin/client_surveys.html", surveys=surveys, stats=stats) + + @admin_bp.route("/admin/users") @login_required @admin_or_permission_required("view_users") @@ -1645,11 +1665,20 @@ def settings(): settings_obj.ai_enabled = None ai_provider = (request.form.get("ai_provider") or "ollama").strip().lower() - if ai_provider not in ("ollama", "openai_compatible", "orcarouter"): + if ai_provider == "custom": + ai_provider = "openai_compatible" + from app.services.llm_service import NAMED_PROVIDERS, PROVIDER_PRESETS, ROUTING_STRATEGIES + + if ai_provider not in NAMED_PROVIDERS: ai_provider = "ollama" settings_obj.ai_provider = ai_provider settings_obj.ai_base_url = (request.form.get("ai_base_url") or "").strip() + # If base URL left blank for a named preset, store the preset default so runtime is explicit. + if not settings_obj.ai_base_url and PROVIDER_PRESETS.get(ai_provider, {}).get("base_url"): + settings_obj.ai_base_url = PROVIDER_PRESETS[ai_provider]["base_url"] settings_obj.ai_model = (request.form.get("ai_model") or "").strip() + routing = (request.form.get("ai_routing_strategy") or "").strip().lower() + settings_obj.ai_routing_strategy = routing if routing in ROUTING_STRATEGIES else "" if request.form.get("ai_clear_api_key") == "on": settings_obj.set_secret("ai_api_key", "") else: @@ -1672,6 +1701,7 @@ def settings(): allow_analytics = request.form.get("allow_analytics") == "on" old_analytics_state = settings_obj.allow_analytics settings_obj.allow_analytics = allow_analytics + settings_obj.portal_allowed_custom_domains = request.form.get("portal_allowed_custom_domains") == "on" # Also update the installation config (used by telemetry system) # This ensures the telemetry system sees the updated preference diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index c3b87619..9c7d4934 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -198,6 +198,11 @@ def api_info(): }, "mileage_gps": "/api/v1/mileage/gps", "focus_sessions": "/api/v1/focus-sessions", + "gamification": { + "me": "/api/v1/gamification/me", + "badges": "/api/v1/gamification/badges", + "leaderboard": "/api/v1/gamification/leaderboard", + }, "search": "/api/v1/search", "inventory": { "items": "/api/v1/inventory/items", @@ -3627,6 +3632,39 @@ def get_stock_levels_api(): return jsonify({"stock_levels": levels}) +@api_v1_bp.route("/inventory/movements", methods=["GET"]) +@require_api_token(("read:inventory", "read:projects")) +def list_stock_movements_api(): + """List stock movements with optional filters and pagination.""" + blocked = _require_module_enabled_for_api("inventory") + if blocked: + return blocked + + item_id = request.args.get("item_id", type=int) or request.args.get("stock_item_id", type=int) + warehouse_id = request.args.get("warehouse_id", type=int) + movement_type = (request.args.get("movement_type") or "").strip() + date_from_str = request.args.get("date_from") + date_to_str = request.args.get("date_to") + date_from, date_to = _parse_date_range(date_from_str, date_to_str) + + query = StockMovement.query + + if item_id: + query = query.filter(StockMovement.stock_item_id == item_id) + if warehouse_id: + query = query.filter(StockMovement.warehouse_id == warehouse_id) + if movement_type: + query = query.filter(StockMovement.movement_type == movement_type) + if date_from: + query = query.filter(StockMovement.moved_at >= date_from) + if date_to: + query = query.filter(StockMovement.moved_at <= date_to) + + result = paginate_query(query.order_by(StockMovement.moved_at.desc())) + result["items"] = [m.to_dict() for m in result["items"]] + return jsonify(result) + + @api_v1_bp.route("/inventory/movements", methods=["POST"]) @require_api_token(("write:inventory", "write:projects")) def create_stock_movement_api(): @@ -5497,6 +5535,73 @@ def api_v1_focus_summary(): return jsonify(PomodoroService().get_session_stats(g.api_user.id, days=days)) +# ==================== Gamification ==================== + + +@api_v1_bp.route("/gamification/me", methods=["GET"]) +@require_api_token("read:users") +def api_v1_gamification_me(): + """Return badges and points for the authenticated API user.""" + blocked = _require_module_enabled_for_api("gamification") + if blocked: + return blocked + + from app.routes.gamification import ensure_default_gamification_data + from app.services.gamification_service import GamificationService + + ensure_default_gamification_data() + svc = GamificationService() + return jsonify( + { + "badges": svc.get_user_badges(g.api_user.id), + "points": svc.get_user_points(g.api_user.id), + } + ) + + +@api_v1_bp.route("/gamification/badges", methods=["GET"]) +@require_api_token("read:users") +def api_v1_gamification_badges(): + """List active badge definitions.""" + blocked = _require_module_enabled_for_api("gamification") + if blocked: + return blocked + + from app.models.gamification import Badge + from app.routes.gamification import ensure_default_gamification_data + + ensure_default_gamification_data() + badges = Badge.query.filter_by(is_active=True).order_by(Badge.points.asc()).all() + return jsonify({"badges": [b.to_dict() for b in badges]}) + + +@api_v1_bp.route("/gamification/leaderboard", methods=["GET"]) +@require_api_token("read:users") +def api_v1_gamification_leaderboard(): + """Return leaderboard entries for an active board.""" + blocked = _require_module_enabled_for_api("gamification") + if blocked: + return blocked + + from app.models.gamification import Leaderboard + from app.routes.gamification import ensure_default_gamification_data + from app.services.gamification_service import GamificationService + + ensure_default_gamification_data() + board_id = request.args.get("board_id", type=int) + board = Leaderboard.query.get(board_id) if board_id else Leaderboard.query.filter_by(is_active=True).first() + if not board: + return jsonify({"leaderboard": None, "entries": []}) + + svc = GamificationService() + try: + svc.calculate_leaderboard(board.id) + except Exception: + pass + limit = min(request.args.get("limit", 50, type=int) or 50, 100) + return jsonify({"leaderboard": board.to_dict(), "entries": svc.get_leaderboard(board.id, limit=limit)}) + + # ==================== Error Handlers ==================== diff --git a/app/routes/api_v1_client_portal.py b/app/routes/api_v1_client_portal.py new file mode 100644 index 00000000..ee78aba0 --- /dev/null +++ b/app/routes/api_v1_client_portal.py @@ -0,0 +1,285 @@ +"""REST API v1 - Client Portal endpoints. + +Mounted at /api/v1/portal. Authenticated via API tokens that have +portal scopes and an optional client_id binding. +""" + +from flask import Blueprint, g, jsonify, request + +from app.models import ( + Client, + ClientAttachment, + Invoice, + Project, + ProjectAttachment, + TimeEntry, +) +from app.models.client_time_approval import ClientTimeApproval +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response, validation_error_response + +api_v1_client_portal_bp = Blueprint("api_v1_client_portal", __name__, url_prefix="/api/v1/portal") + + +def _portal_client(): + """Resolve the client for the current portal API token.""" + token = getattr(g, "api_token", None) + if token and getattr(token, "client_id", None): + client = Client.query.get(token.client_id) + if client and client.is_active and client.has_portal_access: + return client + return None + + # Fallback: allow admin tokens with explicit client_id query/body + client_id = request.args.get("client_id", type=int) or (request.get_json(silent=True) or {}).get("client_id") + if client_id and token and token.has_scope("admin:all"): + return Client.query.get(client_id) + return None + + +def _require_portal_client(): + client = _portal_client() + if not client: + return None, forbidden_response("Portal token must be bound to a client, or pass client_id with admin scope") + return client, None + + +@api_v1_client_portal_bp.route("/projects", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "read:projects", "admin:all")) +def portal_list_projects(): + client, err = _require_portal_client() + if err: + return err + projects = Project.query.filter_by(client_id=client.id).order_by(Project.name).all() + return jsonify( + { + "projects": [ + { + "id": p.id, + "name": p.name, + "status": p.status, + "description": getattr(p, "description", None), + } + for p in projects + ] + } + ) + + +@api_v1_client_portal_bp.route("/invoices", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "read:invoices", "admin:all")) +def portal_list_invoices(): + client, err = _require_portal_client() + if err: + return err + invoices = ( + Invoice.query.filter( + (Invoice.client_id == client.id) | (Invoice.client_name == client.name) + ) + .order_by(Invoice.created_at.desc()) + .limit(100) + .all() + ) + return jsonify( + { + "invoices": [ + { + "id": inv.id, + "invoice_number": inv.invoice_number, + "status": inv.status, + "total_amount": float(inv.total_amount or 0), + "currency_code": getattr(inv, "currency_code", None) or "EUR", + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "issue_date": inv.issue_date.isoformat() if getattr(inv, "issue_date", None) else None, + } + for inv in invoices + ] + } + ) + + +@api_v1_client_portal_bp.route("/invoices/", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "read:invoices", "admin:all")) +def portal_get_invoice(invoice_id): + client, err = _require_portal_client() + if err: + return err + inv = Invoice.query.get_or_404(invoice_id) + if inv.client_id != client.id and inv.client_name != client.name: + return forbidden_response("Invoice does not belong to this client") + items = [] + for item in getattr(inv, "items", []) or []: + items.append( + { + "description": item.description, + "quantity": float(item.quantity or 0), + "unit_price": float(item.unit_price or 0), + } + ) + return jsonify( + { + "invoice": { + "id": inv.id, + "invoice_number": inv.invoice_number, + "status": inv.status, + "client_name": inv.client_name, + "total_amount": float(inv.total_amount or 0), + "tax_rate": float(inv.tax_rate or 0), + "currency_code": getattr(inv, "currency_code", None) or "EUR", + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "notes": inv.notes, + "terms": inv.terms, + "items": items, + } + } + ) + + +@api_v1_client_portal_bp.route("/time-entries", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "read:time_entries", "admin:all")) +def portal_list_time_entries(): + client, err = _require_portal_client() + if err: + return err + project_ids = [p.id for p in Project.query.filter_by(client_id=client.id).all()] + if not project_ids: + return jsonify({"time_entries": []}) + q = TimeEntry.query.filter(TimeEntry.project_id.in_(project_ids)) + billable = request.args.get("billable") + if billable is not None: + q = q.filter(TimeEntry.billable == (billable.lower() in ("1", "true", "yes"))) + entries = q.order_by(TimeEntry.start_time.desc()).limit(200).all() + return jsonify( + { + "time_entries": [ + { + "id": e.id, + "project_id": e.project_id, + "duration_hours": float(e.duration_hours or 0) if hasattr(e, "duration_hours") else None, + "notes": getattr(e, "notes", None) or getattr(e, "description", None), + "billable": getattr(e, "billable", None), + "start_time": e.start_time.isoformat() if e.start_time else None, + "end_time": e.end_time.isoformat() if e.end_time else None, + } + for e in entries + ] + } + ) + + +@api_v1_client_portal_bp.route("/messages", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "admin:all")) +def portal_list_messages(): + client, err = _require_portal_client() + if err: + return err + from app.services.client_message_service import ClientMessageService + + after_id = request.args.get("after_id", type=int) + messages = ClientMessageService().list_messages(client.id, after_id=after_id) + return jsonify({"messages": [m.to_dict() for m in messages]}) + + +@api_v1_client_portal_bp.route("/messages", methods=["POST"]) +@require_api_token(("portal:write", "portal:all", "admin:all")) +def portal_post_message(): + client, err = _require_portal_client() + if err: + return err + from app.services.client_message_service import ClientMessageService + + data = request.get_json() or {} + body = data.get("body") + if not body: + return validation_error_response(errors={"body": ["required"]}, message="body is required") + msg = ClientMessageService().send( + client.id, + sender_type="client", + body=body, + sender_id=client.id, + sender_name=client.name, + attachments=data.get("attachments"), + ) + if not msg: + return error_response("Could not send message", 500) + return jsonify({"message": msg.to_dict()}), 201 + + +@api_v1_client_portal_bp.route("/approvals//approve", methods=["POST"]) +@require_api_token(("portal:write", "portal:all", "admin:all")) +def portal_approve(approval_id): + client, err = _require_portal_client() + if err: + return err + approval = ClientTimeApproval.query.get_or_404(approval_id) + if approval.client_id != client.id: + return forbidden_response("Approval does not belong to this client") + from app.models import Contact + from app.services.client_approval_service import ClientApprovalService + + contacts = Contact.get_active_contacts(client.id) + contact = Contact.get_primary_contact(client.id) or (contacts[0] if contacts else None) + if not contact: + return error_response("No contact available to approve", 400) + data = request.get_json(silent=True) or {} + result = ClientApprovalService().approve(approval_id, contact.id, comment=data.get("comment")) + if not result.get("success"): + return error_response(result.get("message", "Approve failed"), 400) + return jsonify({"success": True, "approval_id": approval_id, "status": "approved", "result": result}) + + +@api_v1_client_portal_bp.route("/approvals//reject", methods=["POST"]) +@require_api_token(("portal:write", "portal:all", "admin:all")) +def portal_reject(approval_id): + client, err = _require_portal_client() + if err: + return err + approval = ClientTimeApproval.query.get_or_404(approval_id) + if approval.client_id != client.id: + return forbidden_response("Approval does not belong to this client") + from app.models import Contact + from app.services.client_approval_service import ClientApprovalService + + contacts = Contact.get_active_contacts(client.id) + contact = Contact.get_primary_contact(client.id) or (contacts[0] if contacts else None) + if not contact: + return error_response("No contact available to reject", 400) + data = request.get_json(silent=True) or {} + reason = data.get("reason") or "Rejected via API" + result = ClientApprovalService().reject(approval_id, contact.id, reason=reason) + if not result.get("success"): + return error_response(result.get("message", "Reject failed"), 400) + return jsonify({"success": True, "approval_id": approval_id, "status": "rejected", "result": result}) + + +@api_v1_client_portal_bp.route("/documents", methods=["GET"]) +@require_api_token(("portal:read", "portal:all", "admin:all")) +def portal_list_documents(): + client, err = _require_portal_client() + if err: + return err + docs = [] + for att in ClientAttachment.query.filter_by(client_id=client.id).all(): + docs.append( + { + "id": att.id, + "type": "client", + "filename": att.original_filename, + "mime_type": att.mime_type, + "created_at": att.created_at.isoformat() if att.created_at else None, + } + ) + project_ids = [p.id for p in Project.query.filter_by(client_id=client.id).all()] + if project_ids: + for att in ProjectAttachment.query.filter(ProjectAttachment.project_id.in_(project_ids)).all(): + docs.append( + { + "id": att.id, + "type": "project", + "project_id": att.project_id, + "filename": att.original_filename, + "mime_type": att.mime_type, + "created_at": att.created_at.isoformat() if att.created_at else None, + } + ) + return jsonify({"documents": docs}) diff --git a/app/routes/client_portal.py b/app/routes/client_portal.py index be032306..1b72cc64 100644 --- a/app/routes/client_portal.py +++ b/app/routes/client_portal.py @@ -11,6 +11,7 @@ abort, current_app, flash, + g, jsonify, redirect, render_template, @@ -238,6 +239,25 @@ def check_client_portal_access(): Response: A redirect response if authentication is needed None: If 403 is raised (abort is called) """ + # Custom domain: prefer g.portal_client set by before_request (white-label host) + domain_client = getattr(g, "portal_client", None) or getattr(g, "portal_domain_client", None) + if domain_client is None: + try: + from app.utils.portal_domain import resolve_portal_client_for_host + + domain_client = resolve_portal_client_for_host() + if domain_client is not None: + g.portal_client = domain_client + g.portal_domain_client = domain_client + except Exception: + domain_client = None + + if domain_client is not None: + session_client_id = session.get("client_portal_id") + if session_client_id and int(session_client_id) == domain_client.id: + return domain_client + g.portal_domain_client = domain_client + # Check for Client portal authentication client_id = session.get("client_portal_id") if client_id: @@ -364,11 +384,17 @@ def get_effective_widget_layout(client_id, user_id=None): @client_portal_bp.route("/client-portal/login", methods=["GET", "POST"]) def login(): """Client portal login page""" + domain_client = getattr(g, "portal_client", None) or getattr(g, "portal_domain_client", None) + if request.method == "GET": # If already logged in, redirect to dashboard if get_current_client(): return redirect(url_for("client_portal.dashboard")) - return render_template("client_portal/login.html") + return render_template( + "client_portal/login.html", + portal_client=domain_client, + prefill_username=domain_client.portal_username if domain_client else "", + ) # POST - handle login username = request.form.get("username", "").strip() @@ -376,14 +402,31 @@ def login(): if not username or not password: flash(_("Username and password are required."), "error") - return render_template("client_portal/login.html") + return render_template( + "client_portal/login.html", + portal_client=domain_client, + prefill_username=username or (domain_client.portal_username if domain_client else ""), + ) # Authenticate client client = Client.authenticate_portal(username, password) if not client: flash(_("Invalid username or password."), "error") - return render_template("client_portal/login.html") + return render_template( + "client_portal/login.html", + portal_client=domain_client, + prefill_username=username, + ) + + # On a white-label host, only allow the mapped client to sign in + if domain_client is not None and client.id != domain_client.id: + flash(_("This portal is reserved for %(name)s.", name=domain_client.name), "error") + return render_template( + "client_portal/login.html", + portal_client=domain_client, + prefill_username=domain_client.portal_username or "", + ) # Log in the client from flask_login import logout_user @@ -1639,3 +1682,116 @@ def activity_feed(): client=client, feed_items=feed_items, ) + + +@client_portal_bp.route("/client-portal/survey/", methods=["GET", "POST"]) +def survey_response(token): + """Public token-based NPS survey form (no portal login required).""" + from app.models.client_survey import ClientSurvey + from app.utils.db import safe_commit + + survey = ClientSurvey.query.filter_by(token=token).first_or_404() + client = Client.query.get(survey.client_id) + + if request.method == "POST" and not survey.is_completed and not survey.is_expired: + try: + score = int(request.form.get("nps_score")) + survey.submit(score, comment=request.form.get("comment")) + if not safe_commit("submit_client_survey", {"survey_id": survey.id}): + flash(_("Could not save your feedback. Please try again."), "error") + else: + flash(_("Thank you for your feedback!"), "success") + except (TypeError, ValueError) as exc: + flash(str(exc) or _("Invalid score."), "error") + + # Minimal render without requiring portal session — use survey template + # which extends portal base; inject a fake session-free path by setting client. + return render_template("client_portal/survey.html", survey=survey, client=client or Client(name="Client")) + + + +@client_portal_bp.route("/client-portal/messages") +def portal_messages(): + """Client portal communication hub.""" + client = check_client_portal_access() + if not isinstance(client, Client): + return client + + from app.services.client_message_service import ClientMessageService + + service = ClientMessageService() + messages = service.list_messages(client.id) + service.mark_thread_read(client.id, for_sender_type="client") + return render_template("client_portal/messages.html", client=client, messages=messages) + + +@client_portal_bp.route("/client-portal/messages", methods=["POST"]) +def portal_send_message(): + """Client sends a message to the team.""" + client = check_client_portal_access() + if not isinstance(client, Client): + return client + + from app.services.client_message_service import ClientMessageService + + body = request.form.get("body") or (request.get_json(silent=True) or {}).get("body") + service = ClientMessageService() + msg = service.send( + client.id, + sender_type="client", + body=body or "", + sender_id=client.id, + sender_name=client.name, + ) + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + if not msg: + return jsonify({"success": False, "error": "empty"}), 400 + return jsonify({"success": True, "message": msg.to_dict()}) + if not msg: + flash(_("Message cannot be empty."), "error") + else: + flash(_("Message sent."), "success") + return redirect(url_for("client_portal.portal_messages")) + + +@client_portal_bp.route("/client-portal/messages/poll") +def portal_poll_messages(): + """JSON poll for new portal messages.""" + client = check_client_portal_access() + if not isinstance(client, Client): + return client + + from app.services.client_message_service import ClientMessageService + + after_id = request.args.get("after_id", type=int) + service = ClientMessageService() + messages = service.list_messages(client.id, after_id=after_id) + return jsonify({"messages": [m.to_dict() for m in messages]}) + + +@client_portal_bp.route("/client-portal/messages/stream") +def portal_messages_stream(): + """Server-Sent Events stream for live message updates.""" + client = check_client_portal_access() + if not isinstance(client, Client): + return client + + import json + import time + + from app.services.client_message_service import ClientMessageService + + def generate(): + service = ClientMessageService() + last_id = request.args.get("after_id", type=int) or 0 + for _ in range(60): + messages = service.list_messages(client.id, after_id=last_id, limit=50) + if messages: + last_id = messages[-1].id + payload = json.dumps({"messages": [m.to_dict() for m in messages]}) + yield f"data: {payload}\n\n" + else: + yield ": keepalive\n\n" + time.sleep(2) + + return current_app.response_class(generate(), mimetype="text/event-stream") diff --git a/app/routes/clients.py b/app/routes/clients.py index 9f7a3c59..2c0e9050 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -559,6 +559,14 @@ def view_client(client_id): except Exception as e: current_app.logger.warning("Could not load unbilled invoice preview for client %s: %s", client_id, e) + email_threads = [] + try: + from app.services.email_sync_service import EmailSyncService + + email_threads = EmailSyncService().threads_for_client(client_id) + except Exception as e: + current_app.logger.debug("Could not load email threads for client %s: %s", client_id, e) + return render_template( "clients/view.html", client=client, @@ -572,6 +580,7 @@ def view_client(client_id): custom_field_definitions_by_key=custom_field_definitions_by_key, can_invoice_unbilled_time=can_invoice_unbilled_time, unbilled_invoice_preview=unbilled_invoice_preview, + email_threads=email_threads, ) @@ -709,6 +718,20 @@ def edit_client(client_id): client.prepaid_reset_day = prepaid_reset_day client.portal_enabled = portal_enabled client.portal_issues_enabled = portal_issues_enabled if portal_enabled else False + custom_domain = (request.form.get("custom_domain") or "").strip().lower() + if custom_domain: + # Normalize: strip scheme and path + custom_domain = custom_domain.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0] + existing_domain = Client.query.filter(Client.custom_domain == custom_domain, Client.id != client.id).first() + if existing_domain: + flash(_("That custom domain is already used by another client."), "error") + custom_field_definitions = CustomFieldDefinition.get_active_definitions() + return render_template( + "clients/edit.html", client=client, custom_field_definitions=custom_field_definitions + ) + client.custom_domain = custom_domain + else: + client.custom_domain = None client.custom_fields = custom_fields if custom_fields else None # Update portal credentials @@ -1448,3 +1471,75 @@ def delete_client_attachment(attachment_id): flash(_("Attachment deleted successfully"), "success") return redirect(url_for("clients.view_client", client_id=client_id)) + + +@clients_bp.route("/clients//messages") +@login_required +@admin_or_permission_required("view_clients", "view_all_clients", "view_own_clients") +def client_messages(client_id): + """Team-side communication hub for a client.""" + from app.services.client_message_service import ClientMessageService + from app.utils.scope_filter import user_can_access_client + + client = Client.query.get_or_404(client_id) + if not user_can_access_client(current_user, client_id): + abort(403) + + service = ClientMessageService() + messages = service.list_messages(client_id) + service.mark_thread_read(client_id, for_sender_type="team") + unread = service.unread_count(client_id, for_sender_type="team") + return render_template( + "clients/messages.html", + client=client, + messages=messages, + unread_count=unread, + ) + + +@clients_bp.route("/clients//messages", methods=["POST"]) +@login_required +@admin_or_permission_required("edit_clients", "edit_all_clients", "edit_own_clients") +def send_client_message(client_id): + """Send a team message to the client hub.""" + from app.services.client_message_service import ClientMessageService + from app.utils.scope_filter import user_can_access_client + + client = Client.query.get_or_404(client_id) + if not user_can_access_client(current_user, client_id): + abort(403) + + body = request.form.get("body") or (request.get_json(silent=True) or {}).get("body") + service = ClientMessageService() + msg = service.send( + client_id, + sender_type="team", + body=body or "", + sender_id=current_user.id, + sender_name=current_user.username, + ) + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + if not msg: + return jsonify({"success": False, "error": "empty"}), 400 + return jsonify({"success": True, "message": msg.to_dict()}) + if not msg: + flash(_("Message cannot be empty."), "error") + else: + flash(_("Message sent."), "success") + return redirect(url_for("clients.client_messages", client_id=client_id)) + + +@clients_bp.route("/clients//messages/poll") +@login_required +@admin_or_permission_required("view_clients", "view_all_clients", "view_own_clients") +def poll_client_messages(client_id): + """JSON poll for new messages.""" + from app.services.client_message_service import ClientMessageService + from app.utils.scope_filter import user_can_access_client + + if not user_can_access_client(current_user, client_id): + abort(403) + after_id = request.args.get("after_id", type=int) + service = ClientMessageService() + messages = service.list_messages(client_id, after_id=after_id) + return jsonify({"messages": [m.to_dict() for m in messages]}) diff --git a/app/routes/deals.py b/app/routes/deals.py index c246472e..3ddaabd3 100644 --- a/app/routes/deals.py +++ b/app/routes/deals.py @@ -171,7 +171,16 @@ def view_deal(deal_id): .limit(25) .all() ) - return render_template("deals/view.html", deal=deal, activities=activities, audit_logs=audit_logs) + email_threads = [] + try: + from app.services.email_sync_service import EmailSyncService + + email_threads = EmailSyncService().threads_for_deal(deal_id) + except Exception: + pass + return render_template( + "deals/view.html", deal=deal, activities=activities, audit_logs=audit_logs, email_threads=email_threads + ) @deals_bp.route("/deals//edit", methods=["GET", "POST"]) diff --git a/app/routes/inventory.py b/app/routes/inventory.py index 12a497a7..6d0cbfb3 100644 --- a/app/routes/inventory.py +++ b/app/routes/inventory.py @@ -4,7 +4,7 @@ from decimal import Decimal, InvalidOperation from uuid import uuid4 -from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for +from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, send_file, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from sqlalchemy import func, or_ @@ -1959,6 +1959,27 @@ def view_purchase_order(po_id): ) +@inventory_bp.route("/inventory/purchase-orders//pdf") +@login_required +@module_enabled("inventory") +@admin_or_permission_required("view_inventory") +def purchase_order_pdf(po_id): + """Download purchase order as PDF.""" + import io + + purchase_order = PurchaseOrder.query.get_or_404(po_id) + from app.utils.purchase_order_pdf import PurchaseOrderPDFGenerator + + pdf_bytes = PurchaseOrderPDFGenerator(purchase_order, settings=Settings.get_settings()).generate_pdf() + filename = f"{purchase_order.po_number or f'PO-{po_id}'}.pdf".replace("/", "-") + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @inventory_bp.route("/inventory/purchase-orders//edit", methods=["GET", "POST"]) @login_required @module_enabled("inventory") diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 5ceea1d2..7fade886 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -485,6 +485,7 @@ def edit_invoice(invoice_id): good_quantities = request.form.getlist("good_quantity[]") good_unit_prices = request.form.getlist("good_unit_price[]") good_skus = request.form.getlist("good_sku[]") + good_stock_item_ids = request.form.getlist("good_stock_item_id[]") # Remove existing extra goods invoice.extra_goods.delete() @@ -495,6 +496,12 @@ def edit_invoice(invoice_id): try: quantity = Decimal(good_quantities[i]) unit_price = Decimal(good_unit_prices[i]) + stock_item_id = None + if i < len(good_stock_item_ids) and good_stock_item_ids[i]: + try: + stock_item_id = int(good_stock_item_ids[i]) + except (TypeError, ValueError): + stock_item_id = None good = ExtraGood( name=good_names[i].strip(), @@ -510,6 +517,7 @@ def edit_invoice(invoice_id): invoice_id=invoice.id, created_by=current_user.id, currency_code=invoice.currency_code, + stock_item_id=stock_item_id, ) db.session.add(good) except ValueError: @@ -712,6 +720,51 @@ def update_invoice_status(invoice_id): "warning", ) + # Also deplete stock linked from ExtraGoods on this invoice + for good in invoice.extra_goods: + if not getattr(good, "stock_item_id", None): + continue + try: + from app.models import Warehouse, WarehouseStock + + warehouse_id = None + # Prefer first active warehouse with available stock for this item + stock_row = ( + WarehouseStock.query.filter_by(stock_item_id=good.stock_item_id) + .join(Warehouse) + .filter(Warehouse.is_active == True) # noqa: E712 + .order_by(WarehouseStock.quantity_on_hand.desc()) + .first() + ) + if stock_row: + warehouse_id = stock_row.warehouse_id + else: + first_wh = Warehouse.query.filter_by(is_active=True).first() + warehouse_id = first_wh.id if first_wh else None + if not warehouse_id: + continue + StockMovement.record_movement( + movement_type="sale", + stock_item_id=good.stock_item_id, + warehouse_id=warehouse_id, + quantity=-Decimal(str(good.quantity or 0)), + moved_by=current_user.id, + reference_type="invoice_extra_good", + reference_id=invoice.id, + unit_cost=good.stock_item.default_cost if good.stock_item else None, + reason=f"Invoice {invoice.invoice_number} extra good: {good.name}", + update_stock=True, + ) + except Exception as e: + flash( + _( + "Warning: Could not reduce stock for extra good %(item)s: %(error)s", + item=good.name, + error=str(e), + ), + "warning", + ) + if not safe_commit("update_invoice_status", {"invoice_id": invoice.id, "status": new_status}): return jsonify({"error": "Database error while updating status"}), 500 @@ -719,6 +772,12 @@ def update_invoice_status(invoice_id): from app.utils.workflow_bridge import fire_invoice_paid_workflow fire_invoice_paid_workflow(invoice, current_user.id) + try: + from app.services.client_survey_service import ClientSurveyService + + ClientSurveyService().on_invoice_paid(invoice) + except Exception as survey_exc: + current_app.logger.debug("Client survey on invoice paid skipped: %s", survey_exc) try: log_event( diff --git a/app/routes/leads.py b/app/routes/leads.py index 057f5237..e6ec9659 100644 --- a/app/routes/leads.py +++ b/app/routes/leads.py @@ -126,7 +126,14 @@ def view_lead(lead_id): activities = ( LeadActivity.query.filter_by(lead_id=lead_id).order_by(LeadActivity.activity_date.desc()).limit(50).all() ) - return render_template("leads/view.html", lead=lead, activities=activities) + email_threads = [] + try: + from app.services.email_sync_service import EmailSyncService + + email_threads = EmailSyncService().threads_for_lead(lead_id) + except Exception: + pass + return render_template("leads/view.html", lead=lead, activities=activities, email_threads=email_threads) @leads_bp.route("/leads//edit", methods=["GET", "POST"]) diff --git a/app/routes/main.py b/app/routes/main.py index e866e7e7..06b79189 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -467,10 +467,15 @@ def dashboard(): support_dashboard_prompt = { **support_dashboard_prompt, "message": _( - "You've tracked %(hours)s hours with TimeTracker. That's reliable data for your clients " - "and your business — consider supporting continued development.", + "TimeTracker has kept you on track for %(hours)s hours. " + "Consider buying a key to remove prompts and support future development.", hours=milestone, ), + "celebration": True, + "title": _("%(hours)s hours tracked!", hours=milestone), + "keyLabel": _("Get key (€25)"), + "bmcLabel": _("Buy a coffee"), + "dismissLabel": _("Maybe later"), } elif v == SupportPromptService.VARIANT_ACTIVE_TODAY: support_dashboard_prompt = { diff --git a/app/routes/projects.py b/app/routes/projects.py index e496be75..7473d8bb 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1445,6 +1445,12 @@ def bulk_status_change(): project.archived_by = current_user.id project.archived_reason = archive_reason if archive_reason else None project.updated_at = datetime.utcnow() + try: + from app.services.client_survey_service import ClientSurveyService + + ClientSurveyService().on_project_closed(project) + except Exception as survey_exc: + current_app.logger.debug("Client survey on project archive skipped: %s", survey_exc) elif new_status == "active": # Clear archiving metadata when activating project.status = "active" @@ -1456,7 +1462,13 @@ def bulk_status_change(): # Just update status for inactive project.status = new_status project.updated_at = datetime.utcnow() + if new_status == "inactive": + try: + from app.services.client_survey_service import ClientSurveyService + ClientSurveyService().on_project_closed(project) + except Exception as survey_exc: + current_app.logger.debug("Client survey on project inactive skipped: %s", survey_exc) updated_count += 1 # Log the status change @@ -2084,6 +2096,12 @@ def list_goods(project_id): def add_good(project_id): """Add a new extra good to a project""" project = Project.query.get_or_404(project_id) + from app.models import StockItem + + try: + stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).limit(500).all() + except Exception: + stock_items = [] if request.method == "POST": name = request.form.get("name", "").strip() @@ -2094,11 +2112,12 @@ def add_good(project_id): sku = request.form.get("sku", "").strip() billable = request.form.get("billable") == "on" currency_code = request.form.get("currency_code", "EUR").strip() + stock_item_id = request.form.get("stock_item_id", type=int) or None # Validate required fields if not name or not unit_price: flash(_("Name and unit price are required"), "error") - return render_template("projects/add_good.html", project=project) + return render_template("projects/add_good.html", project=project, stock_items=stock_items) # Validate quantity try: @@ -2107,7 +2126,7 @@ def add_good(project_id): raise ValueError("Quantity must be positive") except (ValueError, Exception): flash(_("Invalid quantity format"), "error") - return render_template("projects/add_good.html", project=project) + return render_template("projects/add_good.html", project=project, stock_items=stock_items) # Validate unit price try: @@ -2116,7 +2135,7 @@ def add_good(project_id): raise ValueError("Unit price cannot be negative") except (ValueError, Exception): flash(_("Invalid unit price format"), "error") - return render_template("projects/add_good.html", project=project) + return render_template("projects/add_good.html", project=project, stock_items=stock_items) # Create extra good good = ExtraGood( @@ -2130,17 +2149,18 @@ def add_good(project_id): currency_code=currency_code, project_id=project_id, created_by=current_user.id, + stock_item_id=stock_item_id, ) db.session.add(good) if not safe_commit("add_project_good", {"project_id": project_id}): flash(_("Could not add extra good due to a database error. Please check server logs."), "error") - return render_template("projects/add_good.html", project=project) + return render_template("projects/add_good.html", project=project, stock_items=stock_items) flash(_("Extra good added successfully"), "success") return redirect(url_for("projects.view_project", project_id=project.id)) - return render_template("projects/add_good.html", project=project) + return render_template("projects/add_good.html", project=project, stock_items=stock_items) @projects_bp.route("/projects//goods//edit", methods=["GET", "POST"]) @@ -2149,6 +2169,12 @@ def edit_good(project_id, good_id): """Edit a project extra good""" project = Project.query.get_or_404(project_id) good = ExtraGood.query.get_or_404(good_id) + from app.models import StockItem + + try: + stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).limit(500).all() + except Exception: + stock_items = [] # Verify good belongs to project if good.project_id != project_id: @@ -2173,7 +2199,7 @@ def edit_good(project_id, good_id): # Validate required fields if not name or not unit_price: flash(_("Name and unit price are required"), "error") - return render_template("projects/edit_good.html", project=project, good=good) + return render_template("projects/edit_good.html", project=project, good=good, stock_items=stock_items) # Validate quantity try: @@ -2182,7 +2208,7 @@ def edit_good(project_id, good_id): raise ValueError("Quantity must be positive") except (ValueError, Exception): flash(_("Invalid quantity format"), "error") - return render_template("projects/edit_good.html", project=project, good=good) + return render_template("projects/edit_good.html", project=project, good=good, stock_items=stock_items) # Validate unit price try: @@ -2191,7 +2217,7 @@ def edit_good(project_id, good_id): raise ValueError("Unit price cannot be negative") except (ValueError, Exception): flash(_("Invalid unit price format"), "error") - return render_template("projects/edit_good.html", project=project, good=good) + return render_template("projects/edit_good.html", project=project, good=good, stock_items=stock_items) # Update good good.name = name @@ -2202,16 +2228,17 @@ def edit_good(project_id, good_id): good.sku = sku if sku else None good.billable = billable good.currency_code = currency_code + good.stock_item_id = request.form.get("stock_item_id", type=int) or None good.update_total() if not safe_commit("edit_project_good", {"good_id": good_id}): flash(_("Could not update extra good due to a database error. Please check server logs."), "error") - return render_template("projects/edit_good.html", project=project, good=good) + return render_template("projects/edit_good.html", project=project, good=good, stock_items=stock_items) flash(_("Extra good updated successfully"), "success") return redirect(url_for("projects.view_project", project_id=project.id)) - return render_template("projects/edit_good.html", project=project, good=good) + return render_template("projects/edit_good.html", project=project, good=good, stock_items=stock_items) @projects_bp.route("/projects//goods//delete", methods=["POST"]) diff --git a/app/routes/timer.py b/app/routes/timer.py index c8afae16..e346777c 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -2022,6 +2022,17 @@ def _bulk_ctx(**extra): return render_template("timer/bulk_entry.html", **_bulk_ctx()) +@timer_bp.route("/focus") +@login_required +def focus_mode(): + """Dedicated Focus / Pomodoro page.""" + from app.services.pomodoro_service import PomodoroService + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + stats = PomodoroService().get_session_stats(current_user.id, days=7) + return render_template("timer/focus.html", projects=projects, stats=stats) + + @timer_bp.route("/timer") @login_required def timer_page(): diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 629e994c..af872363 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -181,6 +181,46 @@ def toggle_workflow(workflow_id): return jsonify({"success": True, "enabled": workflow.enabled}) +@workflows_bp.route("/workflows//builder", methods=["GET", "POST"]) +@login_required +@module_enabled("workflows") +def visual_builder(workflow_id): + """Visual canvas editor for workflow trigger → conditions → actions.""" + workflow = WorkflowRule.query.get_or_404(workflow_id) + + if workflow.user_id != current_user.id and not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workflows.list_workflows")) + + if request.method == "POST": + data = request.get_json() if request.is_json else request.form + fields, _conditions, _actions = parse_workflow_form_data(data) + # Preserve name/description/enabled/priority when only graph fields sent + if not fields.get("name"): + fields["name"] = workflow.name + if fields.get("description") is None: + fields["description"] = workflow.description + if "enabled" not in data and "enabled" not in (data or {}): + fields["enabled"] = workflow.enabled + if data.get("priority") in (None, ""): + fields["priority"] = workflow.priority + _apply_workflow_fields(workflow, fields) + db.session.commit() + + if request.is_json: + return jsonify({"success": True, "workflow": workflow.to_dict()}) + + flash(_("Workflow saved"), "success") + return redirect(url_for("workflows.visual_builder", workflow_id=workflow_id)) + + return render_template( + "workflows/visual_builder.html", + workflow=workflow, + trigger_types=get_trigger_types(), + action_types=get_action_types(), + ) + + # --- Workflow template library --- diff --git a/app/routes/workforce.py b/app/routes/workforce.py index fc0abebb..2c09afb5 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -95,6 +95,15 @@ def dashboard(): payroll_templates = PayrollExportTemplate.query.order_by(PayrollExportTemplate.name.asc()).all() default_payroll_template = next((t for t in payroll_templates if t.is_default), payroll_templates[0] if payroll_templates else None) + payroll_sync_logs = [] + if current_user.is_admin: + try: + from app.models.payroll_sync_log import PayrollSyncLog + + payroll_sync_logs = PayrollSyncLog.query.order_by(PayrollSyncLog.created_at.desc()).limit(15).all() + except Exception: + payroll_sync_logs = [] + return render_template( "workforce/dashboard.html", periods=periods, @@ -113,9 +122,58 @@ def dashboard(): overtime_leave_type_id=overtime_leave_type_id, payroll_templates=payroll_templates, default_payroll_template=default_payroll_template, + payroll_sync_logs=payroll_sync_logs, ) +@workforce_bp.route("/workforce/payroll-sync", methods=["POST"]) +@login_required +def payroll_sync_push(): + """Push a payroll period to Gusto or ADP.""" + if not current_user.is_admin: + flash(_("Admin access required"), "error") + return redirect(url_for("workforce.dashboard")) + + provider = (request.form.get("provider") or "gusto").strip().lower() + if provider not in ("gusto", "adp"): + flash(_("Unknown payroll provider"), "error") + return redirect(url_for("workforce.dashboard") + "#payroll-sync") + + period_start = _parse_date(request.form.get("start_date")) + period_end = _parse_date(request.form.get("end_date")) + if not period_start or not period_end: + flash(_("Start and end dates are required"), "error") + return redirect(url_for("workforce.dashboard") + "#payroll-sync") + + from app.models import Integration + from app.services.integration_service import IntegrationService + from app.services.payroll_sync_service import PayrollSyncService + + integration = Integration.query.filter_by(provider=provider, is_active=True).first() + if not integration: + flash(_("No active %(provider)s integration found. Configure it under Integrations.", provider=provider), "error") + return redirect(url_for("integrations.list_integrations")) + + try: + connector = IntegrationService().get_connector(integration) + result = PayrollSyncService().push_period( + provider=provider, + integration=integration, + connector=connector, + period_start=period_start, + period_end=period_end, + created_by=current_user.id, + ) + if result.get("success"): + flash(_("Payroll push succeeded (%(n)s employees)", n=result.get("synced", 0)), "success") + else: + flash(_("Payroll push failed: %(msg)s", msg=result.get("message") or "error"), "error") + except Exception as exc: + flash(_("Payroll push failed: %(msg)s", msg=str(exc)), "error") + + return redirect(url_for("workforce.dashboard") + "#payroll-sync") + + @workforce_bp.route("/workforce/periods/create", methods=["POST"]) @login_required def create_period(): diff --git a/app/services/client_message_service.py b/app/services/client_message_service.py new file mode 100644 index 00000000..16502abb --- /dev/null +++ b/app/services/client_message_service.py @@ -0,0 +1,73 @@ +"""Client–team messaging service.""" + +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from app import db +from app.models.client_message import ClientMessage +from app.utils.db import safe_commit + + +class ClientMessageService: + """Send and list messages for a client communication hub thread.""" + + def list_messages(self, client_id: int, *, after_id: Optional[int] = None, limit: int = 100) -> List[ClientMessage]: + q = ClientMessage.query.filter_by(client_id=client_id) + if after_id: + q = q.filter(ClientMessage.id > after_id) + return q.order_by(ClientMessage.created_at.asc()).limit(min(limit, 500)).all() + + def send( + self, + client_id: int, + *, + sender_type: str, + body: str, + sender_id: Optional[int] = None, + sender_name: Optional[str] = None, + attachments=None, + ) -> Optional[ClientMessage]: + body = (body or "").strip() + if not body: + return None + if sender_type not in ("team", "client"): + raise ValueError("sender_type must be 'team' or 'client'") + + msg = ClientMessage( + client_id=client_id, + sender_type=sender_type, + sender_id=sender_id, + sender_name=sender_name, + body=body, + attachments=attachments or [], + ) + db.session.add(msg) + if not safe_commit("client_message_send", {"client_id": client_id}): + db.session.rollback() + return None + return msg + + def mark_thread_read(self, client_id: int, *, for_sender_type: str) -> int: + """Mark messages from the opposite party as read.""" + opposite = "client" if for_sender_type == "team" else "team" + rows = ( + ClientMessage.query.filter_by(client_id=client_id, sender_type=opposite) + .filter(ClientMessage.read_at.is_(None)) + .all() + ) + now = datetime.utcnow() + for row in rows: + row.read_at = now + if rows: + safe_commit("client_message_mark_read", {"client_id": client_id, "count": len(rows)}) + return len(rows) + + def unread_count(self, client_id: int, *, for_sender_type: str) -> int: + opposite = "client" if for_sender_type == "team" else "team" + return ( + ClientMessage.query.filter_by(client_id=client_id, sender_type=opposite) + .filter(ClientMessage.read_at.is_(None)) + .count() + ) diff --git a/app/services/client_notification_service.py b/app/services/client_notification_service.py index 6eb7b521..79520fe2 100644 --- a/app/services/client_notification_service.py +++ b/app/services/client_notification_service.py @@ -59,6 +59,25 @@ def create_notification( }, room=f"client_portal_{client_id}", ) + socketio.emit( + "portal_refresh", + { + "title": notification.title, + "message": notification.message, + "type": notification.type, + "reload": notification.type + in { + "invoice_created", + "invoice_paid", + "invoice_overdue", + "budget_alert", + "time_entry_approval", + "quote_available", + "project_milestone", + }, + }, + room=f"client_portal_{client_id}", + ) except Exception as e: logger.debug("SocketIO emit for client notification skipped: %s", e) diff --git a/app/services/client_survey_service.py b/app/services/client_survey_service.py new file mode 100644 index 00000000..3b9d95c4 --- /dev/null +++ b/app/services/client_survey_service.py @@ -0,0 +1,100 @@ +"""Client survey / NPS service.""" + +import logging +from typing import Optional + +from app import db +from app.models.client_survey import ClientSurvey +from app.utils.db import safe_commit +from app.utils.email import send_template_email + +logger = logging.getLogger(__name__) + + +class ClientSurveyService: + """Create and send NPS surveys after project close or invoice payment.""" + + def create_and_send( + self, + *, + client_id: int, + trigger: str, + project_id: Optional[int] = None, + invoice_id: Optional[int] = None, + recipient_email: Optional[str] = None, + ) -> Optional[ClientSurvey]: + from app.models import Client, Contact + + client = Client.query.get(client_id) + if not client: + return None + + # Avoid duplicate open surveys for same trigger+entity + existing = ClientSurvey.query.filter_by( + client_id=client_id, + trigger=trigger, + project_id=project_id, + invoice_id=invoice_id, + responded_at=None, + ).first() + if existing and not existing.is_expired: + return existing + + survey = ClientSurvey( + client_id=client_id, + trigger=trigger, + project_id=project_id, + invoice_id=invoice_id, + ) + db.session.add(survey) + if not safe_commit("create_client_survey", {"client_id": client_id, "trigger": trigger}): + return None + + emails = [] + if recipient_email: + emails = [recipient_email] + else: + if client.email: + emails.append(client.email) + for contact in Contact.query.filter_by(client_id=client_id, is_active=True).limit(10).all(): + if contact.email and contact.email not in emails: + emails.append(contact.email) + + from app.utils.urls import get_app_base_url + + base = (get_app_base_url() or "").rstrip("/") + survey_url = f"{base}/client-portal/survey/{survey.token}" if base else f"/client-portal/survey/{survey.token}" + + for email in emails: + try: + send_template_email( + to=email, + subject="How did we do? Quick feedback", + template="email/client_survey.html", + client=client, + survey=survey, + survey_url=survey_url, + ) + except Exception as exc: + logger.error("Failed to send survey email to %s: %s", email, exc, exc_info=True) + + return survey + + def on_invoice_paid(self, invoice) -> Optional[ClientSurvey]: + if not invoice or not getattr(invoice, "client_id", None): + return None + return self.create_and_send( + client_id=invoice.client_id, + trigger="invoice_paid", + project_id=getattr(invoice, "project_id", None), + invoice_id=invoice.id, + ) + + def on_project_closed(self, project) -> Optional[ClientSurvey]: + if not project or not getattr(project, "client_id", None): + return None + return self.create_and_send( + client_id=project.client_id, + trigger="project_close", + project_id=project.id, + ) diff --git a/app/services/email_sync_service.py b/app/services/email_sync_service.py new file mode 100644 index 00000000..26e63901 --- /dev/null +++ b/app/services/email_sync_service.py @@ -0,0 +1,178 @@ +"""Match synced emails to CRM entities and persist threads.""" + +from __future__ import annotations + +import logging +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from app import db +from app.models import Client, Contact, Deal, Lead +from app.models.email_thread import EmailMessage, EmailThread +from app.utils.db import safe_commit + +logger = logging.getLogger(__name__) + +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") + + +def extract_emails(text: str) -> List[str]: + if not text: + return [] + return [m.lower() for m in EMAIL_RE.findall(text)] + + +class EmailSyncService: + """Ingest provider threads and link them to clients / leads / deals.""" + + def match_crm(self, addresses: List[str]) -> Tuple[Optional[int], Optional[int], Optional[int]]: + """Return (client_id, lead_id, deal_id) for the first matching address.""" + normalized = {a.lower().strip() for a in addresses if a} + if not normalized: + return None, None, None + + # Contacts → client + for contact in Contact.query.filter(Contact.email.isnot(None)).all(): + if contact.email and contact.email.lower() in normalized: + return contact.client_id, None, None + + for client in Client.query.filter(Client.email.isnot(None)).all(): + if client.email and client.email.lower() in normalized: + return client.id, None, None + + for lead in Lead.query.filter(Lead.email.isnot(None)).all(): + if lead.email and lead.email.lower() in normalized: + return getattr(lead, "client_id", None), lead.id, None + + for deal in Deal.query.all(): + # Deals may link via contact email on related client + if getattr(deal, "contact_email", None) and deal.contact_email.lower() in normalized: + return getattr(deal, "client_id", None), None, deal.id + + return None, None, None + + def ingest_threads(self, provider: str, threads: List[Dict[str, Any]]) -> Dict[str, int]: + created = updated = skipped = 0 + for raw in threads: + external_id = str(raw.get("id") or "") + if not external_id: + skipped += 1 + continue + participants = [] + for p in raw.get("participants") or []: + participants.extend(extract_emails(p) if "@" not in p else [p.lower()]) + for msg in raw.get("messages") or []: + participants.extend(extract_emails(msg.get("from") or "")) + for t in msg.get("to") or []: + participants.extend(extract_emails(t) if isinstance(t, str) else []) + + participants = sorted(set(a for a in participants if a)) + client_id, lead_id, deal_id = self.match_crm(participants) + + thread = EmailThread.query.filter_by(provider=provider, external_thread_id=external_id).first() + if not thread: + thread = EmailThread( + provider=provider, + external_thread_id=external_id, + subject=raw.get("subject"), + snippet=raw.get("snippet"), + participants=participants, + client_id=client_id, + lead_id=lead_id, + deal_id=deal_id, + ) + db.session.add(thread) + created += 1 + else: + thread.subject = raw.get("subject") or thread.subject + thread.snippet = raw.get("snippet") or thread.snippet + thread.participants = participants or thread.participants + if client_id: + thread.client_id = client_id + if lead_id: + thread.lead_id = lead_id + if deal_id: + thread.deal_id = deal_id + updated += 1 + + last_at = None + for msg in raw.get("messages") or []: + mid = str(msg.get("id") or "") + if not mid: + continue + existing = EmailMessage.query.filter_by(external_message_id=mid).first() + sent_at = msg.get("sent_at") + if isinstance(sent_at, str): + try: + sent_at = datetime.fromisoformat(sent_at.replace("Z", "+00:00")) + except Exception: + sent_at = None + if existing: + continue + # Need thread.id — flush first for new threads + db.session.flush() + em = EmailMessage( + thread_id=thread.id, + external_message_id=mid, + from_address=(extract_emails(msg.get("from") or "") or [msg.get("from") or ""])[0][:320], + to_addresses=msg.get("to") or [], + subject=msg.get("subject"), + body_text=msg.get("body_text") or msg.get("snippet"), + sent_at=sent_at, + ) + db.session.add(em) + if sent_at and (last_at is None or sent_at > last_at): + last_at = sent_at + + if last_at: + thread.last_message_at = last_at + elif thread.last_message_at is None: + thread.last_message_at = datetime.utcnow() + + if not safe_commit("email_sync_ingest", {"provider": provider, "created": created}): + db.session.rollback() + return {"created": 0, "updated": 0, "skipped": skipped, "error": "commit_failed"} + return {"created": created, "updated": updated, "skipped": skipped} + + def threads_for_client(self, client_id: int, limit: int = 20): + return ( + EmailThread.query.filter_by(client_id=client_id) + .order_by(EmailThread.last_message_at.desc()) + .limit(limit) + .all() + ) + + def threads_for_lead(self, lead_id: int, limit: int = 20): + return ( + EmailThread.query.filter_by(lead_id=lead_id) + .order_by(EmailThread.last_message_at.desc()) + .limit(limit) + .all() + ) + + def threads_for_deal(self, deal_id: int, limit: int = 20): + return ( + EmailThread.query.filter_by(deal_id=deal_id) + .order_by(EmailThread.last_message_at.desc()) + .limit(limit) + .all() + ) + + def sync_all_connected(self) -> Dict[str, Any]: + """Run sync for all active gmail / outlook_email integrations.""" + from app.models import Integration + from app.services.integration_service import IntegrationService + + results = [] + service = IntegrationService() + for provider in ("gmail", "outlook_email"): + for integration in Integration.query.filter_by(provider=provider, is_active=True).all(): + try: + connector = service.get_connector(integration) + if connector: + results.append({"integration_id": integration.id, "provider": provider, **connector.sync_data()}) + except Exception as exc: + logger.exception("Email sync failed for %s #%s", provider, integration.id) + results.append({"integration_id": integration.id, "provider": provider, "success": False, "error": str(exc)}) + return {"results": results} diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 33cf7c03..7ac98193 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -24,6 +24,63 @@ logger = logging.getLogger(__name__) +# Named provider presets. base_url is the host root; chat_path is appended for completions. +PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = { + "ollama": { + "base_url": "http://127.0.0.1:11434", + "requires_key": False, + "default_model": "llama3.1", + "suggested_models": ["llama3.1", "llama3.2", "mistral", "qwen2.5"], + "chat_path": "/v1/chat/completions", + }, + "openai": { + "base_url": "https://api.openai.com", + "requires_key": True, + "default_model": "gpt-4o-mini", + "suggested_models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"], + "chat_path": "/v1/chat/completions", + }, + "anthropic": { + "base_url": "https://api.anthropic.com", + "requires_key": True, + "default_model": "claude-sonnet-4-5", + "suggested_models": ["claude-opus-4-5", "claude-sonnet-4-5"], + "chat_path": "/v1/chat/completions", + }, + "gemini": { + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "requires_key": True, + "default_model": "gemini-2.0-flash", + "suggested_models": ["gemini-2.0-flash", "gemini-1.5-pro"], + "chat_path": "/chat/completions", + }, + "orcarouter": { + "base_url": "https://api.orcarouter.ai", + "requires_key": True, + "default_model": "auto", + "suggested_models": ["auto"], + "chat_path": "/v1/chat/completions", + }, + "openai_compatible": { + "base_url": "", + "requires_key": True, + "default_model": "", + "suggested_models": [], + "chat_path": "/v1/chat/completions", + }, + "custom": { + "base_url": "", + "requires_key": True, + "default_model": "", + "suggested_models": [], + "chat_path": "/v1/chat/completions", + }, +} + +NAMED_PROVIDERS = set(PROVIDER_PRESETS.keys()) +HOSTED_PROVIDERS = {name for name, preset in PROVIDER_PRESETS.items() if preset.get("requires_key")} +ROUTING_STRATEGIES = {"cost", "quality", "balanced"} + class AIServiceError(Exception): """User-facing AI service error with a stable code.""" @@ -46,12 +103,14 @@ class AIProviderConfig: timeout_seconds: int context_limit: int system_prompt: str + routing_strategy: str = "" @classmethod def from_settings(cls) -> "AIProviderConfig": # Runtime use: include decrypted API key if configured. config = Settings.get_settings().get_ai_config(include_secrets=True) - return cls(**config) + known = set(cls.__dataclass_fields__) + return cls(**{k: v for k, v in config.items() if k in known}) def public_dict(self) -> Dict[str, Any]: return { @@ -62,8 +121,20 @@ def public_dict(self) -> Dict[str, Any]: "api_key_set": self.api_key_set, "timeout_seconds": self.timeout_seconds, "context_limit": self.context_limit, + "routing_strategy": self.routing_strategy or None, } + def chat_completions_url(self) -> str: + preset = PROVIDER_PRESETS.get(self.provider) or PROVIDER_PRESETS["openai_compatible"] + path = preset.get("chat_path") or "/v1/chat/completions" + base = (self.base_url or "").rstrip("/") + if not base: + raise AIServiceError("AI helper is not fully configured.", "ai_not_configured", 400) + # Avoid doubling /v1 when the stored base URL already includes it. + if path.startswith("/v1/") and base.endswith("/v1"): + path = path[len("/v1") :] + return f"{base}{path}" + class LLMService: """Provider-neutral service for AI chat, context building, and confirmed actions.""" @@ -80,7 +151,8 @@ def ensure_enabled(self) -> None: raise AIServiceError("AI helper is disabled.", "ai_disabled", 503) if not self.config.base_url or not self.config.model: raise AIServiceError("AI helper is not fully configured.", "ai_not_configured", 400) - if self.config.provider in ("openai_compatible", "orcarouter") and not self.config.api_key: + provider = self.config.provider + if provider in HOSTED_PROVIDERS and not self.config.api_key: raise AIServiceError("Hosted AI provider requires an API key.", "ai_missing_api_key", 400) def test_connection(self) -> Dict[str, Any]: @@ -184,10 +256,15 @@ def confirm_action(self, user: User, action: Dict[str, Any]) -> Dict[str, Any]: raise AIServiceError("Unsupported AI action.", "unsupported_action", 400) def _chat_completion(self, messages: List[Dict[str, str]], max_tokens: int = 700) -> Dict[str, Any]: - url = f"{self.config.base_url.rstrip('/')}/v1/chat/completions" + url = self.config.chat_completions_url() headers = {"Accept": "application/json", "Content-Type": "application/json"} - if self.config.provider in ("openai_compatible", "orcarouter") and self.config.api_key: + if self.config.provider in HOSTED_PROVIDERS and self.config.api_key: headers["Authorization"] = f"Bearer {self.config.api_key}" + if self.config.provider == "orcarouter" and self.config.routing_strategy in ROUTING_STRATEGIES: + headers["x-routing-strategy"] = self.config.routing_strategy + if self.config.provider == "anthropic" and self.config.api_key: + # Anthropic OpenAI-compatible gateways often still want the Anthropic version header. + headers.setdefault("anthropic-version", "2023-06-01") payload = { "model": self.config.model, "messages": messages, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 366c2992..ff014b32 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -113,6 +113,17 @@ def create_payment( logger = logging.getLogger(__name__) logger.error(f"Failed to send client notification for payment {payment.id}: {e}", exc_info=True) + try: + from app.services.client_survey_service import ClientSurveyService + + ClientSurveyService().on_invoice_paid(invoice) + except Exception as e: + import logging + + logging.getLogger(__name__).error( + "Failed to send client survey for payment %s: %s", payment.id, e, exc_info=True + ) + return {"success": True, "message": "Payment created successfully", "payment": payment} def get_invoice_payments(self, invoice_id: int) -> List[Payment]: diff --git a/app/services/payroll_sync_service.py b/app/services/payroll_sync_service.py new file mode 100644 index 00000000..afc2cd8f --- /dev/null +++ b/app/services/payroll_sync_service.py @@ -0,0 +1,113 @@ +"""Build and push payroll batches to Gusto / ADP.""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, timedelta +from typing import Any, Dict, Optional + +from app import db +from app.models import TimeEntry, User +from app.models.payroll_sync_log import PayrollSyncLog +from app.utils.db import safe_commit + +logger = logging.getLogger(__name__) + + +class PayrollSyncService: + """Aggregate time entries into payroll batches and push via connectors.""" + + def build_batch(self, period_start: date, period_end: date) -> Dict[str, Any]: + entries = ( + TimeEntry.query.filter(TimeEntry.start_time >= datetime.combine(period_start, datetime.min.time())) + .filter(TimeEntry.start_time < datetime.combine(period_end + timedelta(days=1), datetime.min.time())) + .filter(TimeEntry.end_time.isnot(None)) + .all() + ) + by_user: Dict[int, float] = {} + for e in entries: + hours = 0.0 + if hasattr(e, "duration_hours") and e.duration_hours is not None: + hours = float(e.duration_hours) + elif e.start_time and e.end_time: + hours = (e.end_time - e.start_time).total_seconds() / 3600.0 + by_user[e.user_id] = by_user.get(e.user_id, 0.0) + hours + + employees = [] + for user_id, hours in by_user.items(): + user = User.query.get(user_id) + employees.append( + { + "user_id": user_id, + "employee_id": getattr(user, "employee_id", None) or str(user_id), + "email": getattr(user, "email", None), + "name": getattr(user, "username", None) or str(user_id), + "hours": round(hours, 2), + "job_worked": {"hours": round(hours, 2)}, + "workerID": str(getattr(user, "employee_id", None) or user_id), + "payInputs": [{"hoursQuantity": round(hours, 2)}], + } + ) + + return { + "period_start": period_start.isoformat(), + "period_end": period_end.isoformat(), + "employees": employees, + "employee_count": len(employees), + "hours_total": round(sum(by_user.values()), 2), + } + + def push_period( + self, + *, + provider: str, + integration, + connector, + period_start: Optional[date] = None, + period_end: Optional[date] = None, + created_by: Optional[int] = None, + ) -> Dict[str, Any]: + today = date.today() + if period_end is None: + period_end = today + if period_start is None: + period_start = period_end - timedelta(days=13) + + batch = self.build_batch(period_start, period_end) + log = PayrollSyncLog( + provider=provider, + integration_id=integration.id if integration else None, + period_start=period_start, + period_end=period_end, + status="pending", + employee_count=batch["employee_count"], + hours_total=batch["hours_total"], + payload_summary={"employees": [{"user_id": e["user_id"], "hours": e["hours"]} for e in batch["employees"]]}, + created_by=created_by, + ) + db.session.add(log) + safe_commit("payroll_sync_log_create", {"provider": provider}) + + try: + result = connector.push_payroll_batch(batch) + if result.get("success"): + log.status = "success" + log.external_batch_id = result.get("external_batch_id") + else: + log.status = "failed" + log.error_message = result.get("message") or "Push failed" + log.completed_at = datetime.utcnow() + safe_commit("payroll_sync_log_complete", {"id": log.id, "status": log.status}) + return { + "success": bool(result.get("success")), + "synced": batch["employee_count"] if result.get("success") else 0, + "log": log.to_dict(), + "message": result.get("message") or log.status, + } + except Exception as exc: + logger.exception("Payroll push failed") + log.status = "failed" + log.error_message = str(exc) + log.completed_at = datetime.utcnow() + safe_commit("payroll_sync_log_error", {"id": log.id}) + return {"success": False, "synced": 0, "message": str(exc), "log": log.to_dict()} diff --git a/app/static/support-ui.js b/app/static/support-ui.js index e20021ff..cea7e4e8 100644 --- a/app/static/support-ui.js +++ b/app/static/support-ui.js @@ -174,10 +174,93 @@ showSoftToast(cfg, cfg.layoutPrompt.message, cfg.layoutPrompt.variant || 'after_report', 'layout'); } + function showMilestoneCelebration(cfg, raw) { + if (document.getElementById('supportMilestoneCelebration')) return; + + var purchaseUrl = + (cfg && cfg.urls && cfg.urls.license) || + 'https://timetracker.drytrix.com/support.html'; + var bmcUrl = + 'https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=milestone_toast&utm_campaign=support'; + var title = raw.title || raw.message || 'Milestone reached!'; + var message = + raw.message || + 'Consider buying a key to remove prompts and support future development.'; + var keyLabel = raw.keyLabel || 'Get key (€25)'; + var bmcLabel = raw.bmcLabel || 'Buy a coffee'; + var dismissLabel = raw.dismissLabel || 'Maybe later'; + + var overlay = document.createElement('div'); + overlay.id = 'supportMilestoneCelebration'; + overlay.className = 'fixed inset-0 z-[110] flex items-center justify-center p-4'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'supportMilestoneTitle'); + overlay.innerHTML = + '
' + + '
' + + '
' + + '' + + '

' + + '

' + + '
' + + '
' + + '' + + '' + + '' + + '
'; + + overlay.querySelector('#supportMilestoneTitle').textContent = title; + overlay.querySelector('[data-milestone-msg]').textContent = message; + var keyBtn = overlay.querySelector('[data-milestone-key]'); + keyBtn.textContent = keyLabel; + var bmcBtn = overlay.querySelector('[data-milestone-bmc]'); + bmcBtn.innerHTML = ' ' + bmcLabel; + overlay.querySelectorAll('[data-milestone-dismiss]').forEach(function (el) { + if (el.tagName === 'BUTTON') el.textContent = dismissLabel; + el.addEventListener('click', function () { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }); + }); + keyBtn.addEventListener('click', function () { + postTrack(cfg, 'license_clicked', { source: 'milestone_toast', variant: 'hours_milestone' }); + if (typeof window.trackDonationClick === 'function') { + window.trackDonationClick('milestone_key'); + } + }); + bmcBtn.addEventListener('click', function () { + postTrack(cfg, 'donation_clicked', { source: 'milestone_toast', variant: 'bmc' }); + if (typeof window.trackDonationClick === 'function') { + window.trackDonationClick('milestone_bmc'); + } + }); + document.addEventListener('keydown', function onEsc(ev) { + if (ev.key === 'Escape' && overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onEsc); + } + }); + + document.body.appendChild(overlay); + postTrack(cfg, 'prompt_shown', { + variant: 'hours_milestone', + source: 'milestone_celebration', + milestone: raw.milestone + }); + } + function dashboardPrompt() { var cfg = parseSupportConfig(); var raw = window.__TT_DASHBOARD_SUPPORT_PROMPT; if (!cfg || !raw || !raw.message) return; + if (raw.variant === 'hours_milestone' || raw.celebration) { + showMilestoneCelebration(cfg, raw); + return; + } showSoftToast(cfg, raw.message, raw.variant || 'dashboard', raw.source || 'dashboard'); } diff --git a/app/templates/admin/client_surveys.html b/app/templates/admin/client_surveys.html new file mode 100644 index 00000000..6cd4984b --- /dev/null +++ b/app/templates/admin/client_surveys.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [{'text': _('Admin')}, {'text': _('Client feedback (NPS)')}] %} +{{ page_header(icon_class='fas fa-star-half-alt', title_text=_('Client feedback (NPS)'), subtitle_text=_('Survey responses from clients'), breadcrumbs=breadcrumbs) }} + +
+
+

{{ _('Responses') }}

+

{{ stats.responses }}

+
+
+

{{ _('Average score') }}

+

{% if stats.avg_score is not none %}{{ '%.1f'|format(stats.avg_score) }}{% else %}—{% endif %}

+
+
+

{{ _('NPS') }}

+

{% if stats.nps is not none %}{{ stats.nps }}{% else %}—{% endif %}

+
+
+ +
+ + + + + + + + + + + + {% for s in surveys %} + + + + + + + + {% else %} + + {% endfor %} + +
{{ _('Date') }}{{ _('Client') }}{{ _('Trigger') }}{{ _('Score') }}{{ _('Comment') }}
{{ s.responded_at or s.sent_at }}{{ s.client.name if s.client else s.client_id }}{{ s.trigger }}{% if s.nps_score is not none %}{{ s.nps_score }}{% else %}{{ _('Pending') }}{% endif %}{{ s.comment or '—' }}
{{ _('No surveys yet.') }}
+
+{% endblock %} diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 276ceda1..ac05f4eb 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -706,19 +706,41 @@

{{ _('AI Helper') }}

-

{{ _('For Ollama, this is the URL from the Flask server to Ollama. For OrcaRouter, use https://api.orcarouter.ai.') }}

+

{{ _('Auto-filled for named providers. Override for self-hosted OrcaRouter or custom gateways.') }}

+
+ {{ _('Advanced: manual base URL') }} +

{{ _('Leave blank to use the provider preset. For Ollama use http://127.0.0.1:11434; for OrcaRouter use https://api.orcarouter.ai.') }}

+
- + + + {% for m in ai_config.suggested_models or [] %} + + {% endfor %} + +
+
+ + +

{{ _('Sent as x-routing-strategy when using OrcaRouter.') }}

@@ -757,6 +779,13 @@

{{ _('AI Helper') }}

{{ _('Privacy & Analytics') }}

+
+ + +
+

+ {{ _('When enabled, clients with a custom domain set will be resolved by Host header. See nginx/CUSTOM_DOMAINS.md for TLS setup.') }} +

Minimal install telemetry (always on): Version, platform, and last-seen heartbeat so we can understand install footprint and distribution. No personal data.

@@ -1048,6 +1077,46 @@

{{ _('Up } }); } + + const aiProviderSelect = document.getElementById('ai_provider'); + const aiBaseUrlInput = document.getElementById('ai_base_url'); + const aiModelInput = document.getElementById('ai_model'); + const aiModelList = document.getElementById('ai_model_suggestions'); + const aiRoutingWrap = document.getElementById('ai_routing_strategy_wrap'); + const aiPresets = { + ollama: { base_url: 'http://127.0.0.1:11434', models: ['llama3.1', 'llama3.2', 'mistral', 'qwen2.5'], default_model: 'llama3.1' }, + openai: { base_url: 'https://api.openai.com', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'], default_model: 'gpt-4o-mini' }, + anthropic: { base_url: 'https://api.anthropic.com', models: ['claude-opus-4-5', 'claude-sonnet-4-5'], default_model: 'claude-sonnet-4-5' }, + gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', models: ['gemini-2.0-flash', 'gemini-1.5-pro'], default_model: 'gemini-2.0-flash' }, + orcarouter: { base_url: 'https://api.orcarouter.ai', models: ['auto'], default_model: 'auto' }, + openai_compatible: { base_url: '', models: [], default_model: '' } + }; + function applyAiProviderPreset(fillDefaults) { + if (!aiProviderSelect) return; + const preset = aiPresets[aiProviderSelect.value] || aiPresets.openai_compatible; + if (aiRoutingWrap) { + aiRoutingWrap.classList.toggle('hidden', aiProviderSelect.value !== 'orcarouter'); + } + if (aiModelList) { + aiModelList.innerHTML = ''; + (preset.models || []).forEach(function(m) { + const opt = document.createElement('option'); + opt.value = m; + aiModelList.appendChild(opt); + }); + } + if (!fillDefaults) return; + if (aiBaseUrlInput && preset.base_url) { + aiBaseUrlInput.value = preset.base_url; + } + if (aiModelInput && preset.default_model && (!aiModelInput.value || Object.values(aiPresets).some(function(p){ return (p.models||[]).includes(aiModelInput.value) || p.default_model === aiModelInput.value; }))) { + aiModelInput.value = preset.default_model; + } + } + if (aiProviderSelect) { + applyAiProviderPreset(false); + aiProviderSelect.addEventListener('change', function() { applyAiProviderPreset(true); }); + } }); {% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 2d28ad84..01eb5ee9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -72,19 +72,25 @@

{{ _('Become a Supporter or buy a coffee — every contribution helps keep TimeTracker free for everyone.') }}

+

+ {{ _('One-time key — pay once, never see this again.') }} +

- - {{ _('Become a Supporter') }} + {{ _('Remove prompts forever (€25)') }} - {{ _('Buy Me a Coffee') }} + class="px-3 py-1.5 bg-white hover:bg-amber-50 text-amber-600 text-sm font-semibold rounded-lg transition-colors border-2 border-amber-600 shadow-sm"> + {{ _('Buy me a coffee') }} {{ _('AI H if (banner) { banner.classList.add('opacity-0', 'invisible', 'max-h-0', 'overflow-hidden'); banner.classList.remove('opacity-100', 'visible', 'max-h-[100px]'); - // Store dismissal timestamp (show again after 30 days) + // Store dismissal timestamp (show again after 14 days) try { localStorage.setItem('supportBannerDismissed', Date.now().toString()); // Track dismissal @@ -1154,22 +1160,22 @@

{{ _('AI H function shouldShowSupportBanner() { try { - // Check if dismissed recently (30 days) + // Check if dismissed recently (14 days) const dismissed = localStorage.getItem('supportBannerDismissed'); if (dismissed) { const dismissedTime = parseInt(dismissed); - const thirtyDays = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds - if ((Date.now() - dismissedTime) < thirtyDays) { + const fourteenDays = 14 * 24 * 60 * 60 * 1000; + if ((Date.now() - dismissedTime) < fourteenDays) { return false; } } - // Check if user clicked donation link recently (30 days) + // Check if user clicked donation link recently (14 days) const lastClick = localStorage.getItem('donationLinkClicked'); if (lastClick) { const clickTime = parseInt(lastClick); - const thirtyDays = 30 * 24 * 60 * 60 * 1000; - if ((Date.now() - clickTime) < thirtyDays) { + const fourteenDays = 14 * 24 * 60 * 60 * 1000; + if ((Date.now() - clickTime) < fourteenDays) { return false; } } @@ -1179,34 +1185,60 @@

{{ _('AI H return true; // Show by default if localStorage fails } } + + function getSupportBannerSeenCount() { + try { + return parseInt(localStorage.getItem('supportBannerSeenCount') || '0', 10) || 0; + } catch (e) { + return 0; + } + } + + function incrementSupportBannerSeenCount() { + try { + const next = getSupportBannerSeenCount() + 1; + localStorage.setItem('supportBannerSeenCount', String(next)); + return next; + } catch (e) { + return 1; + } + } - function updateBannerMessage() { - // Get user stats from page if available + function updateBannerMessage(seenCount) { const bannerTitle = document.getElementById('bannerTitle'); const bannerMessage = document.getElementById('bannerMessage'); if (!bannerTitle || !bannerMessage) return; - - // Try to get user stats from data attributes or API + + const count = typeof seenCount === 'number' ? seenCount : getSupportBannerSeenCount(); const userStats = window.userStats || {}; const daysSinceSignup = userStats.days_since_signup || 0; const timeEntriesCount = userStats.time_entries_count || 0; const totalHours = userStats.total_hours || 0; - - // Smart messaging based on milestones + + // Escalating copy based on how often the banner has been shown + if (count >= 5) { + bannerTitle.textContent = '{{ _("Still here?") }}'; + bannerMessage.textContent = '{{ _("Buy a key — it removes all prompts permanently.") }}'; + return; + } + if (count >= 3) { + bannerTitle.textContent = {{ _("You've seen this %(n)s times.")|tojson }}.replace('%(n)s', String(count)); + bannerMessage.textContent = '{{ _("A one-time key removes it forever.") }}'; + return; + } + + // Smart messaging based on milestones (first 1–2 impressions) if (totalHours >= 100) { bannerTitle.textContent = '{{ _("Amazing! You\'ve tracked over 100 hours") }}'; - bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕'; } else if (timeEntriesCount >= 50) { bannerTitle.textContent = '{{ _("Great progress! You\'ve logged 50+ entries") }}'; - bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕'; } else if (daysSinceSignup >= 7) { bannerTitle.textContent = '{{ _("Thanks for using TimeTracker!") }}'; - bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕'; } else { bannerTitle.textContent = '{{ _("Enjoying TimeTracker?") }}'; - bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕'; } + bannerMessage.textContent = '{{ _("Support its development — or remove prompts forever with a key.") }}'; } // Show support banner if conditions are met @@ -1214,12 +1246,12 @@

{{ _('AI H (function() { const banner = document.getElementById('supportBanner'); if (!banner) return; - // Server-side suppression: don't show if user clicked a support CTA in last 30 days + // Server-side suppression: don't show if user clicked a support CTA recently if ({{ 'true' if support_banner_suppressed else 'false' }}) { return; } if (shouldShowSupportBanner()) { - // Update banner message based on user stats - updateBannerMessage(); + const seenCount = incrementSupportBannerSeenCount(); + updateBannerMessage(seenCount); // Reserve space immediately by removing height constraints // This prevents layout shift when banner becomes visible diff --git a/app/templates/client_portal/base.html b/app/templates/client_portal/base.html index 873e9f49..34c011cb 100644 --- a/app/templates/client_portal/base.html +++ b/app/templates/client_portal/base.html @@ -281,6 +281,9 @@

{{ _('Client Portal') {{ unread_notifications_count }} {% endif %} + + {{ _('Messages') }} +
+ +
+

+{% endblock %} + +{% block scripts_extra %} + +{% endblock %} diff --git a/app/templates/client_portal/survey.html b/app/templates/client_portal/survey.html new file mode 100644 index 00000000..80b3623c --- /dev/null +++ b/app/templates/client_portal/survey.html @@ -0,0 +1,62 @@ + + + + + + + {{ _('Feedback') }} — {{ app_name|default('TimeTracker') }} + + + + +
+
+

{{ _('How did we do?') }}

+

+ {% if client %}{{ client.name }}{% endif %} +

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% if survey.is_completed %} +

{{ _('Thanks — your feedback was recorded.') }}

+ {% if survey.nps_score is not none %} +

{{ _('Your score:') }} {{ survey.nps_score }}/10

+ {% endif %} + {% elif survey.is_expired %} +

{{ _('This feedback link has expired.') }}

+ {% else %} +
+ +
+ +
+ {% for i in range(0, 11) %} + + {% endfor %} +
+
+
+ + +
+ +
+ {% endif %} +
+
+ + diff --git a/app/templates/clients/edit.html b/app/templates/clients/edit.html index 12f44663..212a77d4 100644 --- a/app/templates/clients/edit.html +++ b/app/templates/clients/edit.html @@ -130,6 +130,13 @@

{{ _('Client Portal Access') }}

{{ _('Set a new password or leave empty to keep current') }}

+
+ + +

+ {{ _('Optional CNAME hostname for white-label portal access. Point DNS to this server, then issue a certificate (see nginx/CUSTOM_DOMAINS.md). Requires Admin → Settings → Allow portal custom domains.') }} +

+
{% if client.portal_enabled and client.portal_username %}
diff --git a/app/templates/clients/messages.html b/app/templates/clients/messages.html new file mode 100644 index 00000000..6b9467e8 --- /dev/null +++ b/app/templates/clients/messages.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ _('Messages') }} - {{ client.name }}{% endblock %} + +{% block content %} +{% set actions %} +{{ _('Back') }} +{% endset %} +{{ page_header('fas fa-comments', _('Messages'), subtitle_text=client.name, actions_html=actions, breadcrumbs=[{'text': _('Clients'), 'url': url_for('clients.list_clients')}, {'text': client.name, 'url': url_for('clients.view_client', client_id=client.id)}, {'text': _('Messages')}]) }} + +
+
+
+ {% for m in messages %} +
+
+
{{ m.sender_name or m.sender_type }} · {{ m.created_at.strftime('%Y-%m-%d %H:%M') if m.created_at else '' }}
+
{{ m.body }}
+
+
+ {% else %} +

{{ _('No messages yet. Start the conversation.') }}

+ {% endfor %} +
+
+ + + +
+
+
+{% endblock %} + +{% block scripts_extra %} + +{% endblock %} diff --git a/app/templates/clients/view.html b/app/templates/clients/view.html index 4f2fbc3d..648345ad 100644 --- a/app/templates/clients/view.html +++ b/app/templates/clients/view.html @@ -8,6 +8,7 @@ {% if current_user.is_admin or has_permission('edit_clients') %} {{ _('Edit Client') }} {% endif %} + {{ _('Messages') }} {% if can_invoice_unbilled_time|default(false) and unbilled_invoice_preview is defined and unbilled_invoice_preview %} {% set up = unbilled_invoice_preview %} {% set no_unbilled = (up.entry_count == 0) and (not up.blocked_reason) %} @@ -52,6 +53,8 @@ {% endset %} {{ page_header('fas fa-user', client.name, subtitle_text=_('Client details and associated projects.'), actions_html=actions, breadcrumbs=[{'text': _('Clients'), 'url': url_for('clients.list_clients')}, {'text': client.name}]) }} +{% include "partials/_email_threads.html" %} +
diff --git a/app/templates/components/support_modal.html b/app/templates/components/support_modal.html index 7ef20beb..fdc02463 100644 --- a/app/templates/components/support_modal.html +++ b/app/templates/components/support_modal.html @@ -37,6 +37,15 @@

{{ _('Support TimeTrack

{{ _('Trusted by teams and freelancers who want simple, reliable time tracking.') }}

{% if not is_license_activated %} + + {{ _('Buy Me a Coffee') }} + +

{{ _('Or choose an amount:') }}

{{ _('Donate') }} (€5) {{ _('Donate') }} (€10) @@ -46,7 +55,7 @@

{{ _('Support TimeTrack
{% if not is_license_activated %} - {{ _('Become a Supporter (€25)') }} + {{ _('Become a Supporter (€25 key — removes all prompts)') }} {% endif %} diff --git a/app/templates/deals/view.html b/app/templates/deals/view.html index 2d5ca618..ccdf26bb 100644 --- a/app/templates/deals/view.html +++ b/app/templates/deals/view.html @@ -25,6 +25,8 @@ actions_html=actions_html ) }} +{% include "partials/_email_threads.html" %} +
diff --git a/app/templates/email/client_survey.html b/app/templates/email/client_survey.html new file mode 100644 index 00000000..0746dd9f --- /dev/null +++ b/app/templates/email/client_survey.html @@ -0,0 +1,26 @@ + + + + + + How did we do? + + +
+

Quick feedback

+
+
+

Hi {{ client.name }},

+

We would love to know how we did. Please take 30 seconds to rate your experience (0 = not likely, 10 = extremely likely).

+ +
+
+

This is an automated message from TimeTracker.

+
+ + diff --git a/app/templates/integrations/wizard_adp.html b/app/templates/integrations/wizard_adp.html new file mode 100644 index 00000000..9b58b363 --- /dev/null +++ b/app/templates/integrations/wizard_adp.html @@ -0,0 +1,26 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: ADP OAuth') }}

+ {% if current_user.is_admin %} +
+
+ + +
+
+ + +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/integrations/wizard_datev.html b/app/templates/integrations/wizard_datev.html new file mode 100644 index 00000000..3910d60a --- /dev/null +++ b/app/templates/integrations/wizard_datev.html @@ -0,0 +1,31 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: DATEV firm data') }}

+
+
+ + +
+
+ + +
+
+
+ +{% endblock %} diff --git a/app/templates/integrations/wizard_gmail.html b/app/templates/integrations/wizard_gmail.html new file mode 100644 index 00000000..1d19717e --- /dev/null +++ b/app/templates/integrations/wizard_gmail.html @@ -0,0 +1,24 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: Google OAuth') }}

+ {% if current_user.is_admin %} +
+
+ + +
+
+ + +
+

{{ _('Enable the Gmail API and request gmail.readonly scope.') }}

+
+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/integrations/wizard_gusto.html b/app/templates/integrations/wizard_gusto.html new file mode 100644 index 00000000..b6fda02d --- /dev/null +++ b/app/templates/integrations/wizard_gusto.html @@ -0,0 +1,26 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: Gusto OAuth') }}

+ {% if current_user.is_admin %} +
+
+ + +
+
+ + +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/integrations/wizard_outlook_email.html b/app/templates/integrations/wizard_outlook_email.html new file mode 100644 index 00000000..9746e529 --- /dev/null +++ b/app/templates/integrations/wizard_outlook_email.html @@ -0,0 +1,24 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: Microsoft OAuth') }}

+ {% if current_user.is_admin %} +
+
+ + +
+
+ + +
+

{{ _('Requires Mail.Read and offline_access on your Azure app registration.') }}

+
+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/integrations/wizard_sage.html b/app/templates/integrations/wizard_sage.html new file mode 100644 index 00000000..61ebf3ad --- /dev/null +++ b/app/templates/integrations/wizard_sage.html @@ -0,0 +1,23 @@ +{% extends "integrations/wizard_base.html" %} + +{% block wizard_steps %} +
+

{{ _('Step 1: OAuth credentials') }}

+ {% if current_user.is_admin %} +
+
+ + +
+
+ + +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/inventory/purchase_orders/view.html b/app/templates/inventory/purchase_orders/view.html index 1b5824b0..06bb92aa 100644 --- a/app/templates/inventory/purchase_orders/view.html +++ b/app/templates/inventory/purchase_orders/view.html @@ -14,6 +14,7 @@ subtitle_text=purchase_order.supplier.name, breadcrumbs=breadcrumbs, actions_html=('Back' + + 'PDF' + ('Edit' if (purchase_order.status != 'received' and purchase_order.status != 'cancelled' and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + ('
' if (purchase_order.status == 'draft' and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + ('
' if (purchase_order.status not in ['received', 'cancelled'] and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + diff --git a/app/templates/invoices/create.html b/app/templates/invoices/create.html index f92d48bb..703c0f55 100644 --- a/app/templates/invoices/create.html +++ b/app/templates/invoices/create.html @@ -3,19 +3,19 @@ {% block content %} {% set actions %} -{{ _('Back to Invoices') }} + {% endset %} {{ page_header('fas fa-file-invoice', _('Create Invoice'), subtitle_text=_('Generate a new invoice for a project and client'), actions_html=actions, breadcrumbs=[{'text': _('Invoices'), 'url': url_for('invoices.list_invoices')}, {'text': _('Create Invoice')}]) }} -
-
-
+
+
+
-
-
+
+
- {% for project in projects %} @@ -23,46 +23,46 @@

{{ _('Selecting a project will auto-fill client details') }}

-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
+
- +
-
- +
-
+
+ +{# Sticky mobile action bar #} +
+
+ {{ _('Cancel') }} + +
+
{% endblock %} {% block scripts_extra %} diff --git a/app/templates/invoices/edit.html b/app/templates/invoices/edit.html index ef364f1a..f7b755af 100644 --- a/app/templates/invoices/edit.html +++ b/app/templates/invoices/edit.html @@ -3,36 +3,36 @@ {% block content %} {% set actions %} - + - {% endset %} {{ page_header('fas fa-file-invoice', _('Edit Invoice') ~ ' ' ~ invoice.invoice_number, subtitle_text=_('Update invoice details, items, and terms'), actions_html=actions, breadcrumbs=[{'text': _('Invoices'), 'url': url_for('invoices.list_invoices')}, {'text': _('Edit Invoice')}]) }} -
-
-
+
+
+
-
+
- +
- +
-
+
- +
- +
@@ -79,22 +79,25 @@

{% for item in invoice.items %} -
+
{% if item.time_entry_ids %} {# Time-based item: show Project and Task #}
+ {{ _('Project') }} {{ invoice.project.name if invoice.project else '-' }}
+ {{ _('Task') }} {{ item.task_name_from_time_entries or _('Project hours') }}
{% else %} {# Stock/manual item: show Stock and Warehouse dropdowns #}
- {% for stock_item in stock_items %} @@ -102,7 +105,8 @@

- {% for warehouse in warehouses %} @@ -111,21 +115,24 @@

{% endif %}
- + {{ _('Description') }} +
+ {{ _('Quantity') }} {% if item.time_entry_ids %} - + {% else %} - + {% endif %}
- + {{ _('Unit Price') }} +
-
@@ -288,14 +295,24 @@

-
+

+{# Sticky mobile action bar #} +
+
+ {{ _('Cancel') }} + + +
+
+

diff --git a/app/templates/leads/view.html b/app/templates/leads/view.html index bd20d749..ef77bffb 100644 --- a/app/templates/leads/view.html +++ b/app/templates/leads/view.html @@ -32,6 +32,8 @@ actions_html=actions_html ) }} +{% include "partials/_email_threads.html" %} +
diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index db297aa3..0587b641 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -982,6 +982,9 @@

{{ _('Re

{% if current_user.ui_show_donate and not is_license_activated %} + {% set _tracked_hours = (usage_support_stats.total_hours or 0)|float %} + {% set _hourly_rate = 30 %} + {% set _estimated_value = (_tracked_hours * _hourly_rate)|round(0, 'common')|int %}
@@ -990,7 +993,7 @@

{{ _('Re

{{ _('Enjoying TimeTracker?') }}

- {{ _('You have tracked %(hours)s hours', hours=('%.1f'|format((usage_support_stats.total_hours or 0)|float))) }} + {{ _('You have tracked %(hours)s hours', hours=('%.1f'|format(_tracked_hours))) }} · {{ _('You have created %(count)s entries', count=usage_support_stats.time_entries_count or 0) }} {% if (usage_support_stats.reports_generated_count or 0) > 0 %} · {{ _('Reports generated: %(n)s', n=usage_support_stats.reports_generated_count) }} @@ -1003,10 +1006,16 @@

{{ _('En {{ _('License') }}

{% else %} + {% if _tracked_hours >= 1 %} +

+ {{ _("You've tracked %(hours)s hours — that's roughly €%(value)s billed at €%(rate)s/h. TimeTracker helped you capture it all.", hours=('%.1f'|format(_tracked_hours)), value=_estimated_value, rate=_hourly_rate) }} +

+ {% else %}

{{ _('If this saves you time, consider supporting development — everything stays free and open.') }}

+ {% endif %}
- - {{ _('Buy License (€25)') }} + + {{ _('Get a key — remove prompts (€25)') }}
{% endif %}
diff --git a/app/templates/main/donate.html b/app/templates/main/donate.html index 03ffb73e..5b1f7c39 100644 --- a/app/templates/main/donate.html +++ b/app/templates/main/donate.html @@ -10,7 +10,7 @@ {{ _('Support Development') }} - +
@@ -18,6 +18,8 @@

{{ _('Support TimeTracker Develo

{% if (support_ab_variant|default('control')) == 'cta_alt' %} {{ _('Donate to support development — or become a Supporter to show your badge') }} + {% elif (support_ab_variant|default('control')) == 'never_see' %} + {{ _('Never see support prompts again — or donate any amount') }} {% else %} {{ _('Support updates and keep TimeTracker free for everyone') }} {% endif %} @@ -34,6 +36,17 @@

{{ _('Support TimeTracker Develo {{ _('Donate') }} + {% elif (support_ab_variant|default('control')) == 'never_see' %} + + {{ _('Never see this again — €25 key') }} + + + + {{ _('Or donate any amount') }} + + {% else %} +

{{ _('Synced Emails') }}

+
    + {% for t in email_threads %} +
  • +
    +
    +
    {{ t.subject or _('(no subject)') }}
    +
    {{ t.snippet or '' }}
    +
    + {{ t.provider }} + {% if t.last_message_at %} · {{ t.last_message_at.strftime('%Y-%m-%d %H:%M') }}{% endif %} + {% if t.participants %} · {{ t.participants|join(', ') }}{% endif %} +
    +
    + {{ t.messages.count() if t.messages else 0 }} {{ _('msgs') }} +
    +
  • + {% endfor %} +
+

+{% endif %} diff --git a/app/templates/partials/_sidebar.html b/app/templates/partials/_sidebar.html index 7e4069be..3c0795f5 100644 --- a/app/templates/partials/_sidebar.html +++ b/app/templates/partials/_sidebar.html @@ -10,7 +10,7 @@

+ +
+ + +

{{ _('When this good is invoiced and the invoice is sent/paid, linked stock is depleted.') }}

+
diff --git a/app/templates/projects/edit_good.html b/app/templates/projects/edit_good.html index 4203d436..c7d12002 100644 --- a/app/templates/projects/edit_good.html +++ b/app/templates/projects/edit_good.html @@ -40,6 +40,16 @@

{{ _('Edit Extra Good') }}

+ +
+ + +
diff --git a/app/templates/timer/focus.html b/app/templates/timer/focus.html new file mode 100644 index 00000000..d8f9adaa --- /dev/null +++ b/app/templates/timer/focus.html @@ -0,0 +1,317 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Time Tracking')}, + {'text': _('Focus')} +] %} + +{{ page_header( + icon_class='fas fa-bullseye', + title_text=_('Focus / Pomodoro'), + subtitle_text=_('Work in focused cycles with short breaks'), + breadcrumbs=breadcrumbs +) }} + + +{% endblock %} + +{% block scripts_extra %} + +{% endblock %} diff --git a/app/templates/workflows/list.html b/app/templates/workflows/list.html index de72ca03..324cac4e 100644 --- a/app/templates/workflows/list.html +++ b/app/templates/workflows/list.html @@ -71,6 +71,9 @@ {% endif %} + + + diff --git a/app/templates/workflows/visual_builder.html b/app/templates/workflows/visual_builder.html new file mode 100644 index 00000000..4bfda004 --- /dev/null +++ b/app/templates/workflows/visual_builder.html @@ -0,0 +1,294 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ _('Visual Builder') }} - {{ workflow.name }}{% endblock %} + +{% block content %} +{% set actions %} +{{ _('Form Editor') }} +{{ _('View') }} + +{% endset %} +{{ page_header( + icon_class='fas fa-project-diagram', + title_text=_('Visual Builder'), + subtitle_text=workflow.name, + breadcrumbs=[{'text': _('Workflows'), 'url': url_for('workflows.list_workflows')}, {'text': workflow.name, 'url': url_for('workflows.view_workflow', workflow_id=workflow.id)}, {'text': _('Builder')}], + actions_html=actions +) }} + +
+ + +
+
+ +
+

+ {{ _('Click a trigger, then add conditions and actions. Drag nodes to rearrange.') }} +

+
+

+
+
+{% endblock %} + +{% block scripts_extra %} + +{% endblock %} diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index f7805a4c..348490d3 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -70,6 +70,65 @@

{{ _('Exports') }}

+{% if current_user.is_admin %} +
+
+
+

{{ _('Payroll connectors') }}

+

{{ _('Push hours for this period to Gusto or ADP') }}

+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ + {{ _('Configure') }} +
+ {% if payroll_sync_logs %} +
+ + + + + + + + + + + + + {% for log in payroll_sync_logs %} + + + + + + + + + {% endfor %} + +
{{ _('When') }}{{ _('Provider') }}{{ _('Period') }}{{ _('Status') }}{{ _('Employees') }}{{ _('Hours') }}
{{ log.created_at.strftime('%Y-%m-%d %H:%M') if log.created_at else '' }}{{ log.provider }}{{ log.period_start }} – {{ log.period_end }}{{ log.status }}{{ log.employee_count }}{{ '%.2f'|format(log.hours_total or 0) }}
+
+ {% endif %} +
+{% endif %} +

{{ _('Timesheet Periods') }}

diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index aa830eb9..a349c721 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -179,8 +179,9 @@ def inject_globals(): user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id) support_banner_suppressed = DonationInteraction.has_recent_donation_click(current_user.id, days=30) - # Stable A/B variant per user for support CTA experiments (control | key_first | cta_alt) - support_ab_variant = ("control", "key_first", "cta_alt")[current_user.id % 3] + # Stable A/B variant per user for support CTA experiments + # (control | key_first | cta_alt | never_see) + support_ab_variant = ("control", "key_first", "cta_alt", "never_see")[current_user.id % 4] except Exception: user_stats = {} support_banner_suppressed = False diff --git a/app/utils/datev_export.py b/app/utils/datev_export.py new file mode 100644 index 00000000..3a75b901 --- /dev/null +++ b/app/utils/datev_export.py @@ -0,0 +1,80 @@ +"""DATEV EXTF Buchungsstapel CSV generator. + +Produces a DATEV-compatible ASCII/CSV export (format header + booking lines) +suitable for import into DATEV Unternehmen online / Rechnungswesen. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Iterable, List, Optional + + +def _money(value) -> str: + try: + d = Decimal(str(value or 0)).quantize(Decimal("0.01")) + except Exception: + d = Decimal("0.00") + # DATEV uses comma as decimal separator + return f"{d:.2f}".replace(".", ",") + + +def _safe(text: Optional[str], max_len: int = 60) -> str: + if not text: + return "" + # Strip characters that break CSV; DATEV uses semicolon delimiter + cleaned = str(text).replace(";", ",").replace("\n", " ").replace("\r", " ").strip() + return cleaned[:max_len] + + +def build_datev_buchungsstapel( + invoices: Iterable, + *, + consultant_number: str = "00000", + client_number: str = "00000", + account_revenue: str = "8400", + account_receivable: str = "10000", + fiscal_year_start: Optional[datetime] = None, +) -> str: + """ + Build EXTF CSV content for DATEV Buchungsstapel. + + Each invoice becomes one booking line (Soll = receivables, Haben = revenue). + """ + now = datetime.utcnow() + fy_start = fiscal_year_start or datetime(now.year, 1, 1) + # Header line (DATEV EXTF format descriptor — simplified) + header = ( + f'"EXTF";700;21;"Buchungsstapel";7;' + f"{now.strftime('%Y%m%d%H%M%S')};" + f"{fy_start.strftime('%Y%m%d')};" + f'4;"{_safe(consultant_number, 7)}";"{_safe(client_number, 5)}";' + f'"";"";"TimeTracker";1;0\n' + ) + # Column headers (subset of DATEV fields) + columns = ( + "Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;" + "Kurs;Basis-Umsatz;WKZ Basis-Umsatz;Konto;Gegenkonto (ohne BU-Schlüssel);" + "BU-Schlüssel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;" + "Buchungstext;Postensperre;Diverse Adressnummer;Geschäftspartnerbank;" + "Sachverhalt;Zinssperre;Beleglink\n" + ) + + lines: List[str] = [header, columns] + for inv in invoices: + amount = getattr(inv, "total_amount", None) or 0 + issue = getattr(inv, "issue_date", None) or now.date() + belegdatum = issue.strftime("%d%m") + belegfeld1 = _safe(getattr(inv, "invoice_number", "") or str(getattr(inv, "id", "")), 36) + text = _safe(getattr(inv, "client_name", None) or "Kunde", 60) + # H = credit on revenue (Haben), debit implied on receivables via Gegenkonto + line = ( + f"{_money(amount)};H;EUR;;;;" + f"{account_revenue};{account_receivable};;" + f"{belegdatum};{belegfeld1};;;" + f"{text};;;;;;\n" + ) + lines.append(line) + + return "".join(lines) diff --git a/app/utils/portal_domain.py b/app/utils/portal_domain.py new file mode 100644 index 00000000..5271ffee --- /dev/null +++ b/app/utils/portal_domain.py @@ -0,0 +1,46 @@ +"""Resolve white-label client portal hosts to Client records.""" + +from __future__ import annotations + +from typing import Optional + +from flask import g, request + + +def normalize_host(host: Optional[str] = None) -> str: + """Return a lowercase hostname without port.""" + raw = host if host is not None else (request.host or "") + return (raw or "").split(":")[0].strip().lower() + + +def resolve_portal_client_for_host(host: Optional[str] = None): + """ + Look up an active client by custom_domain when portal custom domains are enabled. + + Returns the Client or None. Does not require an authenticated portal session. + """ + try: + from app.models import Client, Settings + + settings = Settings.get_settings() + if not getattr(settings, "portal_allowed_custom_domains", None): + return None + + hostname = normalize_host(host) + if not hostname: + return None + + client = Client.query.filter(Client.custom_domain == hostname).first() + if not client or not client.is_active or not client.has_portal_access: + return None + return client + except Exception: + return None + + +def bind_portal_client_to_g(): + """Store the resolved custom-domain client on flask.g as portal_client.""" + g.portal_client = resolve_portal_client_for_host() + if g.portal_client is not None: + g.portal_domain_client = g.portal_client + return g.portal_client diff --git a/app/utils/purchase_order_pdf.py b/app/utils/purchase_order_pdf.py new file mode 100644 index 00000000..4cb282d1 --- /dev/null +++ b/app/utils/purchase_order_pdf.py @@ -0,0 +1,217 @@ +"""Purchase Order PDF generation using ReportLab.""" + +from io import BytesIO + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT, TA_RIGHT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from app.models import Settings + +BRAND_COLOR = colors.HexColor("#1e3a5f") +HEADER_BG = colors.HexColor("#1e3a5f") +HEADER_FG = colors.HexColor("#ffffff") +ROW_ALT_BG = colors.HexColor("#f0f4f8") +GRID_LIGHT = colors.HexColor("#dde3ea") +MUTED_TEXT = colors.HexColor("#64748b") + + +class PurchaseOrderPDFGenerator: + """Generate a printable purchase order PDF.""" + + def __init__(self, purchase_order, settings=None): + self.purchase_order = purchase_order + self.settings = settings or Settings.get_settings() + self.styles = getSampleStyleSheet() + self._setup_styles() + + def _setup_styles(self): + self.styles.add( + ParagraphStyle( + name="POTitle", + parent=self.styles["Heading1"], + fontSize=20, + textColor=BRAND_COLOR, + spaceAfter=6, + ) + ) + self.styles.add( + ParagraphStyle( + name="POSection", + parent=self.styles["Heading2"], + fontSize=12, + textColor=BRAND_COLOR, + spaceBefore=12, + spaceAfter=6, + ) + ) + self.styles.add( + ParagraphStyle( + name="POBody", + parent=self.styles["Normal"], + fontSize=10, + spaceAfter=4, + ) + ) + self.styles.add( + ParagraphStyle( + name="POMuted", + parent=self.styles["Normal"], + fontSize=9, + textColor=MUTED_TEXT, + ) + ) + self.styles.add( + ParagraphStyle( + name="PORight", + parent=self.styles["Normal"], + fontSize=10, + alignment=TA_RIGHT, + ) + ) + + def generate_pdf(self): + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=1.5 * cm, + leftMargin=1.5 * cm, + topMargin=1.5 * cm, + bottomMargin=1.5 * cm, + ) + doc.build(self._build_story(), onFirstPage=self._footer, onLaterPages=self._footer) + return buffer.getvalue() + + def _footer(self, canvas, doc): + canvas.saveState() + canvas.setFont("Helvetica", 7) + canvas.setFillColor(MUTED_TEXT) + canvas.drawRightString(doc.pagesize[0] - 1.5 * cm, 0.8 * cm, f"Page {canvas.getPageNumber()}") + canvas.restoreState() + + def _escape(self, value): + text = "" if value is None else str(value) + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + def _build_story(self): + po = self.purchase_order + settings = self.settings + story = [] + + company = getattr(settings, "company_name", None) or "TimeTracker" + story.append(Paragraph(self._escape(company), self.styles["POTitle"])) + story.append(Paragraph(f"Purchase Order {self._escape(po.po_number)}", self.styles["POSection"])) + story.append(Spacer(1, 0.3 * cm)) + + supplier = po.supplier + supplier_lines = [supplier.name] if supplier else ["—"] + if supplier: + for attr in ("contact_name", "email", "phone", "address", "city", "country"): + val = getattr(supplier, attr, None) + if val: + supplier_lines.append(str(val)) + + company_lines = [company] + for attr in ("company_address", "company_city", "company_country", "company_email", "company_phone"): + val = getattr(settings, attr, None) + if val: + company_lines.append(str(val)) + + meta = [ + ["Order date", po.order_date.isoformat() if po.order_date else "—"], + ["Expected delivery", po.expected_delivery_date.isoformat() if po.expected_delivery_date else "—"], + ["Status", (po.status or "").title()], + ["Currency", po.currency_code or "EUR"], + ] + + header_data = [ + [ + Paragraph("
".join(self._escape(l) for l in company_lines), self.styles["POBody"]), + Paragraph("
".join(self._escape(l) for l in supplier_lines), self.styles["POBody"]), + Paragraph( + "
".join(f"{self._escape(k)}: {self._escape(v)}" for k, v in meta), + self.styles["POBody"], + ), + ] + ] + header_table = Table(header_data, colWidths=[6 * cm, 6 * cm, 5.5 * cm]) + header_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + story.append(header_table) + story.append(Spacer(1, 0.5 * cm)) + story.append(Paragraph("Line items", self.styles["POSection"])) + + rows = [["#", "Description", "Qty", "Unit cost", "Line total"]] + items = list(po.items) if not hasattr(po.items, "all") else po.items.all() + for idx, item in enumerate(items, start=1): + qty = float(item.quantity_ordered or 0) + unit = float(item.unit_cost or 0) + line_total = float(getattr(item, "line_total", None) or (qty * unit)) + rows.append( + [ + str(idx), + Paragraph(self._escape(item.description or ""), self.styles["POBody"]), + f"{qty:.2f}", + f"{unit:.2f}", + f"{line_total:.2f}", + ] + ) + + items_table = Table(rows, colWidths=[1.2 * cm, 9.5 * cm, 2 * cm, 2.5 * cm, 2.5 * cm]) + style_cmds = [ + ("BACKGROUND", (0, 0), (-1, 0), HEADER_BG), + ("TEXTCOLOR", (0, 0), (-1, 0), HEADER_FG), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("ALIGN", (2, 0), (-1, -1), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 0.4, GRID_LIGHT), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ] + for i in range(1, len(rows)): + if i % 2 == 0: + style_cmds.append(("BACKGROUND", (0, i), (-1, i), ROW_ALT_BG)) + items_table.setStyle(TableStyle(style_cmds)) + story.append(items_table) + story.append(Spacer(1, 0.4 * cm)) + + totals = [ + ["Subtotal", f"{float(po.subtotal or 0):.2f} {po.currency_code or ''}"], + ["Tax", f"{float(po.tax_amount or 0):.2f} {po.currency_code or ''}"], + ["Shipping", f"{float(po.shipping_cost or 0):.2f} {po.currency_code or ''}"], + ["Total", f"{float(po.total_amount or 0):.2f} {po.currency_code or ''}"], + ] + totals_table = Table(totals, colWidths=[4 * cm, 4 * cm], hAlign="RIGHT") + totals_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "RIGHT"), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), + ("TEXTCOLOR", (0, -1), (-1, -1), BRAND_COLOR), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ] + ) + ) + story.append(totals_table) + + if po.notes: + story.append(Spacer(1, 0.5 * cm)) + story.append(Paragraph("Notes", self.styles["POSection"])) + story.append(Paragraph(self._escape(po.notes), self.styles["POBody"])) + + return story diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index ee33d1bf..5bbb1d18 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -883,6 +883,27 @@ def sync_google_calendar_for_all_users_with_app(): ) logger.info("Registered Google Calendar connector sync task") + def sync_email_threads_with_app(): + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for email thread sync") + return + with external_url_context(app_instance): + sync_email_threads() + + scheduler.add_job( + func=sync_email_threads_with_app, + trigger="interval", + minutes=30, + id="sync_email_threads", + name="Sync Gmail / Outlook CRM email threads", + replace_existing=True, + ) + logger.info("Registered CRM email thread sync task") + # Slack daily summary dispatcher — runs every 30 minutes, checks each # active Slack integration whose daily_summary_time matches the window. def post_slack_daily_summaries_with_app(): @@ -1851,3 +1872,16 @@ def post_slack_daily_summaries(): ) logger.info("Slack daily summary dispatcher: posted=%d", posted) return {"ok": True, "posted": posted} + + +def sync_email_threads(): + """Poll Gmail / Outlook email integrations and ingest CRM threads.""" + try: + from app.services.email_sync_service import EmailSyncService + + result = EmailSyncService().sync_all_connected() + logger.info("Email thread sync finished: %s", result) + return result + except Exception as exc: + logger.exception("Email thread sync failed: %s", exc) + return {"ok": False, "error": str(exc)} diff --git a/docs/admin/SUPPORT_CONVERSION_AB_TESTS.md b/docs/admin/SUPPORT_CONVERSION_AB_TESTS.md index 26a2e6c1..ebf178ab 100644 --- a/docs/admin/SUPPORT_CONVERSION_AB_TESTS.md +++ b/docs/admin/SUPPORT_CONVERSION_AB_TESTS.md @@ -1,12 +1,13 @@ # Support / Donate conversion A/B tests -TimeTracker supports in-product A/B tests for support and key-purchase CTAs. Variants are assigned stably per user (by `user_id % 3`) and recorded with every support interaction so you can compare conversion by variant. +TimeTracker supports in-product A/B tests for support and key-purchase CTAs. Variants are assigned stably per user (by `user_id % 4`) and recorded with every support interaction so you can compare conversion by variant. ## Variants - **control** — Default: donate-first hero, “Support updates” copy. - **key_first** — Hero shows “Remove prompts with key” as primary button, then Donate. - **cta_alt** — Alternate CTA copy: “Donate to support development — or get a key to remove prompts”. +- **never_see** — Hero primary CTA: “Never see this again — €25 key”; secondary: “Or donate any amount” (Buy Me a Coffee). ## Data @@ -45,7 +46,7 @@ GROUP BY variant; ## Rolling out a winner 1. Run the experiment for at least 2–4 weeks and compare CTR (and, if available, key purchases) by variant. -2. In `app/utils/context_processors.py`, set `support_ab_variant` to the winning variant for everyone, e.g. `support_ab_variant = "key_first"` (remove the `current_user.id % 3` logic). +2. In `app/utils/context_processors.py`, set `support_ab_variant` to the winning variant for everyone, e.g. `support_ab_variant = "never_see"` (remove the `current_user.id % 4` logic). 3. Optionally remove variant-specific template branches in `app/templates/main/donate.html` and keep only the winning layout/copy. 4. Keep the `variant` column and tracking for future experiments. diff --git a/migrations/versions/192_add_ai_routing_strategy.py b/migrations/versions/192_add_ai_routing_strategy.py new file mode 100644 index 00000000..0262e116 --- /dev/null +++ b/migrations/versions/192_add_ai_routing_strategy.py @@ -0,0 +1,39 @@ +"""Add AI routing strategy setting for OrcaRouter. + +Revision ID: 192_add_ai_routing_strategy +Revises: 191_add_geofences +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "192_add_ai_routing_strategy" +down_revision = "191_add_geofences" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + if not _has_column(inspector, "settings", "ai_routing_strategy"): + op.add_column("settings", sa.Column("ai_routing_strategy", sa.String(length=20), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + if _has_column(inspector, "settings", "ai_routing_strategy"): + op.drop_column("settings", "ai_routing_strategy") diff --git a/migrations/versions/193_client_surveys_and_custom_domain.py b/migrations/versions/193_client_surveys_and_custom_domain.py new file mode 100644 index 00000000..d3cc34e2 --- /dev/null +++ b/migrations/versions/193_client_surveys_and_custom_domain.py @@ -0,0 +1,73 @@ +"""Add client surveys and portal custom domain. + +Revision ID: 193_client_surveys_and_custom_domain +Revises: 192_add_ai_routing_strategy +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "193_client_surveys_and_custom_domain" +down_revision = "192_add_ai_routing_strategy" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + if "clients" in tables and not _has_column(inspector, "clients", "custom_domain"): + op.add_column("clients", sa.Column("custom_domain", sa.String(length=255), nullable=True)) + op.create_index("ix_clients_custom_domain", "clients", ["custom_domain"], unique=True) + + if "settings" in tables and not _has_column(inspector, "settings", "portal_allowed_custom_domains"): + op.add_column("settings", sa.Column("portal_allowed_custom_domains", sa.Boolean(), nullable=True)) + + if "client_surveys" not in tables: + op.create_table( + "client_surveys", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True), + sa.Column("invoice_id", sa.Integer(), sa.ForeignKey("invoices.id", ondelete="SET NULL"), nullable=True), + sa.Column("trigger", sa.String(length=40), nullable=False), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("nps_score", sa.Integer(), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("responded_at", sa.DateTime(), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_client_surveys_client_id", "client_surveys", ["client_id"]) + op.create_index("ix_client_surveys_project_id", "client_surveys", ["project_id"]) + op.create_index("ix_client_surveys_invoice_id", "client_surveys", ["invoice_id"]) + op.create_index("ix_client_surveys_trigger", "client_surveys", ["trigger"]) + op.create_index("ix_client_surveys_token", "client_surveys", ["token"], unique=True) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + if "client_surveys" in tables: + op.drop_table("client_surveys") + + if "settings" in tables and _has_column(inspector, "settings", "portal_allowed_custom_domains"): + op.drop_column("settings", "portal_allowed_custom_domains") + + if "clients" in tables and _has_column(inspector, "clients", "custom_domain"): + op.drop_index("ix_clients_custom_domain", table_name="clients") + op.drop_column("clients", "custom_domain") diff --git a/migrations/versions/194_roadmap_features.py b/migrations/versions/194_roadmap_features.py new file mode 100644 index 00000000..34bee4c9 --- /dev/null +++ b/migrations/versions/194_roadmap_features.py @@ -0,0 +1,124 @@ +"""Add client messages, email threads, payroll sync logs, and portal API tokens. + +Revision ID: 194_roadmap_features +Revises: 193_client_surveys_and_custom_domain +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "194_roadmap_features" +down_revision = "193_client_surveys_and_custom_domain" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + if "client_messages" not in tables: + op.create_table( + "client_messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("sender_type", sa.String(length=20), nullable=False), + sa.Column("sender_id", sa.Integer(), nullable=True), + sa.Column("sender_name", sa.String(length=200), nullable=True), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("attachments", sa.JSON(), nullable=True), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_client_messages_client_id", "client_messages", ["client_id"]) + op.create_index("ix_client_messages_client_created", "client_messages", ["client_id", "created_at"]) + + if "email_threads" not in tables: + op.create_table( + "email_threads", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("provider", sa.String(length=40), nullable=False), + sa.Column("external_thread_id", sa.String(length=255), nullable=False), + sa.Column("subject", sa.String(length=500), nullable=True), + sa.Column("snippet", sa.Text(), nullable=True), + sa.Column("participants", sa.JSON(), nullable=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True), + sa.Column("lead_id", sa.Integer(), sa.ForeignKey("leads.id", ondelete="SET NULL"), nullable=True), + sa.Column("deal_id", sa.Integer(), sa.ForeignKey("deals.id", ondelete="SET NULL"), nullable=True), + sa.Column("last_message_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_email_threads_external_thread_id", "email_threads", ["external_thread_id"]) + op.create_index("ix_email_threads_client_id", "email_threads", ["client_id"]) + op.create_index("ix_email_threads_lead_id", "email_threads", ["lead_id"]) + op.create_index("ix_email_threads_deal_id", "email_threads", ["deal_id"]) + + if "email_messages" not in tables: + op.create_table( + "email_messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("thread_id", sa.Integer(), sa.ForeignKey("email_threads.id", ondelete="CASCADE"), nullable=False), + sa.Column("external_message_id", sa.String(length=255), nullable=False), + sa.Column("from_address", sa.String(length=320), nullable=True), + sa.Column("to_addresses", sa.JSON(), nullable=True), + sa.Column("subject", sa.String(length=500), nullable=True), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("body_html", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_email_messages_thread_id", "email_messages", ["thread_id"]) + op.create_index("ix_email_messages_external_message_id", "email_messages", ["external_message_id"]) + + if "payroll_sync_logs" not in tables: + op.create_table( + "payroll_sync_logs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("provider", sa.String(length=40), nullable=False), + sa.Column("integration_id", sa.Integer(), sa.ForeignKey("integrations.id", ondelete="SET NULL"), nullable=True), + sa.Column("period_start", sa.Date(), nullable=False), + sa.Column("period_end", sa.Date(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False, server_default="pending"), + sa.Column("employee_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("hours_total", sa.Float(), nullable=False, server_default="0"), + sa.Column("external_batch_id", sa.String(length=255), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("payload_summary", sa.JSON(), nullable=True), + sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("completed_at", sa.DateTime(), nullable=True), + ) + + if "api_tokens" in tables and not _has_column(inspector, "api_tokens", "client_id"): + op.add_column("api_tokens", sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=True)) + op.create_index("ix_api_tokens_client_id", "api_tokens", ["client_id"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + if "api_tokens" in tables and _has_column(inspector, "api_tokens", "client_id"): + op.drop_index("ix_api_tokens_client_id", table_name="api_tokens") + op.drop_column("api_tokens", "client_id") + + if "payroll_sync_logs" in tables: + op.drop_table("payroll_sync_logs") + if "email_messages" in tables: + op.drop_table("email_messages") + if "email_threads" in tables: + op.drop_table("email_threads") + if "client_messages" in tables: + op.drop_table("client_messages") diff --git a/nginx/CUSTOM_DOMAINS.md b/nginx/CUSTOM_DOMAINS.md new file mode 100644 index 00000000..8cfd8272 --- /dev/null +++ b/nginx/CUSTOM_DOMAINS.md @@ -0,0 +1,58 @@ +# Portal custom domains (white-label client portal) + +TimeTracker can serve the client portal on a per-client hostname +(e.g. `portal.acme.com`) when: + +1. **Admin → Settings → Allow portal custom domains** is enabled. +2. The client has **Custom portal domain** set (Clients → Edit). +3. DNS for that hostname points at this TimeTracker host. +4. TLS is terminated (recommended) via a certificate covering the hostname. + +## DNS + +Create a CNAME (or A record) from the client hostname to your TimeTracker +public hostname, for example: + +```text +portal.acme.com. CNAME timetracker.example.com. +``` + +## TLS with certbot (webroot) + +Example using the existing nginx HTTP server and webroot challenge: + +```bash +certbot certonly --webroot -w /var/www/certbot \ + -d portal.acme.com +``` + +Then add a server block (or reuse a wildcard cert) that proxies to the +TimeTracker upstream, similar to `https.conf`. + +## Example nginx server block + +```nginx +server { + listen 443 ssl http2; + server_name portal.acme.com; + + ssl_certificate /etc/letsencrypt/live/portal.acme.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/portal.acme.com/privkey.pem; + + location / { + proxy_pass http://timetracker_upstream; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +`Host` must be forwarded so TimeTracker can resolve the client by +`custom_domain`. + +## Wildcard option + +If you control a parent domain (e.g. `*.portal.yourcompany.com`), issue a +wildcard certificate once and map each client's `custom_domain` under that +zone. That avoids per-client certbot runs. diff --git a/setup.py b/setup.py index 72ff5fd2..5684adc2 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.15.0', + version='5.16.0', packages=find_packages(), include_package_data=True, package_data={