fix(tenancy): use absolute URL in team invitation email link
Some checks failed
Some checks failed
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) <noreply@anthropic.com>
This commit is contained in:
@@ -99,7 +99,7 @@ def send_campaign(
|
|||||||
db,
|
db,
|
||||||
template_id=data.template_id,
|
template_id=data.template_id,
|
||||||
prospect_ids=data.prospect_ids,
|
prospect_ids=data.prospect_ids,
|
||||||
sent_by_user_id=current_admin.user_id,
|
sent_by_user_id=current_admin.id,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return [CampaignSendResponse.model_validate(s) for s in sends]
|
return [CampaignSendResponse.model_validate(s) for s in sends]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def create_interaction(
|
|||||||
interaction = interaction_service.create(
|
interaction = interaction_service.create(
|
||||||
db,
|
db,
|
||||||
prospect_id=prospect_id,
|
prospect_id=prospect_id,
|
||||||
user_id=current_admin.user_id,
|
user_id=current_admin.id,
|
||||||
data=data.model_dump(exclude_none=True),
|
data=data.model_dump(exclude_none=True),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def create_prospect(
|
|||||||
prospect = prospect_service.create(
|
prospect = prospect_service.create(
|
||||||
db,
|
db,
|
||||||
data.model_dump(exclude_none=True),
|
data.model_dump(exclude_none=True),
|
||||||
captured_by_user_id=current_admin.user_id,
|
captured_by_user_id=current_admin.id,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_response(prospect)
|
return _to_response(prospect)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# app/modules/prospecting/schemas/score.py
|
# app/modules/prospecting/schemas/score.py
|
||||||
"""Pydantic schemas for opportunity scoring."""
|
"""Pydantic schemas for opportunity scoring."""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
class ProspectScoreResponse(BaseModel):
|
class ProspectScoreResponse(BaseModel):
|
||||||
@@ -23,5 +24,19 @@ class ProspectScoreResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
{{ loading_state('Loading prospect...') }}
|
{{ loading_state('Loading prospect...') }}
|
||||||
{{ error_state('Error loading prospect') }}
|
{{ error_state('Error loading prospect') }}
|
||||||
|
|
||||||
<div x-show="!loading && !error && prospect" class="space-y-6">
|
<template x-if="!loading && !error && prospect">
|
||||||
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@@ -215,6 +216,7 @@
|
|||||||
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
|
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Interaction Modal -->
|
<!-- Interaction Modal -->
|
||||||
{% call modal('interactionModal', 'Log Interaction', show_var='showInteractionModal', size='md', show_footer=false) %}
|
{% call modal('interactionModal', 'Log Interaction', show_var='showInteractionModal', size='md', show_footer=false) %}
|
||||||
|
|||||||
@@ -980,9 +980,13 @@ class StoreTeamService:
|
|||||||
role_name: str,
|
role_name: str,
|
||||||
):
|
):
|
||||||
"""Send team invitation email."""
|
"""Send team invitation email."""
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
from app.modules.messaging.services.email_service import EmailService
|
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 = EmailService(db)
|
||||||
email_service.send_template(
|
email_service.send_template(
|
||||||
|
|||||||
73
docs/proposals/hosting-site-creation-fix.md
Normal file
73
docs/proposals/hosting-site-creation-fix.md
Normal file
@@ -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)
|
||||||
@@ -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 ──
|
// ── Refresh helper ──
|
||||||
function refreshIfActive(tab) {
|
function refreshIfActive(tab) {
|
||||||
if (isExpanded && activeTab === tab && contentEl) {
|
if (isExpanded && activeTab === tab && contentEl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user