From bd60c58a36cd9b7315d46d8c22aff3d903cc2795 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 05:54:53 +0200 Subject: [PATCH 01/11] feat(support): add milestone celebration modal and clearer key CTA copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a dedicated celebration dialog for support milestones and tighten donate/support prompts so the €25 license key path is more obvious. --- app/models/donation_interaction.py | 2 +- app/routes/main.py | 9 ++- app/static/support-ui.js | 83 +++++++++++++++++++++ app/templates/base.html | 80 ++++++++++++++------ app/templates/components/support_modal.html | 11 ++- app/templates/main/dashboard.html | 15 +++- app/templates/main/donate.html | 15 +++- app/utils/context_processors.py | 5 +- docs/admin/SUPPORT_CONVERSION_AB_TESTS.md | 5 +- 9 files changed, 189 insertions(+), 36 deletions(-) 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/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/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/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/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/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 %} Date: Fri, 18 Sep 2026 05:55:02 +0200 Subject: [PATCH 02/11] feat: add ship-week and sprint product gaps Expose inventory movements and gamification on the API, add Focus and PO PDF surfaces, expand AI provider presets including OrcaRouter routing, and ship portal realtime refresh, NPS surveys, ExtraGood stock depletion, and white-label custom domains. --- app/models/__init__.py | 2 + app/models/client.py | 1 + app/models/client_survey.py | 75 +++++ app/models/settings.py | 33 +- app/routes/admin.py | 32 +- app/routes/api_v1.py | 105 ++++++ app/routes/client_portal.py | 46 +++ app/routes/clients.py | 14 + app/routes/inventory.py | 23 +- app/routes/invoices.py | 59 ++++ app/routes/projects.py | 47 ++- app/routes/timer.py | 11 + app/services/client_notification_service.py | 19 ++ app/services/client_survey_service.py | 100 ++++++ app/services/llm_service.py | 85 ++++- app/services/payment_service.py | 11 + app/templates/admin/client_surveys.html | 49 +++ app/templates/admin/settings.html | 79 ++++- app/templates/client_portal/base.html | 27 ++ app/templates/client_portal/survey.html | 62 ++++ app/templates/clients/edit.html | 7 + app/templates/email/client_survey.html | 26 ++ .../inventory/purchase_orders/view.html | 1 + app/templates/partials/_sidebar.html | 29 +- app/templates/projects/add_good.html | 11 + app/templates/projects/edit_good.html | 10 + app/templates/timer/focus.html | 317 ++++++++++++++++++ app/utils/purchase_order_pdf.py | 217 ++++++++++++ .../versions/192_add_ai_routing_strategy.py | 39 +++ .../193_client_surveys_and_custom_domain.py | 73 ++++ nginx/CUSTOM_DOMAINS.md | 58 ++++ 31 files changed, 1639 insertions(+), 29 deletions(-) create mode 100644 app/models/client_survey.py create mode 100644 app/services/client_survey_service.py create mode 100644 app/templates/admin/client_surveys.html create mode 100644 app/templates/client_portal/survey.html create mode 100644 app/templates/email/client_survey.html create mode 100644 app/templates/timer/focus.html create mode 100644 app/utils/purchase_order_pdf.py create mode 100644 migrations/versions/192_add_ai_routing_strategy.py create mode 100644 migrations/versions/193_client_surveys_and_custom_domain.py create mode 100644 nginx/CUSTOM_DOMAINS.md diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e963ebd..6a673ccb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -22,6 +22,7 @@ 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_time_approval import ClientApprovalPolicy, ClientApprovalStatus, ClientTimeApproval from .comment import Comment from .comment_attachment import CommentAttachment @@ -267,4 +268,5 @@ "ClientNotification", "ClientNotificationPreferences", "NotificationType", + "ClientSurvey", ] 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_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/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/client_portal.py b/app/routes/client_portal.py index be032306..af655155 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: resolve host to a client when portal custom domains are allowed + try: + from app.models import Settings + + settings = Settings.get_settings() + if getattr(settings, "portal_allowed_custom_domains", None): + host = (request.host or "").split(":")[0].strip().lower() + if host: + domain_client = Client.query.filter(Client.custom_domain == host).first() + if domain_client and domain_client.has_portal_access and domain_client.is_active: + # Prefer an existing portal session for this client; otherwise continue auth flow + session_client_id = session.get("client_portal_id") + if session_client_id and int(session_client_id) == domain_client.id: + return domain_client + # Stash resolved client for login branding / redirects + g.portal_domain_client = domain_client + except Exception: + pass + # Check for Client portal authentication client_id = session.get("client_portal_id") if client_id: @@ -1639,3 +1659,29 @@ 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")) + diff --git a/app/routes/clients.py b/app/routes/clients.py index 9f7a3c59..829a24a0 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -709,6 +709,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 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/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/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/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/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/client_portal/base.html b/app/templates/client_portal/base.html index 873e9f49..c807f80b 100644 --- a/app/templates/client_portal/base.html +++ b/app/templates/client_portal/base.html @@ -419,15 +419,42 @@

{{ _('Client Portal') if (typeof showToast === 'function' && data && data.title) { showToast(data.title, data.message || '', 'info'); } + // Soft-refresh dashboard counters when present + if (data && data.type) { + document.querySelectorAll('[data-portal-stat]').forEach(function(el) { + if (el.getAttribute('data-portal-stat') === 'pending_refresh') { + el.classList.add('ring-2', 'ring-primary'); + } + }); + var badge = document.getElementById('portal-notification-badge'); + if (badge) { + var n = parseInt(badge.textContent || '0', 10) || 0; + badge.textContent = String(n + 1); + badge.classList.remove('hidden'); + } + } }); socket.on('client_approval_update', function(data) { if (typeof showToast === 'function' && data) { showToast('{{ _("Approval update") }}', data.event === 'requested' ? '{{ _("A time entry approval was requested.") }}' : '{{ _("An approval was updated.") }}', 'info'); } }); + socket.on('portal_refresh', function(data) { + if (typeof showToast === 'function' && data && data.message) { + showToast(data.title || '{{ _("Update") }}', data.message, 'info'); + } + // Reload lightweight stat widgets if on dashboard + if (window.location.pathname.indexOf('/client-portal') === 0 && !window.location.pathname.match(/\/(invoices|quotes|approvals|survey)\//)) { + var stats = document.getElementById('dashboard-widgets'); + if (stats && data && data.reload) { + window.setTimeout(function() { window.location.reload(); }, 800); + } + } + }); window.addEventListener('beforeunload', function() { socket.emit('leave_client_room', {}); }); + window.portalSocket = socket; })(); 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/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

+
+
+
+

This is an automated message from TimeTracker.

+
+ + 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/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/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/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/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. From de43656cb8481a1812dea695239eb607b1cb71da Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:28:46 +0200 Subject: [PATCH 03/11] feat(portal): resolve custom domains and brand portal login Serve the client portal for Host matched to Client.custom_domain when allowed, and restrict sign-in on white-label hosts to that client. --- app/__init__.py | 19 ++++++++ app/routes/client_portal.py | 65 +++++++++++++++++--------- app/templates/client_portal/login.html | 6 +-- app/utils/portal_domain.py | 46 ++++++++++++++++++ 4 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 app/utils/portal_domain.py 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/routes/client_portal.py b/app/routes/client_portal.py index af655155..08066141 100644 --- a/app/routes/client_portal.py +++ b/app/routes/client_portal.py @@ -239,24 +239,24 @@ def check_client_portal_access(): Response: A redirect response if authentication is needed None: If 403 is raised (abort is called) """ - # Custom domain: resolve host to a client when portal custom domains are allowed - try: - from app.models import Settings - - settings = Settings.get_settings() - if getattr(settings, "portal_allowed_custom_domains", None): - host = (request.host or "").split(":")[0].strip().lower() - if host: - domain_client = Client.query.filter(Client.custom_domain == host).first() - if domain_client and domain_client.has_portal_access and domain_client.is_active: - # Prefer an existing portal session for this client; otherwise continue auth flow - session_client_id = session.get("client_portal_id") - if session_client_id and int(session_client_id) == domain_client.id: - return domain_client - # Stash resolved client for login branding / redirects - g.portal_domain_client = domain_client - except Exception: - pass + # 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") @@ -384,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() @@ -396,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 diff --git a/app/templates/client_portal/login.html b/app/templates/client_portal/login.html index 197e101b..ee0340b7 100644 --- a/app/templates/client_portal/login.html +++ b/app/templates/client_portal/login.html @@ -30,9 +30,9 @@
@@ -51,7 +51,7 @@

{{ _('Sign in to Client Portal') }
- +
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 From d31b8147f5180895b4d8702b9d76330eb05dd212 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:28:52 +0200 Subject: [PATCH 04/11] feat(invoices): make create and edit forms mobile-friendly Add sticky action bars, larger touch targets, and stacked line-item cards so full invoice editing works on small screens. --- app/templates/invoices/create.html | 60 +++++++++++++++++------------- app/templates/invoices/edit.html | 59 ++++++++++++++++++----------- 2 files changed, 72 insertions(+), 47 deletions(-) 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') }} + + +
+
+

From a78b15040b1e2237f568ba117431705061b5d038 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:28:52 +0200 Subject: [PATCH 05/11] feat(workflows): add visual builder for trigger/condition/action graphs Provide a canvas editor that serializes to the existing WorkflowRule JSON so automation can be designed without the form-only UI. --- app/routes/workflows.py | 40 +++ app/templates/workflows/list.html | 3 + app/templates/workflows/visual_builder.html | 294 ++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 app/templates/workflows/visual_builder.html 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/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 %} From a9c1b6d10c7ddc287a735b66f568d0772a94bfd6 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:29:07 +0200 Subject: [PATCH 06/11] =?UTF-8?q?feat(portal):=20add=20client=E2=80=93team?= =?UTF-8?q?=20messaging=20hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ClientMessage storage, team and portal UIs, and poll/SSE endpoints so clients and staff can communicate in-app. --- app/models/__init__.py | 2 + app/models/client_message.py | 51 ++++++++ app/routes/client_portal.py | 87 ++++++++++++++ app/routes/clients.py | 72 ++++++++++++ app/services/client_message_service.py | 73 ++++++++++++ app/templates/client_portal/base.html | 6 + app/templates/client_portal/messages.html | 56 +++++++++ app/templates/clients/messages.html | 61 ++++++++++ app/templates/clients/view.html | 2 + migrations/versions/194_roadmap_features.py | 124 ++++++++++++++++++++ 10 files changed, 534 insertions(+) create mode 100644 app/models/client_message.py create mode 100644 app/services/client_message_service.py create mode 100644 app/templates/client_portal/messages.html create mode 100644 app/templates/clients/messages.html create mode 100644 migrations/versions/194_roadmap_features.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 6a673ccb..3b981d5d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -23,6 +23,7 @@ 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 @@ -160,6 +161,7 @@ "UserFavoriteProject", "UserClient", "ClientNote", + "ClientMessage", "WeeklyTimeGoal", "WorkdaySession", "WorkingTimeViolation", 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/routes/client_portal.py b/app/routes/client_portal.py index 08066141..1b72cc64 100644 --- a/app/routes/client_portal.py +++ b/app/routes/client_portal.py @@ -1708,3 +1708,90 @@ def survey_response(token): # 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 829a24a0..3b69fba2 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1462,3 +1462,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/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/templates/client_portal/base.html b/app/templates/client_portal/base.html index c807f80b..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/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..c716b7fa 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,7 @@ {% 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}]) }} +
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") From 0ed201026f24f38d924aabe49d2f22c7a8f06b5f Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:29:19 +0200 Subject: [PATCH 07/11] feat(api): add client portal REST endpoints under /api/v1/portal Expose projects, invoices, time entries, messages, approvals, and documents for portal-scoped API tokens with client_id binding. --- app/blueprint_registry.py | 2 + app/models/api_token.py | 9 +- app/routes/api_v1_client_portal.py | 285 +++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 app/routes/api_v1_client_portal.py 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/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/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}) From fb1955ab41eeb7922424d298bc98fc30172c7736 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:29:19 +0200 Subject: [PATCH 08/11] feat(integrations): add Sage Business Cloud and DATEV accounting Register Sage OAuth sync and DATEV EXTF Buchungsstapel export so invoices can be pushed or imported into local accounting tools. --- app/integrations/datev.py | 82 +++++++++ app/integrations/registry.py | 4 + app/integrations/sage.py | 172 +++++++++++++++++++ app/templates/integrations/wizard_datev.html | 31 ++++ app/templates/integrations/wizard_sage.html | 23 +++ app/utils/datev_export.py | 80 +++++++++ 6 files changed, 392 insertions(+) create mode 100644 app/integrations/datev.py create mode 100644 app/integrations/sage.py create mode 100644 app/templates/integrations/wizard_datev.html create mode 100644 app/templates/integrations/wizard_sage.html create mode 100644 app/utils/datev_export.py 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/registry.py b/app/integrations/registry.py index 9e3b10be..85713a9e 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -13,7 +13,9 @@ from app.integrations.linear import LinearConnector from app.integrations.microsoft_teams import MicrosoftTeamsConnector from app.integrations.outlook_calendar import OutlookCalendarConnector +from app.integrations.datev import DatevConnector 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 +38,8 @@ 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) # 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/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_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/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) From 423debf8971a7bfe8b0b717c75eb33cfc79d37a9 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:29:31 +0200 Subject: [PATCH 09/11] feat(crm): sync Gmail and Outlook threads into clients, leads, deals Match mailbox conversations by email address, store threads/messages, surface them on CRM pages, and poll on a schedule. --- app/integrations/gmail.py | 192 +++++++++++++++++ app/integrations/outlook_email.py | 196 ++++++++++++++++++ app/integrations/registry.py | 4 + app/models/__init__.py | 3 + app/models/email_thread.py | 72 +++++++ app/routes/clients.py | 9 + app/routes/deals.py | 11 +- app/routes/leads.py | 9 +- app/services/email_sync_service.py | 178 ++++++++++++++++ app/templates/clients/view.html | 1 + app/templates/deals/view.html | 2 + app/templates/integrations/wizard_gmail.html | 24 +++ .../integrations/wizard_outlook_email.html | 24 +++ app/templates/leads/view.html | 2 + app/templates/partials/_email_threads.html | 24 +++ app/utils/scheduled_tasks.py | 34 +++ 16 files changed, 783 insertions(+), 2 deletions(-) create mode 100644 app/integrations/gmail.py create mode 100644 app/integrations/outlook_email.py create mode 100644 app/models/email_thread.py create mode 100644 app/services/email_sync_service.py create mode 100644 app/templates/integrations/wizard_gmail.html create mode 100644 app/templates/integrations/wizard_outlook_email.html create mode 100644 app/templates/partials/_email_threads.html 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/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 85713a9e..86cf828b 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -7,12 +7,14 @@ from app.integrations.asana import AsanaConnector from app.integrations.caldav_calendar import CalDAVCalendarConnector from app.integrations.github import GitHubConnector +from app.integrations.gmail import GmailConnector from app.integrations.gitlab import GitLabConnector from app.integrations.google_calendar import GoogleCalendarConnector 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.datev import DatevConnector from app.integrations.quickbooks import QuickBooksConnector from app.integrations.sage import SageConnector @@ -40,6 +42,8 @@ def register_connectors(): 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) # Auto-register on import diff --git a/app/models/__init__.py b/app/models/__init__.py index 3b981d5d..e8007e89 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -36,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 @@ -271,4 +272,6 @@ "ClientNotificationPreferences", "NotificationType", "ClientSurvey", + "EmailThread", + "EmailMessage", ] 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/routes/clients.py b/app/routes/clients.py index 3b69fba2..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, ) 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/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/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/templates/clients/view.html b/app/templates/clients/view.html index c716b7fa..648345ad 100644 --- a/app/templates/clients/view.html +++ b/app/templates/clients/view.html @@ -53,6 +53,7 @@ {% 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/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/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_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/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/partials/_email_threads.html b/app/templates/partials/_email_threads.html new file mode 100644 index 00000000..cac6c67e --- /dev/null +++ b/app/templates/partials/_email_threads.html @@ -0,0 +1,24 @@ +{# Shared CRM email threads list #} +{% if email_threads is defined and email_threads %} +
+

{{ _('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/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)} From 62703152e04b1097a907a98fa4d5ee4e94a4b4bd Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:29:40 +0200 Subject: [PATCH 10/11] feat(payroll): add Gusto and ADP connectors with sync logging Build period hour batches from time entries, push to Gusto/ADP, and expose push + history on the workforce dashboard. --- app/integrations/adp.py | 168 +++++++++++++++++++ app/integrations/gusto.py | 147 ++++++++++++++++ app/integrations/registry.py | 8 +- app/models/__init__.py | 2 + app/models/payroll_sync_log.py | 42 +++++ app/routes/workforce.py | 58 +++++++ app/services/payroll_sync_service.py | 113 +++++++++++++ app/templates/integrations/wizard_adp.html | 26 +++ app/templates/integrations/wizard_gusto.html | 26 +++ app/templates/workforce/dashboard.html | 59 +++++++ 10 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 app/integrations/adp.py create mode 100644 app/integrations/gusto.py create mode 100644 app/models/payroll_sync_log.py create mode 100644 app/services/payroll_sync_service.py create mode 100644 app/templates/integrations/wizard_adp.html create mode 100644 app/templates/integrations/wizard_gusto.html 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/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/registry.py b/app/integrations/registry.py index 86cf828b..f523e045 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -4,18 +4,20 @@ """ 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.gmail import GmailConnector 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.datev import DatevConnector from app.integrations.quickbooks import QuickBooksConnector from app.integrations.sage import SageConnector from app.integrations.slack import SlackConnector @@ -44,6 +46,8 @@ def register_connectors(): 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/models/__init__.py b/app/models/__init__.py index e8007e89..ebdae658 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -65,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 @@ -148,6 +149,7 @@ "TaxRule", "Payment", "PayrollExportTemplate", + "PayrollSyncLog", "CreditNote", "InvoiceReminderSchedule", "SavedReportView", 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/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/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/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_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/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') }}

From ed25ca35c9680087147d80513b222234c34df4ed Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 18 Sep 2026 06:34:19 +0200 Subject: [PATCH 11/11] chore: bump version to 5.16.0 and update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump setup.py version to 5.16.0 - Add CHANGELOG.md entry for 5.16.0: client–team messaging, Gmail/Outlook email sync, Gusto/ADP payroll sync, DATEV export, Sage integration, integration setup wizards, visual workflow builder, portal custom domains, and client portal REST API (migration 194) - Add v5.16.0 highlights section to README.md --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 4 ++++ setup.py | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) 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/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={