From 8a70259445f0c93cad21442bc5e9adc3e79cf7cf Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 29 Mar 2026 17:39:46 +0200 Subject: [PATCH] fix(tenancy): use absolute URL in team invitation email link Email clients need absolute URLs to make links clickable. The acceptance_link was a relative path (/store/invitation/accept?token=...) which rendered as plain text. Now prepends the platform domain with the correct protocol. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../prospecting/routes/api/admin_campaigns.py | 2 +- .../routes/api/admin_interactions.py | 2 +- .../prospecting/routes/api/admin_prospects.py | 2 +- app/modules/prospecting/schemas/score.py | 17 ++++- .../prospecting/admin/prospect-detail.html | 4 +- .../tenancy/services/store_team_service.py | 6 +- docs/proposals/hosting-site-creation-fix.md | 73 +++++++++++++++++++ static/shared/js/dev-toolbar.js | 24 ++++++ 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 docs/proposals/hosting-site-creation-fix.md diff --git a/app/modules/prospecting/routes/api/admin_campaigns.py b/app/modules/prospecting/routes/api/admin_campaigns.py index 6540faf7..1f312177 100644 --- a/app/modules/prospecting/routes/api/admin_campaigns.py +++ b/app/modules/prospecting/routes/api/admin_campaigns.py @@ -99,7 +99,7 @@ def send_campaign( db, template_id=data.template_id, prospect_ids=data.prospect_ids, - sent_by_user_id=current_admin.user_id, + sent_by_user_id=current_admin.id, ) db.commit() return [CampaignSendResponse.model_validate(s) for s in sends] diff --git a/app/modules/prospecting/routes/api/admin_interactions.py b/app/modules/prospecting/routes/api/admin_interactions.py index ca8c7902..124e062d 100644 --- a/app/modules/prospecting/routes/api/admin_interactions.py +++ b/app/modules/prospecting/routes/api/admin_interactions.py @@ -48,7 +48,7 @@ def create_interaction( interaction = interaction_service.create( db, prospect_id=prospect_id, - user_id=current_admin.user_id, + user_id=current_admin.id, data=data.model_dump(exclude_none=True), ) db.commit() diff --git a/app/modules/prospecting/routes/api/admin_prospects.py b/app/modules/prospecting/routes/api/admin_prospects.py index ae649d94..2fd76d36 100644 --- a/app/modules/prospecting/routes/api/admin_prospects.py +++ b/app/modules/prospecting/routes/api/admin_prospects.py @@ -80,7 +80,7 @@ def create_prospect( prospect = prospect_service.create( db, data.model_dump(exclude_none=True), - captured_by_user_id=current_admin.user_id, + captured_by_user_id=current_admin.id, ) db.commit() return _to_response(prospect) diff --git a/app/modules/prospecting/schemas/score.py b/app/modules/prospecting/schemas/score.py index a184206f..6e16d8cf 100644 --- a/app/modules/prospecting/schemas/score.py +++ b/app/modules/prospecting/schemas/score.py @@ -1,9 +1,10 @@ # app/modules/prospecting/schemas/score.py """Pydantic schemas for opportunity scoring.""" +import json from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator class ProspectScoreResponse(BaseModel): @@ -23,5 +24,19 @@ class ProspectScoreResponse(BaseModel): created_at: datetime updated_at: datetime + @field_validator("reason_flags", mode="before") + @classmethod + def parse_reason_flags(cls, v): + if isinstance(v, str): + return json.loads(v) + return v + + @field_validator("score_breakdown", mode="before") + @classmethod + def parse_score_breakdown(cls, v): + if isinstance(v, str): + return json.loads(v) + return v + class Config: from_attributes = True diff --git a/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html b/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html index 44282293..01895892 100644 --- a/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html +++ b/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html @@ -11,7 +11,8 @@ {{ loading_state('Loading prospect...') }} {{ error_state('Error loading prospect') }} -
+ {% call modal('interactionModal', 'Log Interaction', show_var='showInteractionModal', size='md', show_footer=false) %} diff --git a/app/modules/tenancy/services/store_team_service.py b/app/modules/tenancy/services/store_team_service.py index 0fb2ea4d..3f0cbc4b 100644 --- a/app/modules/tenancy/services/store_team_service.py +++ b/app/modules/tenancy/services/store_team_service.py @@ -980,9 +980,13 @@ class StoreTeamService: role_name: str, ): """Send team invitation email.""" + from app.core.config import settings as app_settings from app.modules.messaging.services.email_service import EmailService - acceptance_link = f"/store/invitation/accept?token={token}" + domain = app_settings.main_domain.rstrip("/") + scheme = "http" if "localhost" in domain else "https" + base_url = f"{scheme}://{domain}" if "://" not in domain else domain + acceptance_link = f"{base_url}/store/invitation/accept?token={token}" email_service = EmailService(db) email_service.send_template( diff --git a/docs/proposals/hosting-site-creation-fix.md b/docs/proposals/hosting-site-creation-fix.md new file mode 100644 index 00000000..3f9b30ce --- /dev/null +++ b/docs/proposals/hosting-site-creation-fix.md @@ -0,0 +1,73 @@ +# Hosting Site Creation — Require Merchant or Prospect + +## Problem + +`hosted_site_service.create()` (lines 118-131) auto-creates a fake "HostWizard System" `Merchant` with no `owner_user_id` as a placeholder to satisfy the Store's `merchant_id` FK. This is wrong on two levels: + +- Crashes because `Merchant.owner_user_id` is `nullable=False` +- Conceptually wrong — POC sites should belong to a real entity + +## Current Flow + +``` +create() → invents a system merchant (broken) + → creates Store under it + → creates HostedSite + +create_from_prospect() → reads prospect data + → calls create() (same broken path) + +accept_proposal() → creates real merchant + → reassigns store to it +``` + +## Proposed Design: Require merchant_id OR prospect_id + +### Schema (`HostedSiteCreate`) + +- Add `merchant_id: int | None` and `prospect_id: int | None` +- Add `model_validator`: at least one must be provided +- Remove `prospect_id` from the separate URL path endpoint — unify into one create endpoint + +### Service (`create`) + +- **If `merchant_id` provided**: validate merchant exists, create Store under it directly +- **If `prospect_id` provided** (no merchant): auto-create a merchant from prospect data using `merchant_service.create_merchant_with_owner()` (same as `accept_proposal` does today), then create Store under it +- **If both provided**: use the existing merchant, link the prospect +- Remove the system merchant creation entirely (lines 118-131) + +### `create_from_prospect` method + +Can be removed — its logic merges into `create` via the `prospect_id` field. + +### `accept_proposal` + +Simplify — merchant already exists at this point, so it only needs to handle subscription creation and prospect status update. The store reassignment is no longer needed. + +## Files to Change + +| File | Change | +|---|---| +| `hosting/schemas/hosted_site.py` | Add `merchant_id`, `prospect_id` fields + validator | +| `hosting/services/hosted_site_service.py` | Rewrite `create()` to use provided merchant or create from prospect. Remove `create_from_prospect()`. Simplify `accept_proposal()`. | +| `hosting/routes/api/admin_sites.py` | Remove `/from-prospect/{prospect_id}` endpoint. The main `POST /sites` handles both paths now. | +| `hosting/templates/hosting/admin/site-new.html` | Add merchant selector (dropdown/autocomplete) and prospect selector. Validate one is chosen. | +| `hosting/tests/conftest.py` | Update `hosted_site` fixture to pass `merchant_id` | +| `hosting/tests/unit/test_hosted_site_service.py` | Update all `create` calls, remove `TestHostedSiteFromProspect` class (merge into main tests), add validation tests for the "at least one required" rule | + +## Template Changes Needed + +The form currently has a business name + contact fields + a prospect ID input at the bottom. It needs: + +1. A **merchant selector** (autocomplete dropdown searching existing merchants) +2. A **prospect selector** (autocomplete dropdown searching existing prospects) +3. Validation: at least one must be selected +4. When prospect is selected: auto-fill business_name, contact fields from prospect data +5. When merchant is selected: auto-fill business_name from merchant name + +## What Gets Deleted + +- The entire "HostWizard System" merchant pattern (lines 118-131 in service) +- The `POST /sites/from-prospect/{prospect_id}` endpoint +- The `create_from_prospect()` service method +- The prospect ID number input in the template (replaced by proper selector) diff --git a/static/shared/js/dev-toolbar.js b/static/shared/js/dev-toolbar.js index 7224794e..f498cf45 100644 --- a/static/shared/js/dev-toolbar.js +++ b/static/shared/js/dev-toolbar.js @@ -223,6 +223,30 @@ }; }); + // Capture uncaught errors and unhandled rejections + window.addEventListener('error', function (e) { + var msg = e.message || 'Unknown error'; + if (e.filename) msg += ' at ' + e.filename.split('/').pop() + ':' + e.lineno; + consoleLogs.push({ + timestamp: Date.now(), + level: 'error', + args: [msg], + }); + if (consoleLogs.length > MAX_CONSOLE_LOGS) consoleLogs.shift(); + refreshIfActive('console'); + }); + + window.addEventListener('unhandledrejection', function (e) { + var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection'; + consoleLogs.push({ + timestamp: Date.now(), + level: 'error', + args: [msg], + }); + if (consoleLogs.length > MAX_CONSOLE_LOGS) consoleLogs.shift(); + refreshIfActive('console'); + }); + // ── Refresh helper ── function refreshIfActive(tab) { if (isExpanded && activeTab === tab && contentEl) {