Compare commits

...

25 Commits

Author SHA1 Message Date
dd09bcaeec docs: add proposal for HostedSite → Store cascade delete
All checks were successful
CI / ruff (push) Successful in 33s
CI / pytest (push) Successful in 2h46m24s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Successful in 49s
CI / deploy (push) Successful in 2m55s
Deleting a HostedSite leaves the Store orphaned, blocking subdomain
reuse. Proposal: cascade delete the Store when deleting the site.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:31:31 +02:00
013eafd775 fix(hosting): fix Create Site button visibility
Button was invisible — likely due to bg-teal-600 not being in the
Tailwind CSS purge or $icon rendering issue. Switched to bg-purple-600
(known to work) and simplified button content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:28:23 +02:00
07cd66a0e3 feat(hosting): add Build POC button with template selector on site detail
Draft sites with a linked prospect show a "Build POC from Template"
section with:
- Template dropdown (generic, restaurant, construction, auto-parts,
  professional-services) loaded from /admin/hosting/sites/templates API
- "Build POC" button that calls POST /admin/hosting/sites/poc/build
- Loading state + success message with pages created count
- Auto-refreshes site detail after build (status changes to POC_READY)

Visible only for draft sites with a prospect_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:24:19 +02:00
73d453d78a feat(hosting): prospect search dropdown on site creation form
- Replace raw ID inputs with a live search dropdown that queries
  /admin/prospecting/prospects?search= as you type
- Shows matching prospects with business name, domain, and ID
- Clicking a prospect auto-fills business name, email, phone
- Selected prospect shown as badge with clear button
- Optional merchant ID field for existing merchants
- Remove stale "Create from Prospect" link on sites list (was just
  a link to the prospects page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:14:47 +02:00
d4e9fed719 fix(hosting): fix site creation form + add delete to sites list
Site creation form:
- Replace old "Create from Prospect" button (called removed endpoint)
  with inline prospect_id + merchant_id fields
- Schema requires at least one — form validates before submit
- Clean payload (strip nulls/empty strings before POST)

Sites list:
- Add trash icon delete button with confirmation dialog
- Calls DELETE /admin/hosting/sites/{id} (existing endpoint)
- Reloads list after successful deletion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:10:48 +02:00
3e93f64c6b fix(prospecting): clarify opportunity score UI
- Rename "Score Breakdown" → "Opportunity Score" with subtitle
  "Higher = more issues = better sales opportunity"
- "No issues detected" at 0 points shows green "✓ No issues found —
  low opportunity" instead of ambiguous gray text
- Explains why Technical Health 0/40 is actually good (no problems)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:05:59 +02:00
377d2d3ae8 feat(prospecting): add delete button to prospects list
- Trash icon button in Actions column with confirmation dialog
- Calls DELETE /admin/prospecting/prospects/{id} (existing endpoint)
- Reloads list after successful deletion
- Toast notification on success/failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:59:12 +02:00
b51f9e8e30 fix(hosting): smart slug generation with fallback chain
Slugify now handles both domains and business names gracefully:
- Domain: strip protocol/www/TLD → batirenovation-strasbourg
- Business name: take first 3 meaningful words, skip filler
  (le, la, du, des, the, and) → boulangerie-coin
- Cap at 30 chars

Clients without a domain get clean slugs from their business name
instead of the full title truncated mid-word.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:56:28 +02:00
d380437594 fix(hosting): site detail null guard + cleaner preview URLs
- Site detail template: x-show → x-if to prevent Alpine evaluating
  expressions when site is null during async loading
- Slugify: prefer domain_name over business_name for subdomain
  generation (batirenovation-strasbourg vs bati-rnovation-strasbourg-
  peinture-ravalement-dans). Cap at 30 chars. Strip protocol/TLD.
- POC builder passes domain_name for clean slugs
- Remove .lu/.fr/.com TLD from slugs automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:49:32 +02:00
cff0af31be feat(hosting): signed preview URLs for POC sites
Replace the standalone POC viewer (duplicate rendering) with signed
JWT preview tokens that bypass StorefrontAccessMiddleware:

Architecture:
1. Admin clicks Preview → route generates signed JWT
2. Redirects to /storefront/{subdomain}/homepage?_preview=token
3. Middleware validates token signature + expiry + store_id
4. Sets request.state.is_preview = True, skips subscription check
5. Full storefront renders with HostWizard preview banner injected

New files:
- app/core/preview_token.py: create_preview_token/verify_preview_token

Changes:
- middleware/storefront_access.py: preview token bypass before sub check
- storefront/base.html: preview banner injection via is_preview state
- hosting/routes/pages/public.py: redirect with signed token (was direct render)
- hosting/routes/api/admin_sites.py: GET /sites/{id}/preview-url endpoint

Removed:
- hosting/templates/hosting/public/poc-viewer.html (replaced by storefront)

Benefits: one rendering path, all section types work, shareable 24h links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:41:34 +02:00
e492e5f71c fix(hosting): render POC preview directly instead of iframe
The POC viewer was loading the storefront in an iframe, which hit the
StorefrontAccessMiddleware subscription check (POC sites don't have
subscriptions yet). Fixed by rendering CMS sections directly in the
preview template:
- Load ContentPages and StoreTheme from DB
- Render hero, features, testimonials, CTA sections inline
- Apply template colors/fonts via Tailwind CSS config
- HostWizard preview banner with nav links
- Footer with contact info
- No iframe, no subscription check needed

Also fixed Jinja2 dict.items collision (dict.items is the method,
not the 'items' key — use dict.get('items') instead).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:15:11 +02:00
9a5b7dd061 fix: register hosting public preview route + suppress SSL warnings
- Register hosting public page router in main.py (POC preview at
  /hosting/sites/{id}/preview was returning 404 because the
  public_page_router was set on module definition but never mounted)
- Suppress urllib3 InsecureRequestWarning in enrichment service
  (intentional verify=False for prospect site scanning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:01:55 +02:00
b3051b423a feat(cms): add testimonials, gallery, contact_info section types (3D)
New section partials for hosting templates:
- _testimonials.html: customer review cards with star ratings, avatars
- _gallery.html: responsive image grid with hover captions
- _contact_info.html: phone/email/address cards with icons + hours

Updated renderers:
- Platform homepage-default.html: imports + renders new section types
- Storefront landing-full.html: added section-based rendering path
  that takes over when page.sections is set (POC builder pages),
  falls back to hardcoded HTML layout for non-section pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:54:15 +02:00
bc951a36d9 feat(hosting): implement POC builder service (Workstream 3C)
One-click POC site generation from prospect data + industry template:

PocBuilderService.build_poc():
1. Loads prospect (scraped content, contacts, business info)
2. Loads industry template (pages, theme, sections)
3. Creates HostedSite + Store via hosted_site_service
4. Populates CMS ContentPages from template, replacing {{placeholders}}
   (business_name, city, phone, email, address, meta_description,
   about_paragraph) with prospect data
5. Applies StoreTheme (colors, fonts, layout) from template
6. Auto-transitions to POC_READY status

API: POST /admin/hosting/sites/poc/build
Body: {prospect_id, template_id, merchant_id?}

Tested: prospect 1 (batirenovation-strasbourg.fr) + "construction"
template → 4 pages created, theme applied, subdomain assigned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:46:59 +02:00
2e043260eb feat(hosting): add industry template infrastructure (Workstream 3B)
5 industry templates as JSON presets, each with theme + multi-page content:
- generic: clean minimal (homepage, about, contact)
- restaurant: warm tones, Playfair Display (homepage, about, menu, contact)
- construction: amber/earth tones, Montserrat (homepage, services, projects, contact)
- auto-parts: red/bold, parts-focused (homepage, catalog, contact)
- professional-services: navy/blue, Merriweather (homepage, services, team, contact)

Each template has:
- meta.json (name, description, tags, languages)
- theme.json (colors, fonts, layout, header style)
- pages/*.json (section-based homepage + content pages with i18n)
- {{placeholder}} variables for prospect data injection

TemplateService loads from templates_library/ directory with caching.
GET /admin/hosting/sites/templates endpoint to list available templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:41:33 +02:00
1828ac85eb feat(prospecting): add content scraping for POC builder (Workstream 3A)
- New scrape_content() method in enrichment_service: extracts meta
  description, H1/H2 headings, paragraphs, images (filtered for size),
  social links, service items, and detected languages using BeautifulSoup
- Scans 6 pages per prospect: /, /about, /a-propos, /services,
  /nos-services, /contact
- Results stored as JSON in prospect.scraped_content_json
- New endpoints: POST /content-scrape/{id} and /content-scrape/batch
- Added to full_enrichment pipeline (Step 5, before security audit)
- CONTENT_SCRAPE job type for scan-jobs tracking
- "Content Scrape" batch button on scan-jobs page
- Add beautifulsoup4 to requirements.txt

Tested on batirenovation-strasbourg.fr: extracted 30 headings,
21 paragraphs, 13 images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:26:56 +02:00
50a4fc38a7 feat(prospecting): add batch delay + fix Celery error_message field
- Add PROSPECTING_BATCH_DELAY_SECONDS config (default 1.0s) — polite
  delay between prospects in batch scans to avoid rate limiting
- Apply delay to all 5 batch API endpoints and all Celery tasks
- Fix Celery tasks: error_message → error_log (matches model field)
- Add batch-scanning.md docs with rate limiting guide, scaling estimates
  for 70k+ URL imports, and pipeline order recommendations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:55:24 +02:00
30f3dae5a3 feat(prospecting): add security audit report generation (Workstream 2B)
- SecurityReportService generates standalone branded HTML reports from
  stored audit data (grade badge, simulated hacked site, detailed
  findings, business impact, call-to-action with contact info)
- GET /security-audit/report/{prospect_id} returns HTMLResponse
- "Generate Report" button on prospect detail security tab opens
  report in new browser tab (printable to PDF)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:40 +02:00
4c750f0268 feat(prospecting): implement security audit pipeline (Workstream 2A)
Complete security audit integration into the enrichment pipeline:

Backend:
- SecurityAuditService with 7 passive checks: HTTPS, SSL cert, security
  headers, exposed files, cookies, server info, technology detection
- Constants file with SECURITY_HEADERS, EXPOSED_PATHS, SEVERITY_SCORES
- SecurityAuditResponse schema with JSON field validators + aliases
- Endpoints: POST /security-audit/{id}, POST /security-audit/batch
- Added to full_enrichment pipeline (Step 5, before scoring)
- get_pending_security_audit() query in prospect_service

Frontend:
- Security tab on prospect detail page with grade badge (A+ to F),
  score/100, severity counts, HTTPS/SSL status, missing headers,
  exposed files, technologies, and full findings list
- "Run Security Audit" button with loading state
- "Security Audit" batch button on scan-jobs page

Tested on batirenovation-strasbourg.fr: Grade D (50/100), 11 issues
found (missing headers, exposed wp-login, server version disclosure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:58:11 +02:00
59b0d8977a fix(hosting): require merchant or prospect for site creation
- Schema: add merchant_id/prospect_id with model_validator requiring
  at least one. Remove from-prospect endpoint (unified into POST /sites)
- Service: rewrite create() — if merchant_id use it directly, if
  prospect_id auto-create merchant from prospect data. Remove system
  merchant hack entirely. Extract _create_merchant_from_prospect helper.
- Simplify accept_proposal() — merchant already exists at creation,
  only creates subscription and marks prospect converted
- Tests: update all create calls with merchant_id, replace from-prospect
  tests with prospect_id + validation tests

Closes docs/proposals/hosting-site-creation-fix.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:14:47 +02:00
2bc03ed97c docs: add end-to-end plan from prospecting to live site
Master plan covering 4 workstreams:
1. Fix hosting foundation (merchant/prospect required)
2. Security audit pipeline + report + live demo
3. POC builder with industry templates (restaurant, construction,
   auto-parts, professional-services, generic)
4. AI content enhancement (deferred, provider TBD)

Target: 10-step journey from prospect discovery to live website.
Steps 1-3 work today, steps 4-10 need the work described.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:05:21 +02:00
91963f3b87 docs: architecture decision — hosting sites reuse CMS + Store + StoreDomain
Hosted sites leverage existing CMS module (ContentPage, StoreTheme,
MediaFile) instead of building a separate site rendering system. Industry
templates (restaurant, construction, auto-parts, professional-services,
generic) are JSON presets that populate CMS entities for a new Store.

POC phase uses subdomain routing (acme.hostwizard.lu), go-live adds
custom domain via StoreDomain (acme.lu). All routing handled by existing
StoreContextMiddleware + Caddy wildcards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:42:10 +02:00
3ae0b579d3 docs: add security audit + demo + POC builder proposal
4-phase plan for integrating scripts/security-audit/ into the
prospecting module: security audit pipeline, report generation,
live demo server, and POC site builder architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:27:59 +02:00
972ee1e5d0 feat(prospecting): add ProspectSecurityAudit model (Phase 1 foundation)
- New model: ProspectSecurityAudit with score, grade, findings_json,
  severity counts, has_https, has_valid_ssl, missing_headers, exposed
  files, technologies, scan_error
- Add last_security_audit_at timestamp to Prospect model
- Add security_audit 1:1 relationship on Prospect

Part of Phase 1: Security Audit in Enrichment Pipeline. Service,
constants, migration, endpoints, and frontend to follow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:23:38 +02:00
70f2803dd3 fix(prospecting): handle PageSpeed API errors and improve performance card
- Detect API-level errors (quota exceeded, invalid URL) in response JSON
  and store in scan_error instead of silently writing zeros
- Show scan error message on the performance card when present
- Show "No performance data — configure PAGESPEED_API_KEY" when all
  scores are 0 and no error was recorded
- Add accessibility and best practices scores to performance card

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:41:37 +02:00
77 changed files with 3882 additions and 275 deletions

54
app/core/preview_token.py Normal file
View File

@@ -0,0 +1,54 @@
# app/core/preview_token.py
"""
Signed preview tokens for POC site previews.
Generates time-limited JWT tokens that allow viewing storefront pages
for stores without active subscriptions (POC sites). The token is
validated by StorefrontAccessMiddleware to bypass the subscription gate.
"""
import logging
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from app.core.config import settings
logger = logging.getLogger(__name__)
PREVIEW_TOKEN_HOURS = 24
ALGORITHM = "HS256"
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
"""Create a signed preview token for a POC site.
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
to a specific store_id. Shareable with clients for preview access.
"""
payload = {
"sub": f"preview:{store_id}",
"store_id": store_id,
"store_code": store_code,
"site_id": site_id,
"preview": True,
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
"iat": datetime.now(UTC),
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
def verify_preview_token(token: str, store_id: int) -> bool:
"""Verify a preview token is valid and matches the store.
Returns True if:
- Token signature is valid
- Token has not expired
- Token has preview=True claim
- Token store_id matches the requested store
"""
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
return payload.get("preview") is True and payload.get("store_id") == store_id
except JWTError:
return False

View File

@@ -7,6 +7,9 @@
{% from 'cms/platform/sections/_products.html' import render_products %}
{% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
{% from 'cms/platform/sections/_cta.html' import render_cta %}
{% block title %}
@@ -51,6 +54,21 @@
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
{% endif %}
{# Testimonials Section #}
{% if page.sections.testimonials %}
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
{% endif %}
{# Gallery Section #}
{% if page.sections.gallery %}
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
{% endif %}
{# Contact Info Section #}
{% if page.sections.contact_info %}
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
{% endif %}
{# CTA Section #}
{% if page.sections.cta %}
{{ render_cta(page.sections.cta, lang, default_lang) }}

View File

@@ -0,0 +1,66 @@
{# Section partial: Contact Information #}
{#
Parameters:
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_contact_info(contact_info, lang, default_lang) %}
{% if contact_info and contact_info.enabled %}
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{% if contact_info.phone %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128222;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
{{ contact_info.phone }}
</a>
</div>
{% endif %}
{% if contact_info.email %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128231;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
{{ contact_info.email }}
</a>
</div>
{% endif %}
{% if contact_info.address %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128205;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
</div>
{% endif %}
</div>
{% if contact_info.hours %}
<div class="mt-8 text-center">
<p class="text-gray-600 dark:text-gray-400">
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
</p>
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,44 @@
{# Section partial: Image Gallery #}
{#
Parameters:
- gallery: dict with enabled, title, images (list of {src, alt, caption})
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_gallery(gallery, lang, default_lang) %}
{% if gallery and gallery.enabled %}
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
</div>
{# Image grid #}
{% if gallery.images %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for image in gallery.images %}
<div class="relative group overflow-hidden rounded-lg aspect-square">
<img src="{{ image.src }}"
alt="{{ image.alt or '' }}"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy">
{% if image.caption %}
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
<p class="text-sm text-white">{{ image.caption }}</p>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,72 @@
{# Section partial: Testimonials #}
{#
Parameters:
- testimonials: dict with enabled, title, subtitle, items
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_testimonials(testimonials, lang, default_lang) %}
{% if testimonials and testimonials.enabled %}
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
</div>
{# Testimonial cards #}
{% if testimonials.items %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{% for item in testimonials.items %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex text-yellow-400">
{% for _ in range(5) %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
{% endfor %}
</div>
</div>
{% set content = item.content %}
{% if content is mapping %}
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
{% endif %}
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
<div class="flex items-center">
{% if item.avatar %}
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
{% else %}
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
{% set author = item.author %}
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
{{ author[0]|upper if author else '?' }}
</span>
</div>
{% endif %}
<div>
{% set author = item.author %}
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
{% set role = item.role %}
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
{% if role %}
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -10,6 +10,34 @@
{% block alpine_data %}storefrontLayoutData(){% endblock %}
{% block content %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SECTION-BASED RENDERING (when page.sections is configured) #}
{# Used by POC builder templates — takes priority over hardcoded HTML #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if page and page.sections %}
{% from 'cms/platform/sections/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
{% from 'cms/platform/sections/_cta.html' import render_cta %}
{% set lang = request.state.language|default("fr") %}
{% set default_lang = 'fr' %}
<div class="min-h-screen">
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %}
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %}
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %}
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %}
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %}
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %}
</div>
{% else %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
{# ═══════════════════════════════════════════════════════════════════ #}
<div class="min-h-screen">
{# Hero Section - Split Design #}
@@ -255,4 +283,5 @@
</section>
</div>
{% endif %}
{% endblock %}

View File

@@ -7,6 +7,7 @@ import logging
from math import ceil
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
@@ -22,13 +23,88 @@ from app.modules.hosting.schemas.hosted_site import (
HostedSiteUpdate,
SendProposalRequest,
)
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse
from app.modules.hosting.services.hosted_site_service import hosted_site_service
from app.modules.hosting.services.poc_builder_service import poc_builder_service
from app.modules.hosting.services.template_service import template_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/sites")
logger = logging.getLogger(__name__)
@router.get("/templates", response_model=TemplateListResponse)
def list_templates(
current_admin: UserContext = Depends(get_current_admin_api),
):
"""List available industry templates for POC site generation."""
templates = template_service.list_templates()
return TemplateListResponse(
templates=[TemplateResponse(**t) for t in templates],
)
class PreviewUrlResponse(BaseModel):
"""Response with signed preview URL."""
preview_url: str
expires_in_hours: int = 24
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
def get_preview_url(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Generate a signed preview URL for a hosted site."""
from app.core.preview_token import create_preview_token
site = hosted_site_service.get_by_id(db, site_id)
store = site.store
subdomain = store.subdomain or store.store_code
token = create_preview_token(store.id, subdomain, site.id)
return PreviewUrlResponse(
preview_url=f"/storefront/{subdomain}/?_preview={token}",
)
class BuildPocRequest(BaseModel):
"""Request to build a POC site from prospect + template."""
prospect_id: int
template_id: str
merchant_id: int | None = None
class BuildPocResponse(BaseModel):
"""Response from POC builder."""
hosted_site_id: int
store_id: int
pages_created: int
theme_applied: bool
template_id: str
subdomain: str | None = None
@router.post("/poc/build", response_model=BuildPocResponse)
def build_poc(
data: BuildPocRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Build a POC site from prospect data + industry template."""
result = poc_builder_service.build_poc(
db,
prospect_id=data.prospect_id,
template_id=data.template_id,
merchant_id=data.merchant_id,
)
db.commit()
return BuildPocResponse(**result)
def _to_response(site) -> HostedSiteResponse:
"""Convert a hosted site model to response schema."""
return HostedSiteResponse(
@@ -96,17 +172,6 @@ def create_site(
return _to_response(site)
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
def create_from_prospect(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a hosted site pre-filled from prospect data."""
site = hosted_site_service.create_from_prospect(db, prospect_id)
db.commit()
return _to_response(site)
@router.put("/{site_id}", response_model=HostedSiteResponse)
def update_site(

View File

@@ -2,45 +2,73 @@
"""
Hosting Public Page Routes.
Public-facing routes for POC site viewing:
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
POC site preview via signed URL redirect to the storefront.
The StorefrontAccessMiddleware validates the preview token and
allows rendering without an active subscription.
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.templates_config import templates
from app.core.preview_token import create_preview_token
router = APIRouter()
@router.get(
"/hosting/sites/{site_id}/preview",
response_class=HTMLResponse,
include_in_schema=False,
)
async def poc_site_viewer(
request: Request,
site_id: int = Path(..., description="Hosted Site ID"),
page: str = Query("homepage", description="Page slug to preview"),
db: Session = Depends(get_db),
):
"""Render POC site viewer with HostWizard preview banner."""
"""Redirect to storefront with signed preview token.
Generates a time-limited JWT and redirects to the store's
storefront URL. The StorefrontAccessMiddleware validates the
token and bypasses the subscription check.
"""
from app.modules.hosting.models import HostedSite, HostedSiteStatus
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
# Only allow viewing for poc_ready or proposal_sent sites
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
if not site or site.status not in (
HostedSiteStatus.POC_READY,
HostedSiteStatus.PROPOSAL_SENT,
HostedSiteStatus.ACCEPTED,
):
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
context = {
"request": request,
"site": site,
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
}
return templates.TemplateResponse(
"hosting/public/poc-viewer.html",
context,
store = site.store
if not store:
return HTMLResponse(content="<h1>Store not found</h1>", status_code=404)
# Generate signed preview token — use subdomain for URL routing
subdomain = store.subdomain or store.store_code
token = create_preview_token(store.id, subdomain, site.id)
# Get platform code for dev-mode URL prefix
from app.core.config import settings
from app.modules.tenancy.models import StorePlatform
store_platform = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == store.id)
.first()
)
# In dev mode, storefront needs /platforms/{code}/ prefix
if settings.debug and store_platform and store_platform.platform:
platform_code = store_platform.platform.code
base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
else:
base_url = f"/storefront/{subdomain}"
# Append page slug — storefront needs /{slug} (root has no catch-all)
base_url += f"/{page}"
return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)

View File

@@ -3,18 +3,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class HostedSiteCreate(BaseModel):
"""Schema for creating a hosted site."""
"""Schema for creating a hosted site.
Either merchant_id or prospect_id must be provided:
- merchant_id: store is created under this merchant
- prospect_id: a merchant is auto-created from prospect data
"""
business_name: str = Field(..., max_length=255)
merchant_id: int | None = None
prospect_id: int | None = None
contact_name: str | None = Field(None, max_length=255)
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
internal_notes: str | None = None
@model_validator(mode="after")
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
if not self.merchant_id and not self.prospect_id:
raise ValueError("Either merchant_id or prospect_id is required")
return self
class HostedSiteUpdate(BaseModel):
"""Schema for updating a hosted site."""

View File

@@ -0,0 +1,21 @@
# app/modules/hosting/schemas/template.py
"""Pydantic schemas for template responses."""
from pydantic import BaseModel
class TemplateResponse(BaseModel):
"""Schema for a single template."""
id: str
name: str
description: str
tags: list[str] = []
languages: list[str] = []
pages: list[str] = []
class TemplateListResponse(BaseModel):
"""Schema for template list response."""
templates: list[TemplateResponse]

View File

@@ -34,12 +34,30 @@ ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
}
def _slugify(name: str) -> str:
"""Generate a URL-safe slug from a business name."""
def _slugify(name: str, max_length: int = 30) -> str:
"""Generate a short URL-safe slug from a domain or business name.
Priority: domain name (clean) > first 3 words of business name > full slug truncated.
"""
slug = name.lower().strip()
# If it looks like a domain, extract the hostname part
for prefix in ["https://", "http://", "www."]:
if slug.startswith(prefix):
slug = slug[len(prefix):]
slug = slug.rstrip("/")
if "." in slug and " " not in slug:
# Domain: remove TLD → batirenovation-strasbourg.fr → batirenovation-strasbourg
slug = slug.rsplit(".", 1)[0]
else:
# Business name: take first 3 meaningful words for brevity
words = re.sub(r"[^a-z0-9\s]", "", slug).split()
# Skip filler words
filler = {"the", "le", "la", "les", "de", "du", "des", "et", "and", "und", "die", "der", "das"}
words = [w for w in words if w not in filler][:3]
slug = " ".join(words)
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s-]+", "-", slug)
return slug.strip("-")[:50]
return slug.strip("-")[:max_length]
class HostedSiteService:
@@ -88,50 +106,47 @@ class HostedSiteService:
return sites, total
def create(self, db: Session, data: dict) -> HostedSite:
"""Create a hosted site with an auto-created Store on the hosting platform."""
from app.modules.tenancy.models import Platform
"""Create a hosted site with an auto-created Store on the hosting platform.
Requires either merchant_id or prospect_id in data:
- merchant_id: store created under this merchant
- prospect_id: merchant auto-created from prospect data
"""
from app.modules.tenancy.models import Merchant, Platform, Store
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
slug = _slugify(business_name)
merchant_id = data.get("merchant_id")
prospect_id = data.get("prospect_id")
# Prefer domain_name for slug (shorter, cleaner), fall back to business_name
slug_source = data.get("domain_name") or business_name
slug = _slugify(slug_source)
# Find hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if not platform:
raise ValueError("Hosting platform not found. Run init_production first.")
# Create a temporary merchant-less store requires a merchant_id.
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
# Use the platform's own admin store or create under a system merchant.
# For now, create store via AdminService which handles defaults.
store_code = slug.upper().replace("-", "_")[:50]
subdomain = slug
# Resolve merchant
if merchant_id:
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
elif prospect_id:
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
else:
raise ValueError("Either merchant_id or prospect_id is required")
# Check for duplicate subdomain
from app.modules.tenancy.models import Store
subdomain = slug
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
# We need a system merchant for POC sites.
# Look for one or create if needed.
from app.modules.tenancy.models import Merchant
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not system_merchant:
system_merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
is_active=True,
is_verified=True,
)
db.add(system_merchant)
db.flush()
store_code = slug.upper().replace("-", "_")[:50]
store_data = StoreCreate(
merchant_id=system_merchant.id,
merchant_id=merchant.id,
store_code=store_code,
subdomain=subdomain,
name=business_name,
@@ -142,7 +157,7 @@ class HostedSiteService:
site = HostedSite(
store_id=store.id,
prospect_id=data.get("prospect_id"),
prospect_id=prospect_id,
status=HostedSiteStatus.DRAFT,
business_name=business_name,
contact_name=data.get("contact_name"),
@@ -153,12 +168,14 @@ class HostedSiteService:
db.add(site)
db.flush()
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
return site
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
"""Create a hosted site pre-filled from prospect data."""
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
"""Create a merchant from prospect data."""
from app.modules.prospecting.models import Prospect
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
@@ -166,20 +183,29 @@ class HostedSiteService:
raise ProspectNotFoundException(str(prospect_id))
# Get primary contact info from prospect contacts
# Get contact info: prefer form data, fall back to prospect contacts
contacts = prospect.contacts or []
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
contact_name = next((c.label for c in contacts if c.label), None)
email = (
data.get("contact_email")
or next((c.value for c in contacts if c.contact_type == "email"), None)
or f"contact-{prospect_id}@hostwizard.lu"
)
phone = data.get("contact_phone") or next(
(c.value for c in contacts if c.contact_type == "phone"), None
)
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
data = {
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
"contact_name": contact_name,
"contact_email": primary_email,
"contact_phone": primary_phone,
"prospect_id": prospect.id,
}
return self.create(db, data)
merchant_data = MerchantCreate(
name=business_name,
contact_email=email,
contact_phone=phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
return merchant
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
site = self.get_by_id(db, site_id)
@@ -227,37 +253,25 @@ class HostedSiteService:
def accept_proposal(
self, db: Session, site_id: int, merchant_id: int | None = None
) -> HostedSite:
"""Accept proposal: create or link merchant, create subscription, mark converted."""
"""Accept proposal: create subscription, mark prospect converted.
The merchant already exists (assigned at site creation time).
Optionally pass merchant_id to reassign to a different merchant.
"""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
# Use provided merchant_id to reassign, or keep existing store merchant
if merchant_id:
# Link to existing merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
site.store.merchant_id = merchant.id
db.flush()
else:
# Create new merchant from contact info
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
merchant_data = MerchantCreate(
name=site.business_name,
contact_email=email,
contact_phone=site.contact_phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s for site %d", merchant.name, site_id)
# Re-assign store to the real merchant
site.store.merchant_id = merchant.id
db.flush()
merchant = site.store.merchant
# Create MerchantSubscription on hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
@@ -286,7 +300,6 @@ class HostedSiteService:
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
if prospect and prospect.status != ProspectStatus.CONVERTED:
prospect.status = ProspectStatus.CONVERTED
db.flush()
db.flush()
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)

View File

@@ -0,0 +1,253 @@
# app/modules/hosting/services/poc_builder_service.py
"""
POC Builder Service — creates a near-final multi-page website from
a prospect + industry template.
Flow:
1. Load prospect data (scraped content, contacts)
2. Load industry template (pages, theme)
3. Create HostedSite + Store via hosted_site_service
4. Populate CMS ContentPages from template, replacing {{placeholders}}
with prospect data
5. Apply StoreTheme from template
6. Result: a previewable site at {subdomain}.hostwizard.lu
"""
import json
import logging
import re
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.hosting.services.hosted_site_service import hosted_site_service
from app.modules.hosting.services.template_service import template_service
logger = logging.getLogger(__name__)
class PocBuilderService:
"""Builds POC sites from prospect data + industry templates."""
def build_poc(
self,
db: Session,
prospect_id: int,
template_id: str,
merchant_id: int | None = None,
) -> dict:
"""Build a complete POC site from prospect data and a template.
Returns dict with hosted_site, store, pages_created, theme_applied.
"""
from app.modules.prospecting.models import Prospect
# 1. Load prospect
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
from app.modules.prospecting.exceptions import ProspectNotFoundException
raise ProspectNotFoundException(str(prospect_id))
# 2. Load template
template = template_service.get_template(template_id)
if not template:
raise ValueError(f"Template '{template_id}' not found")
# 3. Build placeholder context from prospect data
context = self._build_context(prospect)
# 4. Create HostedSite + Store
site_data = {
"business_name": context["business_name"],
"domain_name": prospect.domain_name, # used for clean subdomain slug
"prospect_id": prospect_id,
"contact_email": context.get("email"),
"contact_phone": context.get("phone"),
}
if merchant_id:
site_data["merchant_id"] = merchant_id
site = hosted_site_service.create(db, site_data)
# 5. Get the hosting platform_id from the store
from app.modules.tenancy.models import StorePlatform
store_platform = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == site.store_id)
.first()
)
platform_id = store_platform.platform_id if store_platform else None
if not platform_id:
logger.warning("No platform found for store %d", site.store_id)
# 6. Populate CMS ContentPages from template
pages_created = 0
if platform_id:
pages_created = self._create_pages(db, site.store_id, platform_id, template, context)
# 7. Apply StoreTheme
theme_applied = self._apply_theme(db, site.store_id, template)
# 8. Mark POC ready
hosted_site_service.mark_poc_ready(db, site.id)
db.flush()
logger.info(
"POC built for prospect %d: site=%d, store=%d, %d pages, template=%s",
prospect_id, site.id, site.store_id, pages_created, template_id,
)
return {
"hosted_site_id": site.id,
"store_id": site.store_id,
"pages_created": pages_created,
"theme_applied": theme_applied,
"template_id": template_id,
"subdomain": site.store.subdomain if site.store else None,
}
def _build_context(self, prospect) -> dict:
"""Build placeholder replacement context from prospect data."""
# Base context
context = {
"business_name": prospect.business_name or prospect.domain_name or "My Business",
"domain": prospect.domain_name or "",
"city": prospect.city or "",
"address": "",
"email": "",
"phone": "",
"meta_description": "",
"about_paragraph": "",
}
# From contacts
contacts = prospect.contacts or []
for c in contacts:
if c.contact_type == "email" and not context["email"]:
context["email"] = c.value
elif c.contact_type == "phone" and not context["phone"]:
context["phone"] = c.value
elif c.contact_type == "address" and not context["address"]:
context["address"] = c.value
# From scraped content
if prospect.scraped_content_json:
try:
scraped = json.loads(prospect.scraped_content_json)
if scraped.get("meta_description"):
context["meta_description"] = scraped["meta_description"]
if scraped.get("paragraphs"):
context["about_paragraph"] = scraped["paragraphs"][0]
if scraped.get("headings") and not prospect.business_name:
context["business_name"] = scraped["headings"][0]
except (json.JSONDecodeError, KeyError):
pass
# From prospect fields
if prospect.city:
context["city"] = prospect.city
elif context["address"]:
# Try to extract city from address (last word after postal code)
parts = context["address"].split()
if len(parts) >= 2:
context["city"] = parts[-1]
return context
def _replace_placeholders(self, text: str, context: dict) -> str:
"""Replace {{placeholder}} variables in text with context values."""
if not text:
return text
def replacer(match):
key = match.group(1).strip()
return context.get(key, match.group(0))
return re.sub(r"\{\{(\w+)\}\}", replacer, text)
def _replace_in_structure(self, data, context: dict):
"""Recursively replace placeholders in a nested dict/list structure."""
if isinstance(data, str):
return self._replace_placeholders(data, context)
if isinstance(data, dict):
return {k: self._replace_in_structure(v, context) for k, v in data.items()}
if isinstance(data, list):
return [self._replace_in_structure(item, context) for item in data]
return data
def _create_pages(self, db: Session, store_id: int, platform_id: int, template: dict, context: dict) -> int:
"""Create CMS ContentPages from template page definitions."""
from app.modules.cms.models.content_page import ContentPage
count = 0
for page_def in template.get("pages", []):
slug = page_def.get("slug", "")
if not slug:
continue
# Replace placeholders in all text fields
page_data = self._replace_in_structure(page_def, context)
# Build content from content_translations if present
content = page_data.get("content", "")
content_translations = page_data.get("content_translations")
if content_translations and not content:
content = next(iter(content_translations.values()), "")
page = ContentPage(
platform_id=platform_id,
store_id=store_id,
is_platform_page=False,
slug=slug,
title=page_data.get("title", slug.title()),
content=content or f"<p>{slug.title()} page content</p>",
content_format="html",
template=page_data.get("template", "default"),
sections=page_data.get("sections"),
title_translations=page_data.get("title_translations"),
content_translations=content_translations,
meta_description=context.get("meta_description"),
is_published=page_data.get("is_published", True),
published_at=datetime.now(UTC) if page_data.get("is_published", True) else None,
show_in_header=page_data.get("show_in_header", False),
show_in_footer=page_data.get("show_in_footer", False),
)
db.add(page)
count += 1
db.flush()
return count
def _apply_theme(self, db: Session, store_id: int, template: dict) -> bool:
"""Apply the template's theme to the store."""
from app.modules.cms.models.store_theme import StoreTheme
theme_data = template.get("theme")
if not theme_data:
return False
# Check if store already has a theme
existing = db.query(StoreTheme).filter(StoreTheme.store_id == store_id).first()
if existing:
# Update existing
theme = existing
else:
theme = StoreTheme(store_id=store_id)
db.add(theme)
colors = theme_data.get("colors", {})
theme.theme_name = theme_data.get("theme_name", "default")
theme.colors = colors
theme.font_family_heading = theme_data.get("font_family_heading", "Inter")
theme.font_family_body = theme_data.get("font_family_body", "Inter")
theme.layout_style = theme_data.get("layout_style", "grid")
theme.header_style = theme_data.get("header_style", "fixed")
db.flush()
return True
poc_builder_service = PocBuilderService()

View File

@@ -0,0 +1,114 @@
# app/modules/hosting/services/template_service.py
"""
Template service for the hosting module.
Loads and manages industry templates from the templates_library directory.
Templates are JSON files that define page content, themes, and sections
for different business types (restaurant, construction, etc.).
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates_library"
class TemplateService:
"""Manages industry templates for POC site generation."""
def __init__(self):
self._manifest = None
self._cache: dict[str, dict] = {}
def _load_manifest(self) -> dict:
"""Load the manifest.json file."""
if self._manifest is None:
manifest_path = TEMPLATES_DIR / "manifest.json"
self._manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
return self._manifest
def list_templates(self) -> list[dict]:
"""List all available templates with metadata."""
manifest = self._load_manifest()
templates = []
for entry in manifest.get("templates", []):
template_id = entry["id"]
meta = self._load_meta(template_id)
templates.append({
"id": template_id,
"name": meta.get("name", entry.get("name", template_id)),
"description": meta.get("description", entry.get("description", "")),
"tags": meta.get("tags", entry.get("tags", [])),
"languages": meta.get("languages", []),
"pages": entry.get("pages", []),
})
return templates
def get_template(self, template_id: str) -> dict | None:
"""Load a complete template with meta, theme, and all pages."""
if template_id in self._cache:
return self._cache[template_id]
template_dir = TEMPLATES_DIR / template_id
if not template_dir.is_dir():
return None
meta = self._load_meta(template_id)
theme = self._load_json(template_dir / "theme.json")
pages = self._load_pages(template_dir)
template = {
"id": template_id,
"meta": meta,
"theme": theme,
"pages": pages,
}
self._cache[template_id] = template
return template
def get_theme(self, template_id: str) -> dict | None:
"""Load just the theme configuration for a template."""
template_dir = TEMPLATES_DIR / template_id
return self._load_json(template_dir / "theme.json")
def get_page(self, template_id: str, page_slug: str) -> dict | None:
"""Load a single page definition from a template."""
page_path = TEMPLATES_DIR / template_id / "pages" / f"{page_slug}.json"
return self._load_json(page_path)
def template_exists(self, template_id: str) -> bool:
"""Check if a template exists."""
return (TEMPLATES_DIR / template_id / "meta.json").is_file()
def _load_meta(self, template_id: str) -> dict:
"""Load meta.json for a template."""
return self._load_json(TEMPLATES_DIR / template_id / "meta.json") or {}
def _load_pages(self, template_dir: Path) -> list[dict]:
"""Load all page JSONs from a template's pages/ directory."""
pages_dir = template_dir / "pages"
if not pages_dir.is_dir():
return []
pages = []
for page_file in sorted(pages_dir.glob("*.json")):
page_data = self._load_json(page_file)
if page_data:
pages.append(page_data)
return pages
@staticmethod
def _load_json(path: Path) -> dict | None:
"""Safely load a JSON file."""
if not path.is_file():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load template file %s: %s", path, e)
return None
template_service = TemplateService()

View File

@@ -12,7 +12,8 @@
{{ loading_state('Loading site...') }}
{{ error_state('Error loading site') }}
<div x-show="!loading && !error && site" class="space-y-6">
<template x-if="!loading && !error && site">
<div class="space-y-6">
<!-- Header -->
<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">
@@ -38,6 +39,29 @@
</div>
</div>
<!-- Build POC (draft sites only) -->
<div x-show="site.status === 'draft' && site.prospect_id" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Build POC from Template</h3>
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[200px]">
<select x-model="selectedTemplate"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-teal-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">Select a template...</option>
<template x-for="t in templates" :key="t.id">
<option :value="t.id" x-text="t.name + ' — ' + t.description"></option>
</template>
</select>
</div>
<button type="button" @click="buildPoc()" :disabled="!selectedTemplate || buildingPoc"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50">
<span x-show="!buildingPoc" x-html="$icon('sparkles', 'w-4 h-4 mr-2')"></span>
<span x-show="buildingPoc" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="buildingPoc ? 'Building...' : 'Build POC'"></span>
</button>
</div>
<p x-show="pocResult" class="mt-2 text-sm text-green-600" x-text="pocResult"></p>
</div>
<!-- Lifecycle Actions -->
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
@@ -187,6 +211,7 @@
</div>
</div>
</div>
</template>
<!-- Send Proposal Modal -->
{% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
@@ -305,7 +330,39 @@ function hostingSiteDetail(siteId) {
acceptMerchantId: '',
goLiveDomain: '',
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
async init() { await this.loadSite(); },
// POC builder
templates: [],
selectedTemplate: '',
buildingPoc: false,
pocResult: '',
async init() {
await this.loadSite();
await this.loadTemplates();
},
async loadTemplates() {
try {
var resp = await apiClient.get('/admin/hosting/sites/templates');
this.templates = resp.templates || [];
} catch (e) { /* ignore */ }
},
async buildPoc() {
if (!this.selectedTemplate || !this.site.prospect_id) return;
this.buildingPoc = true;
this.pocResult = '';
try {
var result = await apiClient.post('/admin/hosting/sites/poc/build', {
prospect_id: this.site.prospect_id,
template_id: this.selectedTemplate,
});
this.pocResult = 'POC built! ' + result.pages_created + ' pages created.';
Utils.showToast('POC built successfully', 'success');
await this.loadSite();
} catch (e) {
Utils.showToast('Build failed: ' + e.message, 'error');
} finally {
this.buildingPoc = false;
}
},
async loadSite() {
this.loading = true;
this.error = null;

View File

@@ -44,18 +44,48 @@
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<!-- Prospect Selector -->
<!-- Prospect Search -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Create from Prospect (optional)</label>
<div class="flex mt-1 space-x-2">
<input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<button type="button" @click="createFromProspect()"
:disabled="!prospectId || creating"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
Create from Prospect
</button>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Link to Prospect <span class="text-red-500">*</span>
</label>
<div class="relative">
<input type="text" x-model="prospectSearch" @input.debounce.300ms="searchProspects()"
@focus="showProspectDropdown = true"
placeholder="Search by domain or business name..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<!-- Selected prospect badge -->
<div x-show="selectedProspect" class="absolute right-2 top-1/2 -translate-y-1/2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300">
<span x-text="'#' + form.prospect_id"></span>
<button type="button" @click="clearProspect()" class="ml-1 text-teal-500 hover:text-teal-700">&times;</button>
</span>
</div>
<!-- Dropdown -->
<div x-show="showProspectDropdown && prospectResults.length > 0" @click.away="showProspectDropdown = false"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-auto">
<template x-for="p in prospectResults" :key="p.id">
<button type="button" @click="selectProspect(p)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex justify-between items-center">
<div>
<span class="font-medium text-gray-700 dark:text-gray-200" x-text="p.business_name || p.domain_name"></span>
<span x-show="p.domain_name && p.business_name" class="text-xs text-gray-400 ml-2" x-text="p.domain_name"></span>
</div>
<span class="text-xs text-gray-400" x-text="'#' + p.id"></span>
</button>
</template>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">A merchant will be auto-created from the prospect's contact data.</p>
</div>
<!-- Optional: Existing Merchant -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Or link to existing Merchant ID <span class="text-xs text-gray-400">(optional)</span>
</label>
<input type="number" x-model.number="form.merchant_id" placeholder="Leave empty to auto-create" {# noqa: FE008 #}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
@@ -66,10 +96,8 @@
Cancel
</a>
<button type="button" @click="createSite()"
:disabled="!form.business_name || creating"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
<span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
:disabled="!canCreate || creating"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
<span x-text="creating ? 'Creating...' : 'Create Site'"></span>
</button>
</div>
@@ -85,15 +113,76 @@ function hostingSiteNew() {
return {
...data(),
currentPage: 'hosting-sites',
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '',
form: {
business_name: '',
prospect_id: null,
merchant_id: null,
contact_name: '',
contact_email: '',
contact_phone: '',
internal_notes: '',
},
// Prospect search
prospectSearch: '',
prospectResults: [],
selectedProspect: null,
showProspectDropdown: false,
creating: false,
errorMsg: '',
get canCreate() {
return this.form.business_name && (this.form.prospect_id || this.form.merchant_id);
},
async searchProspects() {
if (this.prospectSearch.length < 2) { this.prospectResults = []; return; }
try {
var resp = await apiClient.get('/admin/prospecting/prospects?search=' + encodeURIComponent(this.prospectSearch) + '&per_page=10');
this.prospectResults = resp.items || [];
this.showProspectDropdown = true;
} catch (e) {
this.prospectResults = [];
}
},
selectProspect(prospect) {
this.selectedProspect = prospect;
this.form.prospect_id = prospect.id;
this.prospectSearch = prospect.business_name || prospect.domain_name;
this.showProspectDropdown = false;
// Auto-fill form from prospect
if (!this.form.business_name) {
this.form.business_name = prospect.business_name || prospect.domain_name || '';
}
if (!this.form.contact_email && prospect.primary_email) {
this.form.contact_email = prospect.primary_email;
}
if (!this.form.contact_phone && prospect.primary_phone) {
this.form.contact_phone = prospect.primary_phone;
}
},
clearProspect() {
this.selectedProspect = null;
this.form.prospect_id = null;
this.prospectSearch = '';
this.prospectResults = [];
},
async createSite() {
if (!this.canCreate) {
this.errorMsg = 'Business name and a linked prospect or merchant are required';
return;
}
this.creating = true;
this.errorMsg = '';
try {
const site = await apiClient.post('/admin/hosting/sites', this.form);
var payload = {};
for (var k in this.form) {
if (this.form[k] !== null && this.form[k] !== '') payload[k] = this.form[k];
}
const site = await apiClient.post('/admin/hosting/sites', payload);
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) {
this.errorMsg = e.message || 'Failed to create site';
@@ -101,18 +190,6 @@ function hostingSiteNew() {
this.creating = false;
}
},
async createFromProspect() {
this.creating = true;
this.errorMsg = '';
try {
const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) {
this.errorMsg = e.message || 'Failed to create from prospect';
} finally {
this.creating = false;
}
},
};
}
</script>

View File

@@ -40,11 +40,6 @@
<option value="cancelled">Cancelled</option>
</select>
<a href="/admin/prospecting/prospects"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect
</a>
</div>
</div>
</div>
@@ -95,6 +90,11 @@
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button type="button" @click="deleteSite(s)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete">
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
@@ -144,6 +144,16 @@ function hostingSitesList() {
this.loading = false;
}
},
async deleteSite(site) {
if (!confirm('Delete "' + site.business_name + '"? This will also delete the associated store.')) return;
try {
await apiClient.delete('/admin/hosting/sites/' + site.id);
Utils.showToast('Site deleted', 'success');
await this.loadSites();
} catch (e) {
Utils.showToast('Failed: ' + e.message, 'error');
}
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;

View File

@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.hw-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: linear-gradient(135deg, #0D9488, #14B8A6);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
.hw-banner-logo { font-weight: 700; font-size: 16px; }
.hw-banner-text { opacity: 0.9; }
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
.hw-banner-link {
color: white;
text-decoration: none;
padding: 6px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
font-size: 13px;
transition: background 0.2s;
}
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
.hw-iframe-container {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
}
.hw-iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div class="hw-banner">
<div class="hw-banner-left">
<span class="hw-banner-logo">HostWizard</span>
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
</div>
<div class="hw-banner-right">
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
</div>
</div>
<div class="hw-iframe-container">
<iframe src="{{ store_url }}" title="Site preview"></iframe>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
{"id": "auto-parts", "name": "Auto Parts & Garage", "description": "Template for auto parts shops, garages, and car dealers", "tags": ["automotive", "garage", "car", "parts"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,12 @@
{
"slug": "catalog",
"title": "Catalog",
"title_translations": {"en": "Parts Catalog", "fr": "Catalogue de pièces", "de": "Teilekatalog"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Parts Catalog</h2>\n<p>Browse our extensive catalog of auto parts for all major brands.</p>",
"fr": "<h2>Catalogue de pièces</h2>\n<p>Parcourez notre catalogue complet de pièces auto pour toutes les grandes marques.</p>"
}
}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Contact Us</h2>\n<p>Visit our store or get in touch for parts inquiries.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>Visitez notre magasin ou contactez-nous pour vos demandes de pièces.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,34 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Your trusted auto parts specialist in {{city}}", "fr": "Votre spécialiste pièces auto de confiance à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Why Choose Us", "fr": "Pourquoi nous choisir"}},
"items": [
{"icon": "truck", "title": {"translations": {"en": "Fast Delivery", "fr": "Livraison rapide"}}, "description": {"translations": {"en": "Same-day delivery on in-stock parts", "fr": "Livraison le jour même pour les pièces en stock"}}},
{"icon": "shield-check", "title": {"translations": {"en": "Quality Guaranteed", "fr": "Qualité garantie"}}, "description": {"translations": {"en": "OEM and certified aftermarket parts", "fr": "Pièces OEM et aftermarket certifiées"}}},
{"icon": "currency-euro", "title": {"translations": {"en": "Best Prices", "fr": "Meilleurs prix"}}, "description": {"translations": {"en": "Competitive pricing on all brands", "fr": "Prix compétitifs sur toutes les marques"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Need a specific part?", "fr": "Besoin d'une pièce spécifique ?"}},
"buttons": [
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,8 @@
{
"theme_name": "modern",
"colors": {"primary": "#dc2626", "secondary": "#991b1b", "accent": "#f59e0b", "background": "#fafafa", "text": "#18181b", "border": "#e4e4e7"},
"font_family_heading": "Montserrat",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1 @@
{"id": "construction", "name": "Construction & Renovation", "description": "Professional template for builders, renovators, and tradespeople", "tags": ["construction", "renovation", "building", "trades"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Get a Free Quote</h2>\n<p>Tell us about your project and we'll get back to you within 24 hours.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Demandez un devis gratuit</h2>\n<p>Décrivez-nous votre projet et nous vous recontacterons sous 24h.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,40 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Quality construction and renovation in {{city}}", "fr": "Construction et rénovation de qualité à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Our Services", "fr": "Nos Services"}},
"items": [
{"icon": "home", "title": {"translations": {"en": "New Construction", "fr": "Construction neuve"}}, "description": {"translations": {"en": "Custom-built homes and commercial buildings", "fr": "Maisons et bâtiments commerciaux sur mesure"}}},
{"icon": "wrench", "title": {"translations": {"en": "Renovation", "fr": "Rénovation"}}, "description": {"translations": {"en": "Complete interior and exterior renovation", "fr": "Rénovation complète intérieure et extérieure"}}},
{"icon": "color-swatch", "title": {"translations": {"en": "Painting & Finishing", "fr": "Peinture & Finitions"}}, "description": {"translations": {"en": "Professional painting and finishing work", "fr": "Travaux de peinture et finitions professionnels"}}},
{"icon": "shield-check", "title": {"translations": {"en": "Insulation", "fr": "Isolation"}}, "description": {"translations": {"en": "Energy-efficient insulation solutions", "fr": "Solutions d'isolation éco-énergétiques"}}}
]
},
"testimonials": {
"enabled": true,
"title": {"translations": {"en": "What Our Clients Say", "fr": "Témoignages de nos clients"}},
"items": []
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Ready to start your project?", "fr": "Prêt à démarrer votre projet ?"}},
"buttons": [
{"label": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "projects",
"title": "Projects",
"title_translations": {"en": "Our Projects", "fr": "Nos Réalisations", "de": "Unsere Projekte"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Projects</h2>\n<p>Browse our portfolio of completed construction and renovation projects.</p>",
"fr": "<h2>Nos Réalisations</h2>\n<p>Découvrez notre portfolio de projets de construction et rénovation réalisés.</p>"
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "services",
"title": "Services",
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Services</h2>\n<p>We offer a comprehensive range of construction and renovation services.</p>\n<h3>Construction</h3>\n<p>From foundations to finishing touches, we handle every aspect of new builds.</p>\n<h3>Renovation</h3>\n<p>Transform your existing space with our expert renovation team.</p>\n<h3>Painting & Decoration</h3>\n<p>Professional interior and exterior painting services.</p>",
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons une gamme complète de services de construction et rénovation.</p>\n<h3>Construction</h3>\n<p>Des fondations aux finitions, nous gérons chaque aspect des constructions neuves.</p>\n<h3>Rénovation</h3>\n<p>Transformez votre espace avec notre équipe de rénovation experte.</p>\n<h3>Peinture & Décoration</h3>\n<p>Services professionnels de peinture intérieure et extérieure.</p>"
}
}

View File

@@ -0,0 +1,15 @@
{
"theme_name": "modern",
"colors": {
"primary": "#d97706",
"secondary": "#92400e",
"accent": "#fbbf24",
"background": "#fafaf9",
"text": "#1c1917",
"border": "#d6d3d1"
},
"font_family_heading": "Montserrat",
"font_family_body": "Open Sans",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1,7 @@
{
"id": "generic",
"name": "Generic Business",
"description": "Clean, minimal template that works for any business type",
"tags": ["general", "minimal", "any"],
"languages": ["en", "fr", "de"]
}

View File

@@ -0,0 +1,13 @@
{
"slug": "about",
"title": "About Us",
"title_translations": {"en": "About Us", "fr": "À propos", "de": "Über uns"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content": "{{about_content}}",
"content_translations": {
"en": "<h2>About {{business_name}}</h2>\n<p>{{about_paragraph}}</p>",
"fr": "<h2>À propos de {{business_name}}</h2>\n<p>{{about_paragraph}}</p>"
}
}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contact", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Get in Touch</h2>\n<p>We'd love to hear from you. Reach out using the information below.</p>\n<ul>\n<li>Email: {{email}}</li>\n<li>Phone: {{phone}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>N'hésitez pas à nous contacter.</p>\n<ul>\n<li>Email : {{email}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,35 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"show_in_header": false,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "{{meta_description}}", "fr": "{{meta_description}}"}},
"background_type": "gradient",
"buttons": [
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "What We Offer", "fr": "Nos Services"}},
"items": [
{"icon": "shield-check", "title": {"translations": {"en": "Quality", "fr": "Qualité"}}, "description": {"translations": {"en": "Committed to excellence in everything we do", "fr": "Engagés pour l'excellence dans tout ce que nous faisons"}}},
{"icon": "clock", "title": {"translations": {"en": "Reliability", "fr": "Fiabilité"}}, "description": {"translations": {"en": "Dependable service you can count on", "fr": "Un service fiable sur lequel vous pouvez compter"}}},
{"icon": "users", "title": {"translations": {"en": "Experience", "fr": "Expérience"}}, "description": {"translations": {"en": "Years of expertise at your service", "fr": "Des années d'expertise à votre service"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Ready to get started?", "fr": "Prêt à commencer ?"}},
"subtitle": {"translations": {"en": "Contact us today for a free consultation", "fr": "Contactez-nous pour une consultation gratuite"}},
"buttons": [
{"label": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"theme_name": "modern",
"colors": {
"primary": "#3b82f6",
"secondary": "#1e40af",
"accent": "#f59e0b",
"background": "#ffffff",
"text": "#1e293b",
"border": "#e2e8f0"
},
"font_family_heading": "Inter",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1,40 @@
{
"version": "1.0",
"templates": [
{
"id": "generic",
"name": "Generic Business",
"description": "Clean, minimal template that works for any business type",
"tags": ["general", "minimal", "any"],
"pages": ["homepage", "about", "contact"]
},
{
"id": "restaurant",
"name": "Restaurant & Dining",
"description": "Elegant template for restaurants, cafés, bars, and catering",
"tags": ["food", "dining", "hospitality", "café"],
"pages": ["homepage", "about", "menu", "contact"]
},
{
"id": "construction",
"name": "Construction & Renovation",
"description": "Professional template for builders, renovators, and tradespeople",
"tags": ["construction", "renovation", "building", "trades"],
"pages": ["homepage", "services", "projects", "contact"]
},
{
"id": "auto-parts",
"name": "Auto Parts & Garage",
"description": "Template for auto parts shops, garages, and car dealers",
"tags": ["automotive", "garage", "car", "parts"],
"pages": ["homepage", "catalog", "contact"]
},
{
"id": "professional-services",
"name": "Professional Services",
"description": "Template for lawyers, accountants, consultants, and agencies",
"tags": ["professional", "consulting", "legal", "finance"],
"pages": ["homepage", "services", "team", "contact"]
}
]
}

View File

@@ -0,0 +1 @@
{"id": "professional-services", "name": "Professional Services", "description": "Template for lawyers, accountants, consultants, and agencies", "tags": ["professional", "consulting", "legal", "finance"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Contact Us</h2>\n<p>Schedule a consultation or reach out with any questions.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>Planifiez une consultation ou posez-nous vos questions.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,34 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Professional expertise you can trust", "fr": "Une expertise professionnelle de confiance"}},
"background_type": "gradient",
"buttons": [
{"label": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Areas of Expertise", "fr": "Domaines d'expertise"}},
"items": [
{"icon": "briefcase", "title": {"translations": {"en": "Advisory", "fr": "Conseil"}}, "description": {"translations": {"en": "Strategic guidance tailored to your needs", "fr": "Conseils stratégiques adaptés à vos besoins"}}},
{"icon": "document-text", "title": {"translations": {"en": "Compliance", "fr": "Conformité"}}, "description": {"translations": {"en": "Ensure regulatory compliance across your operations", "fr": "Assurez la conformité réglementaire de vos opérations"}}},
{"icon": "chart-bar", "title": {"translations": {"en": "Analysis", "fr": "Analyse"}}, "description": {"translations": {"en": "Data-driven insights for informed decisions", "fr": "Analyses basées sur les données pour des décisions éclairées"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Need professional guidance?", "fr": "Besoin d'un accompagnement professionnel ?"}},
"buttons": [
{"label": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "services",
"title": "Services",
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Services</h2>\n<p>We provide comprehensive professional services to help your business thrive.</p>",
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons des services professionnels complets pour aider votre entreprise à prospérer.</p>"
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "team",
"title": "Team",
"title_translations": {"en": "Our Team", "fr": "Notre Équipe", "de": "Unser Team"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Team</h2>\n<p>Meet the professionals behind {{business_name}}.</p>",
"fr": "<h2>Notre Équipe</h2>\n<p>Découvrez les professionnels derrière {{business_name}}.</p>"
}
}

View File

@@ -0,0 +1,8 @@
{
"theme_name": "modern",
"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "accent": "#3b82f6", "background": "#f8fafc", "text": "#0f172a", "border": "#cbd5e1"},
"font_family_heading": "Merriweather",
"font_family_body": "Source Sans Pro",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1 @@
{"id": "restaurant", "name": "Restaurant & Dining", "description": "Elegant template for restaurants, cafés, bars, and catering", "tags": ["food", "dining", "hospitality"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,12 @@
{
"slug": "about",
"title": "About",
"title_translations": {"en": "Our Story", "fr": "Notre Histoire", "de": "Über uns"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Story</h2>\n<p>{{about_paragraph}}</p>",
"fr": "<h2>Notre Histoire</h2>\n<p>{{about_paragraph}}</p>"
}
}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Visit Us", "fr": "Nous Rendre Visite", "de": "Besuchen Sie uns"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Visit Us</h2>\n<p>We look forward to welcoming you.</p>\n<ul>\n<li>Address: {{address}}</li>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n</ul>",
"fr": "<h2>Nous Rendre Visite</h2>\n<p>Nous avons hâte de vous accueillir.</p>\n<ul>\n<li>Adresse : {{address}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,39 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "A culinary experience in {{city}}", "fr": "Une expérience culinaire à {{city}}"}},
"background_type": "image",
"buttons": [
{"label": {"translations": {"en": "Reserve a Table", "fr": "Réserver une table"}}, "url": "/contact", "style": "primary"},
{"label": {"translations": {"en": "See Our Menu", "fr": "Voir la carte"}}, "url": "/menu", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Our Specialties", "fr": "Nos Spécialités"}},
"items": [
{"icon": "fire", "title": {"translations": {"en": "Fresh Ingredients", "fr": "Produits frais"}}, "description": {"translations": {"en": "Locally sourced, seasonal ingredients", "fr": "Produits locaux et de saison"}}},
{"icon": "star", "title": {"translations": {"en": "Chef's Selection", "fr": "Sélection du chef"}}, "description": {"translations": {"en": "Carefully crafted dishes by our expert chef", "fr": "Plats élaborés par notre chef expert"}}},
{"icon": "heart", "title": {"translations": {"en": "Warm Atmosphere", "fr": "Ambiance chaleureuse"}}, "description": {"translations": {"en": "A welcoming space for every occasion", "fr": "Un espace accueillant pour chaque occasion"}}}
]
},
"testimonials": {
"enabled": true,
"title": {"translations": {"en": "What Our Guests Say", "fr": "Ce que disent nos clients"}},
"items": []
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Ready to dine with us?", "fr": "Prêt à nous rendre visite ?"}},
"buttons": [
{"label": {"translations": {"en": "Make a Reservation", "fr": "Réserver"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "menu",
"title": "Menu",
"title_translations": {"en": "Our Menu", "fr": "Notre Carte", "de": "Speisekarte"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Menu</h2>\n<p>Discover our selection of dishes, prepared with fresh, local ingredients.</p>",
"fr": "<h2>Notre Carte</h2>\n<p>Découvrez notre sélection de plats, préparés avec des produits frais et locaux.</p>"
}
}

View File

@@ -0,0 +1,15 @@
{
"theme_name": "modern",
"colors": {
"primary": "#b45309",
"secondary": "#78350f",
"accent": "#f59e0b",
"background": "#fffbeb",
"text": "#1c1917",
"border": "#e7e5e4"
},
"font_family_heading": "Playfair Display",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "transparent"
}

View File

@@ -89,6 +89,7 @@ def hosted_site(db, hosting_platform, system_merchant):
db,
{
"business_name": f"Test Business {unique}",
"merchant_id": system_merchant.id,
"contact_name": "John Doe",
"contact_email": f"john-{unique}@example.com",
"contact_phone": "+352 123 456",

View File

@@ -55,6 +55,7 @@ class TestHostedSiteService:
db,
{
"business_name": f"New Business {unique}",
"merchant_id": system_merchant.id,
"contact_email": f"test-{unique}@example.com",
},
)
@@ -72,6 +73,7 @@ class TestHostedSiteService:
db,
{
"business_name": f"Full Business {unique}",
"merchant_id": system_merchant.id,
"contact_name": "Jane Doe",
"contact_email": f"jane-{unique}@example.com",
"contact_phone": "+352 999 888",
@@ -251,75 +253,65 @@ class TestHostedSiteLifecycle:
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteFromProspect:
"""Tests for creating hosted sites from prospects."""
"""Tests for creating hosted sites from prospects via prospect_id."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site from a prospect."""
from app.modules.prospecting.models import Prospect
def test_create_with_prospect_id(self, db, hosting_platform):
"""Test creating a hosted site with prospect_id auto-creates merchant."""
from app.modules.prospecting.models import Prospect, ProspectContact
unique = uuid.uuid4().hex[:8]
prospect = Prospect(
channel="digital",
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
domain_name=f"prospect-{unique}.lu",
business_name="Prospect Business",
status="active",
has_website=True,
)
db.add(prospect)
db.flush()
# Add email contact (needed for merchant creation)
db.add(ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value=f"hello-{unique}@test.lu",
is_primary=True,
))
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
site = self.service.create(
db,
{
"business_name": "Prospect Business",
"prospect_id": prospect.id,
"contact_email": f"hello-{unique}@test.lu",
},
)
db.commit()
assert site.id is not None
assert site.prospect_id == prospect.id
assert site.business_name == "Prospect Business"
assert site.store.merchant is not None
def test_create_from_prospect_with_contacts(
self, db, hosting_platform, system_merchant
):
"""Test creating from prospect pre-fills contact info."""
from app.modules.prospecting.models import Prospect, ProspectContact
def test_create_without_merchant_or_prospect_raises(self, db, hosting_platform):
"""Test that creating without merchant_id or prospect_id raises ValueError."""
with pytest.raises(ValueError, match="Either merchant_id or prospect_id"):
self.service.create(
db,
{"business_name": "No Owner"},
)
prospect = Prospect(
channel="digital",
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
business_name="Contact Business",
status="active",
)
db.add(prospect)
db.flush()
email_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value="hello@test.lu",
is_primary=True,
)
phone_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value="+352 111 222",
is_primary=True,
)
db.add_all([email_contact, phone_contact])
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.contact_email == "hello@test.lu"
assert site.contact_phone == "+352 111 222"
def test_create_from_nonexistent_prospect(
self, db, hosting_platform, system_merchant
):
"""Test creating from non-existent prospect raises exception."""
def test_create_with_nonexistent_prospect_raises(self, db, hosting_platform):
"""Test creating with non-existent prospect raises exception."""
from app.modules.prospecting.exceptions import ProspectNotFoundException
with pytest.raises(ProspectNotFoundException):
self.service.create_from_prospect(db, 99999)
self.service.create(
db,
{"business_name": "Ghost", "prospect_id": 99999},
)

View File

@@ -26,6 +26,9 @@ class ModuleConfig(BaseSettings):
# Max concurrent HTTP requests for batch scanning
max_concurrent_requests: int = 10
# Delay between prospects in batch scans (seconds) — be polite to target sites
batch_delay_seconds: float = 1.0
model_config = {"env_prefix": "PROSPECTING_", "env_file": ".env", "extra": "ignore"}

View File

@@ -0,0 +1,74 @@
# Batch Scanning & Rate Limiting
## Overview
The prospecting module performs passive scans against prospect websites to gather intelligence. Batch operations process multiple prospects sequentially with a configurable delay between each.
## Scan Types
| Type | What It Does | HTTP Requests/Prospect |
|---|---|---|
| **HTTP Check** | Connectivity, HTTPS, redirects | 2 (HTTP + HTTPS) |
| **Tech Scan** | CMS, framework, server detection | 1 (homepage) |
| **Performance** | PageSpeed Insights audit | 1 (Google API) |
| **Contact Scrape** | Email, phone, address extraction | 6 (homepage + 5 subpages) |
| **Security Audit** | Headers, SSL, exposed files, cookies | ~35 (homepage + 30 path checks) |
| **Score Compute** | Calculate opportunity score | 0 (local computation) |
## Rate Limiting
### Configuration
```bash
# .env
PROSPECTING_BATCH_DELAY_SECONDS=1.0 # delay between prospects (default: 1s)
PROSPECTING_HTTP_TIMEOUT=10 # per-request timeout (default: 10s)
```
### Where Delays Apply
- **Batch API endpoints** (`POST /enrichment/*/batch`) — 1s delay between prospects
- **Celery background tasks** (`scan_tasks.py`) — same 1s delay
- **Full enrichment** (`POST /enrichment/full/{id}`) — no delay (single prospect)
- **Score compute batch** — no delay (no outbound HTTP)
### Scaling to 70k+ URLs
For bulk imports (e.g., domain registrar list), use Celery tasks with limits:
| Scan Type | Time per prospect | 70k URLs | Recommended Batch |
|---|---|---|---|
| HTTP Check | ~2s (timeout + delay) | ~39 hours | 500/batch via Celery |
| Tech Scan | ~2s | ~39 hours | 500/batch |
| Contact Scrape | ~12s (6 pages + delay) | ~10 days | 100/batch |
| Security Audit | ~40s (35 paths + delay) | ~32 days | 50/batch |
**Recommendation:** For 70k URLs, run HTTP Check first (fastest, filters out dead sites). Then run subsequent scans only on prospects with `has_website=True` (~50-70% of domains typically have working sites).
### Pipeline Order
```
1. HTTP Check batch → sets has_website, filters dead domains
2. Tech Scan batch → only where has_website=True
3. Contact Scrape → only where has_website=True
4. Security Audit → only where has_website=True
5. Score Compute → all prospects (local, fast)
```
Each scan type uses `last_*_at` timestamps to track what's been processed. Re-running a batch only processes prospects that haven't been scanned yet.
## User-Agent
All scans use a standard Chrome User-Agent:
```
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
```
The security audit also identifies as `OrionBot/1.0` in the contact scraper for transparency.
## Error Handling
- Individual prospect failures don't stop the batch
- Errors are logged but the next prospect continues
- The scan job record tracks `processed_items` vs `total_items`
- Celery tasks retry on failure (2 retries with exponential backoff)

View File

@@ -22,6 +22,7 @@ from app.modules.prospecting.models.prospect import (
from app.modules.prospecting.models.prospect_contact import ContactType, ProspectContact
from app.modules.prospecting.models.prospect_score import ProspectScore
from app.modules.prospecting.models.scan_job import JobStatus, JobType, ProspectScanJob
from app.modules.prospecting.models.security_audit import ProspectSecurityAudit
from app.modules.prospecting.models.tech_profile import ProspectTechProfile
__all__ = [
@@ -44,4 +45,5 @@ __all__ = [
"CampaignChannel",
"CampaignSendStatus",
"LeadType",
"ProspectSecurityAudit",
]

View File

@@ -69,10 +69,16 @@ class Prospect(Base, TimestampMixin):
last_tech_scan_at = Column(DateTime, nullable=True)
last_perf_scan_at = Column(DateTime, nullable=True)
last_contact_scrape_at = Column(DateTime, nullable=True)
last_security_audit_at = Column(DateTime, nullable=True)
last_content_scrape_at = Column(DateTime, nullable=True)
# Scraped page content for POC builder
scraped_content_json = Column(Text, nullable=True)
# Relationships
tech_profile = relationship("ProspectTechProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
performance_profile = relationship("ProspectPerformanceProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
security_audit = relationship("ProspectSecurityAudit", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
score = relationship("ProspectScore", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
contacts = relationship("ProspectContact", back_populates="prospect", cascade="all, delete-orphan")
interactions = relationship("ProspectInteraction", back_populates="prospect", cascade="all, delete-orphan")

View File

@@ -20,6 +20,7 @@ class JobType(str, enum.Enum):
SCORE_COMPUTE = "score_compute"
FULL_ENRICHMENT = "full_enrichment"
SECURITY_AUDIT = "security_audit"
CONTENT_SCRAPE = "content_scrape"
class JobStatus(str, enum.Enum):

View File

@@ -0,0 +1,59 @@
# app/modules/prospecting/models/security_audit.py
"""
Security audit results for a prospect's website.
Stores findings from passive security checks (HTTPS, headers, exposed files,
cookies, server info, technology detection). Follows the same 1:1 pattern as
ProspectTechProfile and ProspectPerformanceProfile.
"""
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ProspectSecurityAudit(Base, TimestampMixin):
"""Security audit results for a prospect's website."""
__tablename__ = "prospect_security_audits"
id = Column(Integer, primary_key=True, index=True)
prospect_id = Column(
Integer,
ForeignKey("prospects.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Overall score and grade
score = Column(Integer, nullable=False, default=0) # 0-100
grade = Column(String(2), nullable=False, default="F") # A+, A, B, C, D, F
# Detected language for bilingual reports
detected_language = Column(String(5), nullable=True, default="en")
# Findings stored as JSON (variable structure per check)
findings_json = Column(Text, nullable=True) # JSON list of finding dicts
# Denormalized severity counts (for dashboard queries without JSON parsing)
findings_count_critical = Column(Integer, nullable=False, default=0)
findings_count_high = Column(Integer, nullable=False, default=0)
findings_count_medium = Column(Integer, nullable=False, default=0)
findings_count_low = Column(Integer, nullable=False, default=0)
findings_count_info = Column(Integer, nullable=False, default=0)
# Key results (denormalized for quick access)
has_https = Column(Boolean, nullable=True)
has_valid_ssl = Column(Boolean, nullable=True)
ssl_expires_at = Column(DateTime, nullable=True)
missing_headers_json = Column(Text, nullable=True) # JSON list of header names
exposed_files_json = Column(Text, nullable=True) # JSON list of exposed paths
technologies_json = Column(Text, nullable=True) # JSON list of detected techs
# Scan metadata
scan_error = Column(Text, nullable=True)
# Relationships
prospect = relationship("Prospect", back_populates="security_audit")

View File

@@ -8,12 +8,15 @@ catch "batch" as a string before trying to parse it as int → 422.
"""
import logging
import time
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.prospecting.config import config as prospecting_config
from app.modules.prospecting.models import JobType
from app.modules.prospecting.schemas.enrichment import (
ContactScrapeResponse,
@@ -25,9 +28,18 @@ from app.modules.prospecting.schemas.enrichment import (
ScanSingleResponse,
ScoreComputeBatchResponse,
)
from app.modules.prospecting.schemas.security_audit import (
SecurityAuditSingleResponse,
)
from app.modules.prospecting.services.enrichment_service import enrichment_service
from app.modules.prospecting.services.prospect_service import prospect_service
from app.modules.prospecting.services.scoring_service import scoring_service
from app.modules.prospecting.services.security_audit_service import (
security_audit_service,
)
from app.modules.prospecting.services.security_report_service import (
security_report_service,
)
from app.modules.prospecting.services.stats_service import stats_service
from app.modules.tenancy.schemas.auth import UserContext
@@ -35,6 +47,12 @@ router = APIRouter(prefix="/enrichment")
logger = logging.getLogger(__name__)
def _batch_delay():
"""Delay between prospects in batch scans to avoid rate limiting."""
if prospecting_config.batch_delay_seconds > 0:
time.sleep(prospecting_config.batch_delay_seconds)
# ── Batch endpoints (must be before /{prospect_id} routes) ──────────────────
@@ -48,10 +66,12 @@ def http_check_batch(
job = stats_service.create_job(db,JobType.HTTP_CHECK)
prospects = prospect_service.get_pending_http_check(db, limit=limit)
results = []
for prospect in prospects:
for i, prospect in enumerate(prospects):
result = enrichment_service.check_http(db, prospect)
results.append(HttpCheckBatchItem(domain=prospect.domain_name, **result))
stats_service.complete_job(job,processed=len(results))
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(results))
db.commit()
return HttpCheckBatchResponse(processed=len(results), results=results)
@@ -66,11 +86,13 @@ def tech_scan_batch(
job = stats_service.create_job(db,JobType.TECH_SCAN)
prospects = prospect_service.get_pending_tech_scan(db, limit=limit)
count = 0
for prospect in prospects:
for i, prospect in enumerate(prospects):
result = enrichment_service.scan_tech_stack(db, prospect)
if result:
count += 1
stats_service.complete_job(job,processed=len(prospects))
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(prospects))
db.commit()
return ScanBatchResponse(processed=len(prospects), successful=count)
@@ -85,11 +107,13 @@ def performance_scan_batch(
job = stats_service.create_job(db,JobType.PERFORMANCE_SCAN)
prospects = prospect_service.get_pending_performance_scan(db, limit=limit)
count = 0
for prospect in prospects:
for i, prospect in enumerate(prospects):
result = enrichment_service.scan_performance(db, prospect)
if result:
count += 1
stats_service.complete_job(job,processed=len(prospects))
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(prospects))
db.commit()
return ScanBatchResponse(processed=len(prospects), successful=count)
@@ -104,11 +128,55 @@ def contact_scrape_batch(
job = stats_service.create_job(db,JobType.CONTACT_SCRAPE)
prospects = prospect_service.get_pending_contact_scrape(db, limit=limit)
count = 0
for prospect in prospects:
for i, prospect in enumerate(prospects):
contacts = enrichment_service.scrape_contacts(db, prospect)
if contacts:
count += 1
stats_service.complete_job(job,processed=len(prospects))
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(prospects))
db.commit()
return ScanBatchResponse(processed=len(prospects), successful=count)
@router.post("/content-scrape/batch", response_model=ScanBatchResponse)
def content_scrape_batch(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Scrape page content for pending prospects."""
job = stats_service.create_job(db, JobType.CONTENT_SCRAPE)
prospects = prospect_service.get_pending_content_scrape(db, limit=limit)
count = 0
for i, prospect in enumerate(prospects):
result = enrichment_service.scrape_content(db, prospect)
if result:
count += 1
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(prospects))
db.commit()
return ScanBatchResponse(processed=len(prospects), successful=count)
@router.post("/security-audit/batch", response_model=ScanBatchResponse)
def security_audit_batch(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Run security audit for pending prospects."""
job = stats_service.create_job(db, JobType.SECURITY_AUDIT)
prospects = prospect_service.get_pending_security_audit(db, limit=limit)
count = 0
for i, prospect in enumerate(prospects):
result = security_audit_service.run_audit(db, prospect)
if result:
count += 1
if i < len(prospects) - 1:
_batch_delay()
stats_service.complete_job(job, processed=len(prospects))
db.commit()
return ScanBatchResponse(processed=len(prospects), successful=count)
@@ -127,6 +195,28 @@ def compute_scores_batch(
return ScoreComputeBatchResponse(scored=count)
# ── Report endpoints ────────────────────────────────────────────────────────
@router.get("/security-audit/report/{prospect_id}", response_class=HTMLResponse)
def security_audit_report(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Generate branded HTML security audit report."""
prospect = prospect_service.get_by_id(db, prospect_id)
if not prospect.security_audit:
from app.exceptions.base import ResourceNotFoundException
raise ResourceNotFoundException("SecurityAudit", str(prospect_id))
html = security_report_service.generate_html_report(
audit=prospect.security_audit,
domain=prospect.domain_name,
)
return HTMLResponse(content=html)
# ── Single-prospect endpoints ───────────────────────────────────────────────
@@ -182,6 +272,40 @@ def scrape_contacts_single(
return ContactScrapeResponse(domain=prospect.domain_name, contacts_found=len(contacts))
@router.post("/security-audit/{prospect_id}", response_model=SecurityAuditSingleResponse)
def security_audit_single(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Run security audit for a single prospect."""
prospect = prospect_service.get_by_id(db, prospect_id)
audit = security_audit_service.run_audit(db, prospect)
db.commit()
findings_count = 0
if audit:
findings_count = audit.findings_count_critical + audit.findings_count_high + audit.findings_count_medium + audit.findings_count_low
return SecurityAuditSingleResponse(
domain=prospect.domain_name,
score=audit.score if audit else 0,
grade=audit.grade if audit else "F",
findings_count=findings_count,
)
@router.post("/content-scrape/{prospect_id}", response_model=ScanSingleResponse)
def content_scrape_single(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Scrape page content for a single prospect."""
prospect = prospect_service.get_by_id(db, prospect_id)
result = enrichment_service.scrape_content(db, prospect)
db.commit()
return ScanSingleResponse(domain=prospect.domain_name, profile=result is not None)
@router.post("/full/{prospect_id}", response_model=FullEnrichmentResponse)
def full_enrichment(
prospect_id: int = Path(...),
@@ -209,7 +333,15 @@ def full_enrichment(
if prospect.has_website:
contacts = enrichment_service.scrape_contacts(db, prospect)
# Step 5: Compute score
# Step 5: Content scrape (if has website)
if prospect.has_website:
enrichment_service.scrape_content(db, prospect)
# Step 6: Security audit (if has website)
if prospect.has_website:
security_audit_service.run_audit(db, prospect)
# Step 7: Compute score
db.refresh(prospect)
score = scoring_service.compute_score(db, prospect)
db.commit()

View File

@@ -75,6 +75,7 @@ class ProspectDetailResponse(ProspectResponse):
tech_profile: "TechProfileResponse | None" = None
performance_profile: "PerformanceProfileResponse | None" = None
security_audit: "SecurityAuditResponse | None" = None
contacts: list["ProspectContactResponse"] = []
class Config:
@@ -114,6 +115,9 @@ from app.modules.prospecting.schemas.performance_profile import (
PerformanceProfileResponse, # noqa: E402
)
from app.modules.prospecting.schemas.score import ProspectScoreResponse # noqa: E402
from app.modules.prospecting.schemas.security_audit import (
SecurityAuditResponse, # noqa: E402
)
from app.modules.prospecting.schemas.tech_profile import (
TechProfileResponse, # noqa: E402
)

View File

@@ -0,0 +1,82 @@
# app/modules/prospecting/schemas/security_audit.py
"""Pydantic schemas for security audit responses."""
import json
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class SecurityAuditFinding(BaseModel):
"""A single security finding."""
title: str
severity: str
category: str
detail: str
is_positive: bool = False
class SecurityAuditResponse(BaseModel):
"""Schema for security audit detail response."""
id: int
prospect_id: int
score: int
grade: str
detected_language: str | None = None
findings: list[SecurityAuditFinding] = Field(default=[], validation_alias="findings_json")
findings_count_critical: int = 0
findings_count_high: int = 0
findings_count_medium: int = 0
findings_count_low: int = 0
findings_count_info: int = 0
has_https: bool | None = None
has_valid_ssl: bool | None = None
ssl_expires_at: datetime | None = None
missing_headers: list[str] = Field(default=[], validation_alias="missing_headers_json")
exposed_files: list[str] = Field(default=[], validation_alias="exposed_files_json")
technologies: list[str] = Field(default=[], validation_alias="technologies_json")
scan_error: str | None = None
created_at: datetime
updated_at: datetime
@field_validator("findings", mode="before")
@classmethod
def parse_findings(cls, v):
if isinstance(v, str):
return json.loads(v)
return v
@field_validator("missing_headers", mode="before")
@classmethod
def parse_missing_headers(cls, v):
if isinstance(v, str):
return json.loads(v)
return v or []
@field_validator("exposed_files", mode="before")
@classmethod
def parse_exposed_files(cls, v):
if isinstance(v, str):
return json.loads(v)
return v or []
@field_validator("technologies", mode="before")
@classmethod
def parse_technologies(cls, v):
if isinstance(v, str):
return json.loads(v)
return v or []
class Config:
from_attributes = True
class SecurityAuditSingleResponse(BaseModel):
"""Response for single-prospect security audit."""
domain: str
score: int
grade: str
findings_count: int

View File

@@ -16,6 +16,10 @@ import ssl
from datetime import UTC, datetime
import requests
import urllib3
# Suppress SSL warnings for intentional verify=False on prospect sites # noqa: SEC047
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # noqa: SEC047
from sqlalchemy.orm import Session
from app.modules.prospecting.config import config
@@ -210,6 +214,20 @@ class EnrichmentService:
response = requests.get(api_url, params=params, timeout=60)
data = response.json()
# Check for API-level errors (quota exceeded, invalid URL, etc.)
if "error" in data:
error_msg = data["error"].get("message", str(data["error"]))
logger.warning("PageSpeed API error for %s: %s", domain, error_msg)
profile = prospect.performance_profile
if not profile:
profile = ProspectPerformanceProfile(prospect_id=prospect.id)
db.add(profile)
profile.scan_error = error_msg
profile.scan_strategy = "mobile"
prospect.last_perf_scan_at = datetime.now(UTC)
db.flush()
return profile
lighthouse = data.get("lighthouseResult", {})
categories = lighthouse.get("categories", {})
audits = lighthouse.get("audits", {})
@@ -454,4 +472,159 @@ class EnrichmentService:
return ",".join(found) if found else None
def scrape_content(self, db: Session, prospect: Prospect) -> dict | None:
"""Scrape page content (headings, paragraphs, images, services) for POC builder.
Uses BeautifulSoup to extract structured content from the prospect's
website. Stores results as JSON in prospect.scraped_content_json.
"""
import json
from bs4 import BeautifulSoup
domain = prospect.domain_name
if not domain or not prospect.has_website:
return None
scheme = "https" if prospect.uses_https else "http"
base_url = f"{scheme}://{domain}"
paths = ["", "/about", "/a-propos", "/services", "/nos-services", "/contact"]
session = requests.Session()
session.verify = False # noqa: SEC047 passive scan
session.headers.update({"User-Agent": "Mozilla/5.0 (compatible; OrionBot/1.0)"})
content = {
"meta_description": None,
"headings": [],
"paragraphs": [],
"services": [],
"images": [],
"social_links": {},
"business_hours": None,
"languages_detected": [],
}
seen_headings = set()
seen_paragraphs = set()
for path in paths:
try:
url = base_url + path
resp = session.get(url, timeout=config.http_timeout, allow_redirects=True)
if resp.status_code != 200:
continue
soup = BeautifulSoup(resp.text, "html.parser")
# Meta description (first one found)
if not content["meta_description"]:
meta = soup.find("meta", attrs={"name": "description"})
if meta and meta.get("content"):
content["meta_description"] = meta["content"].strip()
# Language detection
html_tag = soup.find("html")
if html_tag and html_tag.get("lang"):
lang = html_tag["lang"][:2].lower()
if lang not in content["languages_detected"]:
content["languages_detected"].append(lang)
# Headings (H1, H2)
for tag in soup.find_all(["h1", "h2"]):
text = tag.get_text(strip=True)
if text and len(text) > 3 and text not in seen_headings:
seen_headings.add(text)
content["headings"].append(text)
# Paragraphs (substantial ones, skip tiny/boilerplate)
for tag in soup.find_all("p"):
text = tag.get_text(strip=True)
if text and len(text) > 50 and text not in seen_paragraphs:
seen_paragraphs.add(text)
content["paragraphs"].append(text)
if len(content["paragraphs"]) >= 20:
break
# Images (hero/banner sized, skip tiny icons)
for img in soup.find_all("img"):
src = img.get("src") or img.get("data-src")
if not src:
continue
# Make absolute
if src.startswith("//"):
src = "https:" + src
elif src.startswith("/"):
src = base_url + src
elif not src.startswith("http"):
continue
# Skip tiny images, data URIs, tracking pixels
if "1x1" in src or "pixel" in src or src.startswith("data:"):
continue
width = img.get("width", "")
height = img.get("height", "")
if width and width.isdigit() and int(width) < 100:
continue
if height and height.isdigit() and int(height) < 100:
continue
if src not in content["images"]:
content["images"].append(src)
if len(content["images"]) >= 15:
break
# Social links
for a in soup.find_all("a", href=True):
href = a["href"]
for platform, pattern in [
("facebook", "facebook.com"),
("instagram", "instagram.com"),
("linkedin", "linkedin.com"),
("twitter", "twitter.com"),
("youtube", "youtube.com"),
("tiktok", "tiktok.com"),
]:
if pattern in href and platform not in content["social_links"]:
content["social_links"][platform] = href
# Service items (from list items near "service" headings)
for heading in soup.find_all(["h2", "h3"]):
heading_text = heading.get_text(strip=True).lower()
if any(kw in heading_text for kw in ["service", "prestation", "leistung", "angebot", "nos activit"]):
# Look for list items or cards after this heading
sibling = heading.find_next_sibling()
while sibling and sibling.name not in ["h1", "h2", "h3"]:
if sibling.name in ["ul", "ol"]:
for li in sibling.find_all("li"):
text = li.get_text(strip=True)
if text and len(text) > 3 and text not in content["services"]:
content["services"].append(text)
elif sibling.name == "div":
# Cards pattern: divs with h3/h4 + p
card_title = sibling.find(["h3", "h4", "h5"])
if card_title:
text = card_title.get_text(strip=True)
if text and text not in content["services"]:
content["services"].append(text)
sibling = sibling.find_next_sibling()
if len(content["services"]) >= 10:
break
except Exception as e: # noqa: EXC003
logger.debug("Content scrape failed for %s%s: %s", domain, path, e)
session.close()
# Store results
prospect.scraped_content_json = json.dumps(content, ensure_ascii=False)
prospect.last_content_scrape_at = datetime.now(UTC)
db.flush()
logger.info(
"Content scrape for %s: %d headings, %d paragraphs, %d images, %d services",
domain, len(content["headings"]), len(content["paragraphs"]),
len(content["images"]), len(content["services"]),
)
return content
enrichment_service = EnrichmentService()

View File

@@ -251,6 +251,28 @@ class ProspectService:
.all()
)
def get_pending_content_scrape(self, db: Session, limit: int = 100) -> list[Prospect]:
return (
db.query(Prospect)
.filter(
Prospect.has_website.is_(True),
Prospect.last_content_scrape_at.is_(None),
)
.limit(limit)
.all()
)
def get_pending_security_audit(self, db: Session, limit: int = 50) -> list[Prospect]:
return (
db.query(Prospect)
.filter(
Prospect.has_website.is_(True),
Prospect.last_security_audit_at.is_(None),
)
.limit(limit)
.all()
)
def count_by_status(self, db: Session) -> dict[str, int]:
results = db.query(Prospect.status, func.count(Prospect.id)).group_by(Prospect.status).all() # noqa: SVC-005 - prospecting is platform-scoped, not store-scoped
return {status.value if hasattr(status, "value") else str(status): count for status, count in results}

View File

@@ -0,0 +1,75 @@
# app/modules/prospecting/services/security_audit_constants.py
"""
Constants for security audit checks.
Structural data used by SecurityAuditService. Translations for report
generation are kept in the standalone script (scripts/security-audit/audit.py)
until Phase 2B (report service) migrates them.
"""
# Severity scores — deducted from a starting score of 100
SEVERITY_SCORES = {
"critical": 15,
"high": 10,
"medium": 5,
"low": 2,
"info": 0,
}
# Security headers to check and their severity if missing
SECURITY_HEADERS = {
"Strict-Transport-Security": {"severity": "high", "impact": "MITM attacks, session hijacking via HTTP downgrade"},
"Content-Security-Policy": {"severity": "high", "impact": "XSS attacks, script injection, data theft"},
"X-Frame-Options": {"severity": "medium", "impact": "Clickjacking attacks via invisible iframes"},
"X-Content-Type-Options": {"severity": "medium", "impact": "MIME type confusion, content injection"},
"Referrer-Policy": {"severity": "low", "impact": "URL parameter leakage to third parties"},
"Permissions-Policy": {"severity": "low", "impact": "Unrestricted browser API access (camera, mic, location)"},
"X-XSS-Protection": {"severity": "info", "impact": "Legacy XSS filter not configured"},
}
# Paths to check for exposed sensitive files/directories
EXPOSED_PATHS = [
("/.env", "Environment file (database passwords, API keys)", "critical"),
("/.git/config", "Git repository (full source code)", "critical"),
("/.git/HEAD", "Git repository HEAD", "critical"),
("/.htpasswd", "Password file", "critical"),
("/wp-admin/", "WordPress admin panel", "high"),
("/wp-login.php", "WordPress login page", "high"),
("/administrator/", "Joomla admin panel", "high"),
("/admin/", "Admin panel", "high"),
("/admin/login", "Admin login page", "high"),
("/phpmyadmin/", "phpMyAdmin (database manager)", "high"),
("/backup/", "Backup directory", "high"),
("/backup.zip", "Backup archive", "high"),
("/backup.sql", "Database backup", "high"),
("/db.sql", "Database dump", "high"),
("/dump.sql", "Database dump", "high"),
("/.htaccess", "Server configuration", "medium"),
("/web.config", "IIS configuration", "medium"),
("/server-status", "Apache server status", "medium"),
("/server-info", "Apache server info", "medium"),
("/info.php", "PHP info page", "medium"),
("/phpinfo.php", "PHP info page", "medium"),
("/graphql", "GraphQL endpoint", "medium"),
("/debug/", "Debug endpoint", "medium"),
("/elmah.axd", ".NET error log", "medium"),
("/trace.axd", ".NET trace log", "medium"),
("/readme.html", "CMS readme (reveals version)", "low"),
("/license.txt", "CMS license (reveals version)", "low"),
("/CHANGELOG.md", "Changelog (reveals version)", "low"),
("/robots.txt", "Robots file", "info"),
("/.well-known/security.txt", "Security contact file", "info"),
("/sitemap.xml", "Sitemap", "info"),
("/crossdomain.xml", "Flash cross-domain policy", "low"),
("/api/", "API endpoint", "info"),
]
# Paths that are admin panels (separate severity logic)
ADMIN_PATHS = {"/wp-admin/", "/wp-login.php", "/administrator/", "/admin/", "/admin/login"}
# Robots.txt disallow patterns that may reveal sensitive areas
ROBOTS_SENSITIVE_PATTERNS = [
"admin", "backup", "private", "secret", "staging",
"test", "dev", "internal", "api", "config",
"database", "panel", "dashboard", "login", "cgi-bin",
]

View File

@@ -0,0 +1,443 @@
# app/modules/prospecting/services/security_audit_service.py
"""
Security audit service for prospect websites.
Performs passive security checks (HTTPS, SSL, headers, exposed files,
cookies, server info, technology detection) and stores results as
ProspectSecurityAudit. All checks are read-only — no active exploitation.
Migrated from scripts/security-audit/audit.py into the enrichment pipeline.
"""
import json
import logging
import re
import socket
import ssl
from datetime import UTC, datetime
import requests
from sqlalchemy.orm import Session
from app.modules.prospecting.models import Prospect, ProspectSecurityAudit
from app.modules.prospecting.services.security_audit_constants import (
ADMIN_PATHS,
EXPOSED_PATHS,
ROBOTS_SENSITIVE_PATTERNS,
SECURITY_HEADERS,
SEVERITY_SCORES,
)
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = 10
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
class SecurityAuditService:
"""Runs passive security checks against a prospect's website."""
def run_audit(self, db: Session, prospect: Prospect) -> ProspectSecurityAudit | None:
"""Run all security checks and store results."""
domain = prospect.domain_name
if not domain or not prospect.has_website:
return None
scheme = "https" if prospect.uses_https else "http"
url = f"{scheme}://{domain}"
findings = []
technologies = []
score = 100
has_https = None
has_valid_ssl = None
ssl_expires_at = None
missing_headers = []
exposed_files = []
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
session.verify = True
session.max_redirects = 5
# Fetch the page
response = None
html_content = ""
try:
response = session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
html_content = response.text
if response.url != url:
url = response.url
except requests.exceptions.SSLError:
findings.append(self._finding("Weak SSL/TLS configuration", "critical", "transport",
"Server supports outdated encryption protocols"))
try:
session.verify = False # noqa: SEC047 fallback for broken SSL
response = session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
html_content = response.text
except Exception:
pass
except requests.exceptions.RequestException as e:
logger.warning("Security audit: cannot reach %s: %s", domain, e)
return self._save_audit(db, prospect, score=0, grade="F", findings=findings,
scan_error=f"Cannot reach website: {e}",
technologies=technologies)
# Run checks
https_findings, has_https = self._check_https(url, html_content)
findings.extend(https_findings)
ssl_findings, has_valid_ssl, ssl_expires_at = self._check_ssl(domain)
findings.extend(ssl_findings)
header_findings, missing_headers = self._check_headers(response)
findings.extend(header_findings)
server_findings, server_techs = self._check_server_info(response)
findings.extend(server_findings)
technologies.extend(server_techs)
tech_findings, detected_techs = self._check_technology(html_content, response)
findings.extend(tech_findings)
technologies.extend(detected_techs)
cookie_findings = self._check_cookies(response)
findings.extend(cookie_findings)
exposed_findings, exposed_files = self._check_exposed_files(domain, scheme, session)
findings.extend(exposed_findings)
session.close()
# Calculate score
for f in findings:
if not f.get("is_positive", False):
score = max(0, score - SEVERITY_SCORES.get(f["severity"], 0))
grade = self._calculate_grade(score)
return self._save_audit(
db, prospect,
score=score, grade=grade, findings=findings,
has_https=has_https, has_valid_ssl=has_valid_ssl,
ssl_expires_at=ssl_expires_at,
missing_headers=missing_headers, exposed_files=exposed_files,
technologies=technologies,
)
# ── Check methods ───────────────────────────────────────────────────────
def _check_https(self, url: str, html_content: str) -> tuple[list[dict], bool | None]:
"""Check HTTPS configuration."""
findings = []
from urllib.parse import urlparse
parsed = urlparse(url)
has_https = parsed.scheme == "https"
if has_https:
findings.append(self._finding("HTTPS enabled", "info", "transport",
"Website uses encrypted connections", is_positive=True))
# Check mixed content
http_resources = re.findall(r'(src|href|action)=["\']http://[^"\']+["\']', html_content, re.IGNORECASE)
if http_resources:
findings.append(self._finding("Mixed content detected", "medium", "transport",
"HTTPS site loads resources over insecure HTTP"))
else:
findings.append(self._finding("No HTTPS", "critical", "transport",
"Website transmits all data in plain text"))
return findings, has_https
def _check_ssl(self, domain: str) -> tuple[list[dict], bool | None, datetime | None]:
"""Check SSL certificate validity."""
findings = []
has_valid_ssl = None
ssl_expires_at = None
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=REQUEST_TIMEOUT) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=UTC)
days_remaining = (not_after - datetime.now(UTC)).days
ssl_expires_at = not_after
if days_remaining < 0:
has_valid_ssl = False
findings.append(self._finding("SSL certificate expired", "critical", "transport",
f"Certificate expired on {not_after.strftime('%Y-%m-%d')}"))
elif days_remaining < 30:
has_valid_ssl = True
findings.append(self._finding(f"SSL expires in {days_remaining} days", "high", "transport",
f"Certificate expires on {not_after.strftime('%Y-%m-%d')}"))
else:
has_valid_ssl = True
findings.append(self._finding("SSL certificate valid", "info", "transport",
f"Valid until {not_after.strftime('%Y-%m-%d')} ({days_remaining} days)",
is_positive=True))
# Check TLS version
protocol = ssock.version()
if protocol in ("TLSv1", "TLSv1.1", "SSLv3", "SSLv2"):
findings.append(self._finding("Weak TLS version", "high", "transport",
f"Server supports outdated protocol: {protocol}"))
except ssl.SSLCertVerificationError:
has_valid_ssl = False
findings.append(self._finding("SSL certificate invalid", "critical", "transport",
"Certificate verification failed"))
except (TimeoutError, ConnectionRefusedError, OSError):
pass # No SSL, already caught by HTTPS check
return findings, has_valid_ssl, ssl_expires_at
def _check_headers(self, response) -> tuple[list[dict], list[str]]:
"""Check for missing security headers."""
findings = []
missing = []
if not response:
return findings, missing
for header_name, config in SECURITY_HEADERS.items():
if header_name in response.headers:
findings.append(self._finding(f"Header present: {header_name}", "info", "headers",
header_name, is_positive=True))
else:
missing.append(header_name)
findings.append(self._finding(f"Missing: {header_name}", config["severity"], "headers",
config["impact"]))
return findings, missing
def _check_server_info(self, response) -> tuple[list[dict], list[str]]:
"""Check for server version disclosure."""
findings = []
technologies = []
if not response:
return findings, technologies
server = response.headers.get("Server", "")
x_powered = response.headers.get("X-Powered-By", "")
info_parts = []
if server:
info_parts.append(server)
technologies.append(server)
if x_powered:
info_parts.append(f"X-Powered-By: {x_powered}")
technologies.append(x_powered)
if info_parts:
has_version = bool(re.search(r"\d+\.\d+", " ".join(info_parts)))
severity = "medium" if has_version else "low"
findings.append(self._finding("Server version exposed", severity, "config",
" | ".join(info_parts)))
return findings, technologies
def _check_technology(self, html_content: str, response) -> tuple[list[dict], list[str]]:
"""Detect CMS and technology stack."""
findings = []
technologies = []
if not html_content:
return findings, technologies
# WordPress
wp_indicators = ["wp-content/", "wp-includes/", 'name="generator" content="WordPress']
if any(ind in html_content for ind in wp_indicators):
version = "unknown"
ver_match = re.search(r'content="WordPress\s+([\d.]+)"', html_content)
if ver_match:
version = ver_match.group(1)
severity = "medium" if version != "unknown" else "low"
findings.append(self._finding(f"WordPress detected (v{version})", severity, "technology",
"Version publicly visible" if version != "unknown" else "CMS detected"))
technologies.append(f"WordPress {version}")
# Joomla
if "/media/jui/" in html_content or "Joomla" in html_content:
findings.append(self._finding("Joomla detected", "low", "technology", "CMS detected"))
technologies.append("Joomla")
# Drupal
if "Drupal" in html_content or "/sites/default/" in html_content:
findings.append(self._finding("Drupal detected", "low", "technology", "CMS detected"))
technologies.append("Drupal")
# Hosted platforms (not vulnerable in the same way)
if "wix.com" in html_content:
technologies.append("Wix")
if "squarespace.com" in html_content:
technologies.append("Squarespace")
if "cdn.shopify.com" in html_content:
technologies.append("Shopify")
return findings, technologies
def _check_cookies(self, response) -> list[dict]:
"""Check cookie security flags."""
findings = []
if not response:
return findings
set_cookie_headers = response.headers.get("Set-Cookie", "")
if not set_cookie_headers:
return findings
has_insecure = False
has_no_httponly = False
has_no_samesite = False
for cookie in set_cookie_headers.split(","):
cookie_lower = cookie.lower()
if "secure" not in cookie_lower:
has_insecure = True
if "httponly" not in cookie_lower:
has_no_httponly = True
if "samesite" not in cookie_lower:
has_no_samesite = True
if has_insecure:
findings.append(self._finding("Cookies lack Secure flag", "medium", "cookies",
"Session cookies can be intercepted over HTTP"))
if has_no_httponly:
findings.append(self._finding("Cookies lack HttpOnly flag", "medium", "cookies",
"Cookies accessible to JavaScript (XSS risk)"))
if has_no_samesite:
findings.append(self._finding("Cookies lack SameSite attribute", "low", "cookies",
"Vulnerable to cross-site request attacks"))
return findings
def _check_exposed_files(self, domain: str, scheme: str, session) -> tuple[list[dict], list[str]]:
"""Check for exposed sensitive files and directories."""
findings = []
exposed = []
base = f"{scheme}://{domain}"
security_txt_found = False
robots_content = None
for path, description, default_severity in EXPOSED_PATHS:
try:
resp = session.get(f"{base}{path}", timeout=REQUEST_TIMEOUT, allow_redirects=False)
if path == "/.well-known/security.txt" and resp.status_code == 200:
security_txt_found = True
continue
if path == "/robots.txt" and resp.status_code == 200:
robots_content = resp.text
continue
if path == "/sitemap.xml" or path == "/api/":
continue
if resp.status_code == 200:
if path in ADMIN_PATHS:
findings.append(self._finding(f"Admin panel exposed: {path}", "high", "exposure",
f"Admin login at {base}{path} is publicly accessible"))
else:
findings.append(self._finding(f"Exposed: {path}", default_severity, "exposure",
f"{description} is publicly accessible"))
exposed.append(path)
except Exception:
continue
# Security.txt check
if not security_txt_found:
findings.append(self._finding("No security.txt", "info", "exposure",
"No /.well-known/security.txt for responsible disclosure"))
# Robots.txt analysis
if robots_content:
disallowed = re.findall(r"Disallow:\s*(.+)", robots_content, re.IGNORECASE)
sensitive_found = []
for path in disallowed:
path = path.strip()
if any(pattern in path.lower() for pattern in ROBOTS_SENSITIVE_PATTERNS):
sensitive_found.append(path)
if sensitive_found:
findings.append(self._finding("Robots.txt reveals sensitive paths", "low", "exposure",
f"Disallowed paths: {', '.join(sensitive_found[:5])}"))
return findings, exposed
# ── Helpers ──────────────────────────────────────────────────────────────
@staticmethod
def _finding(title: str, severity: str, category: str, detail: str, is_positive: bool = False) -> dict:
"""Create a finding dict."""
return {
"title": title,
"severity": severity,
"category": category,
"detail": detail,
"is_positive": is_positive,
}
@staticmethod
def _calculate_grade(score: int) -> str:
if score >= 95:
return "A+"
if score >= 85:
return "A"
if score >= 70:
return "B"
if score >= 55:
return "C"
if score >= 40:
return "D"
return "F"
def _save_audit(
self, db: Session, prospect: Prospect, *,
score: int, grade: str, findings: list[dict],
has_https: bool | None = None, has_valid_ssl: bool | None = None,
ssl_expires_at: datetime | None = None,
missing_headers: list[str] | None = None,
exposed_files: list[str] | None = None,
technologies: list[str] | None = None,
scan_error: str | None = None,
) -> ProspectSecurityAudit:
"""Upsert security audit results."""
audit = prospect.security_audit
if not audit:
audit = ProspectSecurityAudit(prospect_id=prospect.id)
db.add(audit)
audit.score = score
audit.grade = grade
audit.findings_json = json.dumps(findings)
audit.has_https = has_https
audit.has_valid_ssl = has_valid_ssl
audit.ssl_expires_at = ssl_expires_at
audit.missing_headers_json = json.dumps(missing_headers or [])
audit.exposed_files_json = json.dumps(exposed_files or [])
audit.technologies_json = json.dumps(technologies or [])
audit.scan_error = scan_error
# Denormalized counts
audit.findings_count_critical = sum(1 for f in findings if f["severity"] == "critical" and not f.get("is_positive"))
audit.findings_count_high = sum(1 for f in findings if f["severity"] == "high" and not f.get("is_positive"))
audit.findings_count_medium = sum(1 for f in findings if f["severity"] == "medium" and not f.get("is_positive"))
audit.findings_count_low = sum(1 for f in findings if f["severity"] == "low" and not f.get("is_positive"))
audit.findings_count_info = sum(1 for f in findings if f["severity"] == "info" and not f.get("is_positive"))
prospect.last_security_audit_at = datetime.now(UTC)
db.flush()
logger.info("Security audit for %s: score=%d grade=%s (%d findings)",
prospect.domain_name, score, grade,
len([f for f in findings if not f.get("is_positive")]))
return audit
security_audit_service = SecurityAuditService()

View File

@@ -0,0 +1,241 @@
# app/modules/prospecting/services/security_report_service.py
"""
Generate branded HTML security audit reports from stored audit data.
Produces a standalone HTML document suitable for viewing in a browser,
printing to PDF, or emailing to prospects. Reports include:
- Security grade and score
- "What could happen" fear section with simulated hacked site
- Detailed findings grouped by category
- Business impact summary
- Call to action with contact info
"""
import html as html_module
import json
import logging
from datetime import datetime
from app.modules.prospecting.models import ProspectSecurityAudit
logger = logging.getLogger(__name__)
SEVERITY_COLORS = {
"critical": ("#dc2626", "#fef2f2", "#991b1b"),
"high": ("#ea580c", "#fff7ed", "#9a3412"),
"medium": ("#ca8a04", "#fefce8", "#854d0e"),
"low": ("#2563eb", "#eff6ff", "#1e40af"),
"info": ("#6b7280", "#f9fafb", "#374151"),
}
GRADE_COLORS = {
"A+": "#16a34a", "A": "#22c55e", "B": "#eab308",
"C": "#f97316", "D": "#ef4444", "F": "#991b1b",
}
CATEGORY_LABELS = {
"transport": "Transport Security (HTTPS/SSL)",
"headers": "Security Headers",
"exposure": "Information Exposure",
"cookies": "Cookie Security",
"config": "Server Configuration",
"technology": "Technology & Versions",
}
DEFAULT_CONTACT = {
"name": "Samir Boulahtit",
"email": "contact@wizard.lu",
"phone": "+352 XXX XXX XXX",
"company": "Wizard",
"website": "https://wizard.lu",
"tagline": "Professional Web Development & Security",
}
class SecurityReportService:
"""Generate branded HTML security audit reports."""
def generate_html_report(
self,
audit: ProspectSecurityAudit,
domain: str,
contact: dict | None = None,
) -> str:
"""Generate a standalone HTML report from stored audit data."""
contact = contact or DEFAULT_CONTACT
esc = html_module.escape
findings = json.loads(audit.findings_json) if audit.findings_json else []
technologies = json.loads(audit.technologies_json) if audit.technologies_json else []
grade = audit.grade
score = audit.score
grade_color = GRADE_COLORS.get(grade, "#6b7280")
# Severity counts
sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
if not f.get("is_positive") and f["severity"] in sev_counts:
sev_counts[f["severity"]] += 1
# Group findings by category
categories = ["transport", "headers", "exposure", "cookies", "config", "technology"]
grouped = {cat: [f for f in findings if f["category"] == cat] for cat in categories}
# Build findings HTML
findings_html = ""
for cat in categories:
cat_findings = grouped.get(cat, [])
if not cat_findings:
continue
label = CATEGORY_LABELS.get(cat, cat)
findings_html += f'<h3 style="margin-top:32px;font-size:18px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">{esc(label)}</h3>\n'
for f in cat_findings:
if f.get("is_positive"):
findings_html += f"""
<div style="margin:12px 0;padding:12px 16px;background:#f0fdf4;border-left:4px solid #16a34a;border-radius:0 8px 8px 0;">
<span style="color:#16a34a;font-weight:600;">&#10003; {esc(f["title"])}</span>
<span style="color:#166534;font-size:13px;margin-left:8px;">{esc(f["detail"])}</span>
</div>"""
else:
sev_color, sev_bg, sev_text = SEVERITY_COLORS.get(f["severity"], SEVERITY_COLORS["info"])
findings_html += f"""
<div style="margin:16px 0;background:{sev_bg};border:1px solid {sev_color}20;border-radius:12px;overflow:hidden;">
<div style="padding:16px 20px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<span style="background:{sev_color};color:white;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:700;text-transform:uppercase;">{esc(f["severity"])}</span>
<span style="font-weight:700;color:#1e293b;font-size:15px;">{esc(f["title"])}</span>
</div>
<div style="font-size:14px;color:#334155;margin-top:8px;">{esc(f["detail"])}</div>
</div>
</div>"""
# Technologies
tech_html = ""
if technologies:
tech_items = " ".join(
f'<span style="background:#f1f5f9;padding:4px 12px;border-radius:20px;font-size:13px;color:#475569;border:1px solid #e2e8f0;">{esc(t)}</span>'
for t in technologies
)
tech_html = f'<div style="margin-top:16px;"><span style="font-size:13px;color:#64748b;font-weight:600;">Technologies:</span><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;">{tech_items}</div></div>'
now = datetime.now().strftime("%Y-%m-%d %H:%M")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Security Audit Report — {esc(domain)}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.6; }}
.container {{ max-width: 800px; margin: 0 auto; padding: 40px 24px; }}
@media print {{ body {{ background: white; }} .container {{ padding: 20px; }} .no-print {{ display: none !important; }} .page-break {{ page-break-before: always; }} }}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div style="text-align:center;margin-bottom:48px;">
<div style="font-size:12px;letter-spacing:3px;color:#94a3b8;text-transform:uppercase;margin-bottom:8px;">&#128274; Website Security Audit Report</div>
<h1 style="font-size:28px;font-weight:800;color:#0f172a;margin-bottom:8px;">{esc(domain)}</h1>
<div style="font-size:14px;color:#64748b;">Confidential — Prepared for {esc(domain)}</div>
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">Report generated on {now}</div>
</div>
<!-- Score Card -->
<div style="background:white;border-radius:16px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:32px;margin-bottom:32px;text-align:center;">
<h2 style="font-size:16px;color:#64748b;margin-bottom:24px;">Overall Security Grade</h2>
<div style="display:inline-flex;align-items:center;justify-content:center;width:120px;height:120px;border-radius:50%;background:{grade_color};margin-bottom:16px;">
<span style="font-size:48px;font-weight:900;color:white;">{grade}</span>
</div>
<div style="font-size:14px;color:#64748b;">Security Score: {score}/100</div>
<div style="margin-top:16px;height:8px;background:#e2e8f0;border-radius:4px;overflow:hidden;">
<div style="width:{score}%;height:100%;background:{grade_color};border-radius:4px;"></div>
</div>
<div style="margin-top:20px;display:flex;justify-content:center;gap:16px;flex-wrap:wrap;">
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#dc2626;margin-right:4px;"></span>{sev_counts['critical']} Critical</span>
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ea580c;margin-right:4px;"></span>{sev_counts['high']} High</span>
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ca8a04;margin-right:4px;"></span>{sev_counts['medium']} Medium</span>
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2563eb;margin-right:4px;"></span>{sev_counts['low']} Low</span>
</div>
{tech_html}
</div>
<!-- What Could Happen -->
<div class="page-break" style="background:#1e293b;border-radius:16px;padding:32px;margin-bottom:32px;color:white;">
<h2 style="font-size:20px;font-weight:700;color:#f87171;margin-bottom:24px;text-align:center;">&#9888;&#65039; What Could Happen To Your Website</h2>
<div style="background:#0f172a;border-radius:12px;overflow:hidden;border:1px solid #334155;margin-bottom:24px;">
<div style="background:#1e293b;padding:8px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #334155;">
<span style="width:10px;height:10px;border-radius:50%;background:#ef4444;"></span>
<span style="width:10px;height:10px;border-radius:50%;background:#eab308;"></span>
<span style="width:10px;height:10px;border-radius:50%;background:#22c55e;"></span>
<div style="flex:1;margin-left:8px;background:#0f172a;border-radius:6px;padding:4px 12px;">
<span style="font-size:12px;color:#64748b;">&#128274; https://{esc(domain)}</span>
</div>
</div>
<div style="padding:40px 24px;text-align:center;">
<div style="font-size:64px;margin-bottom:16px;">&#128128;</div>
<div style="font-size:28px;font-weight:900;color:#ef4444;margin-bottom:16px;letter-spacing:2px;">WEBSITE COMPROMISED</div>
<div style="font-size:14px;color:#94a3b8;max-width:500px;margin:0 auto;">This website has been hacked. All customer data including names, emails, phone numbers, and payment information has been stolen.</div>
</div>
</div>
<div style="padding:16px;background:#dc262615;border-radius:8px;border:1px solid #dc262630;margin-bottom:24px;">
<p style="font-size:13px;color:#fca5a5;">This is a simulation based on real-world attacks. With the vulnerabilities found on your site, this scenario is technically possible.</p>
</div>
<h3 style="font-size:16px;color:#f1f5f9;margin-bottom:16px;">Business Impact</h3>
<ul style="list-style:none;padding:0;">
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">&#10060;</span> Reputation Damage — Customers lose trust</li>
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">&#10060;</span> GDPR Fines — Up to 4% of annual turnover or &euro;20 million</li>
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">&#10060;</span> Google Blacklist — "This site may be hacked" warning kills traffic</li>
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">&#10060;</span> Business Downtime — Revenue loss during recovery</li>
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">&#10060;</span> Legal Liability — Liable for customers' stolen data</li>
</ul>
</div>
<!-- Detailed Findings -->
<div style="background:white;border-radius:16px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:32px;margin-bottom:32px;">
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin-bottom:24px;">Detailed Findings</h2>
{findings_html}
</div>
<!-- Call to Action -->
<div style="background:linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);border-radius:16px;padding:40px;margin-bottom:32px;color:white;">
<h2 style="font-size:24px;font-weight:800;margin-bottom:16px;text-align:center;">&#128737;&#65039; Protect Your Business</h2>
<p style="font-size:15px;color:#e9d5ff;text-align:center;margin-bottom:24px;max-width:600px;margin-left:auto;margin-right:auto;">
Every day these vulnerabilities remain unfixed is another day your business and your customers are at risk.
</p>
<div style="background:rgba(255,255,255,0.1);border-radius:12px;padding:24px;margin-bottom:24px;">
<h3 style="font-size:16px;margin-bottom:12px;">What We Offer</h3>
<ul style="list-style:none;padding:0;">
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">&#10004;</span> Complete security audit and remediation plan</li>
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">&#10004;</span> Modern, secure website built with best practices</li>
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">&#10004;</span> Ongoing security monitoring and maintenance</li>
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">&#10004;</span> GDPR compliance and data protection</li>
</ul>
</div>
<div style="text-align:center;">
<p style="font-size:14px;color:#c4b5fd;margin-bottom:16px;">Contact us today for a free consultation:</p>
<div style="display:inline-block;background:white;border-radius:12px;padding:20px 32px;text-align:left;">
<div style="font-size:18px;font-weight:700;color:#6d28d9;margin-bottom:8px;">{esc(contact['name'])}</div>
<div style="font-size:14px;color:#475569;margin-bottom:4px;">&#128231; {esc(contact['email'])}</div>
<div style="font-size:14px;color:#475569;margin-bottom:4px;">&#128222; {esc(contact['phone'])}</div>
<div style="font-size:14px;color:#475569;">&#127760; {esc(contact['website'])}</div>
</div>
</div>
</div>
<!-- Disclaimer -->
<div style="text-align:center;padding:24px;font-size:12px;color:#94a3b8;line-height:1.7;">
<p>This report was generated using passive, non-intrusive analysis techniques only. No active exploitation or unauthorized access was attempted.</p>
<p style="margin-top:8px;">&copy; {datetime.now().year} {esc(contact['company'])} &mdash; {esc(contact['tagline'])}</p>
</div>
</div>
</body>
</html>"""
security_report_service = SecurityReportService()

View File

@@ -13,10 +13,12 @@ function prospectDetail(prospectId) {
campaignSends: [],
loading: true,
error: null,
auditRunning: false,
activeTab: 'overview',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'security', label: 'Security' },
{ id: 'interactions', label: 'Interactions' },
{ id: 'campaigns', label: 'Campaigns' },
],
@@ -115,6 +117,43 @@ function prospectDetail(prospectId) {
}
},
openSecurityReport() {
window.open('/api/v1/admin/prospecting/enrichment/security-audit/report/' + this.prospectId, '_blank');
},
async runSecurityAudit() {
this.auditRunning = true;
try {
await apiClient.post('/admin/prospecting/enrichment/security-audit/' + this.prospectId);
Utils.showToast('Security audit complete', 'success');
await this.loadProspect();
} catch (err) {
Utils.showToast('Audit failed: ' + err.message, 'error');
} finally {
this.auditRunning = false;
}
},
gradeColor(grade) {
if (!grade) return 'text-gray-400';
if (grade === 'A+' || grade === 'A') return 'text-green-600 dark:text-green-400';
if (grade === 'B') return 'text-blue-600 dark:text-blue-400';
if (grade === 'C') return 'text-yellow-600 dark:text-yellow-400';
if (grade === 'D') return 'text-orange-600 dark:text-orange-400';
return 'text-red-600 dark:text-red-400';
},
severityBadge(severity) {
var classes = {
critical: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
high: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
low: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
info: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
};
return classes[severity] || classes.info;
},
scoreColor(score) {
if (score == null) return 'text-gray-400';
if (score >= 70) return 'text-red-600';

View File

@@ -152,6 +152,18 @@ function prospectsList() {
if (this.pagination.page > 1) { this.pagination.page--; this.loadProspects(); }
},
async deleteProspect(prospect) {
var name = prospect.business_name || prospect.domain_name || 'this prospect';
if (!confirm('Delete "' + name + '"? This cannot be undone.')) return;
try {
await apiClient.delete('/admin/prospecting/prospects/' + prospect.id);
Utils.showToast('Prospect deleted', 'success');
await this.loadProspects();
} catch (err) {
Utils.showToast('Failed: ' + err.message, 'error');
}
},
statusBadgeClass(status) {
const classes = {
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',

View File

@@ -53,6 +53,8 @@ function scanJobs() {
'tech_scan': 'tech-scan',
'performance_scan': 'performance',
'contact_scrape': 'contacts',
'content_scrape': 'content-scrape',
'security_audit': 'security-audit',
'score_compute': 'score-compute',
},

View File

@@ -4,9 +4,11 @@ Celery tasks for batch prospect scanning and enrichment.
"""
import logging
import time
from datetime import UTC, datetime
from app.core.celery_config import celery_app
from app.modules.prospecting.config import config as prospecting_config
from app.modules.prospecting.models import ProspectScanJob
from app.modules.task_base import ModuleTask
@@ -53,6 +55,8 @@ def batch_http_check(self, job_id: int, limit: int = 100):
job.processed_items = processed
if processed % 10 == 0:
db.flush()
if processed < len(prospects):
time.sleep(prospecting_config.batch_delay_seconds)
job.status = "completed"
job.completed_at = datetime.now(UTC)
@@ -61,7 +65,7 @@ def batch_http_check(self, job_id: int, limit: int = 100):
except Exception as e:
logger.error("batch_http_check job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise
@@ -110,6 +114,8 @@ def batch_tech_scan(self, job_id: int, limit: int = 100):
job.processed_items = processed
if processed % 10 == 0:
db.flush()
if processed < len(prospects):
time.sleep(prospecting_config.batch_delay_seconds)
job.status = "completed"
job.completed_at = datetime.now(UTC)
@@ -118,7 +124,7 @@ def batch_tech_scan(self, job_id: int, limit: int = 100):
except Exception as e:
logger.error("batch_tech_scan job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise
@@ -167,6 +173,8 @@ def batch_performance_scan(self, job_id: int, limit: int = 50):
job.processed_items = processed
if processed % 5 == 0:
db.flush()
if processed < len(prospects):
time.sleep(prospecting_config.batch_delay_seconds)
job.status = "completed"
job.completed_at = datetime.now(UTC)
@@ -175,7 +183,7 @@ def batch_performance_scan(self, job_id: int, limit: int = 50):
except Exception as e:
logger.error("batch_performance_scan job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise
@@ -223,6 +231,8 @@ def batch_contact_scrape(self, job_id: int, limit: int = 100):
job.processed_items = processed
if processed % 10 == 0:
db.flush()
if processed < len(prospects):
time.sleep(prospecting_config.batch_delay_seconds)
job.status = "completed"
job.completed_at = datetime.now(UTC)
@@ -231,7 +241,7 @@ def batch_contact_scrape(self, job_id: int, limit: int = 100):
except Exception as e:
logger.error("batch_contact_scrape job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise
@@ -270,7 +280,7 @@ def batch_score_compute(self, job_id: int, limit: int = 500):
except Exception as e:
logger.error("batch_score_compute job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise
@@ -331,7 +341,7 @@ def full_enrichment(self, job_id: int, prospect_id: int):
except Exception as e:
logger.error("full_enrichment job %d failed: %s", job_id, e, exc_info=True)
job.status = "failed"
job.error_message = str(e)[:500]
job.error_log = str(e)[:500]
job.completed_at = datetime.now(UTC)
db.commit() # SVC-006 - persist failure status
raise

View File

@@ -71,6 +71,7 @@
<!-- Tabs -->
{{ tab_header([
{'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
{'id': 'security', 'label': 'Security', 'icon': 'shield-check'},
{'id': 'interactions', 'label': 'Interactions', 'icon': 'chat'},
{'id': 'campaigns', 'label': 'Campaigns', 'icon': 'mail'},
], active_var='activeTab') }}
@@ -91,7 +92,8 @@
<!-- Score Breakdown -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Score Breakdown', icon='chart-bar') }}
{{ section_header('Opportunity Score', icon='chart-bar') }}
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">Higher = more issues = better sales opportunity</p>
<template x-if="prospect.score">
<div class="space-y-4">
<template x-for="cat in scoreCategories()" :key="cat.key">
@@ -120,7 +122,9 @@
</template>
</div>
</template>
<p x-show="cat.flags.length === 0" class="text-xs text-gray-400 ml-2">No issues detected</p>
<p x-show="cat.flags.length === 0" class="text-xs ml-2"
:class="cat.score === 0 ? 'text-green-500' : 'text-gray-400'"
x-text="cat.score === 0 ? '✓ No issues found — low opportunity' : 'No data available'"></p>
</div>
</template>
</div>
@@ -162,24 +166,149 @@
<!-- Performance Summary -->
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Performance', icon='chart-bar') }}
<div class="space-y-2 text-sm">
<!-- Scan error -->
<div x-show="prospect.performance_profile?.scan_error" class="p-3 mb-3 text-sm text-orange-700 bg-orange-50 rounded-lg dark:bg-orange-900/20 dark:text-orange-300">
<span class="font-medium">Scan failed:</span>
<span x-text="prospect.performance_profile?.scan_error"></span>
</div>
<!-- Scores (only when real data exists) -->
<div x-show="!prospect.performance_profile?.scan_error && (prospect.performance_profile?.performance_score > 0 || prospect.performance_profile?.seo_score > 0)" class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Performance Score</span>
<span class="text-gray-600 dark:text-gray-400">Performance</span>
<span class="font-semibold" :class="scoreColor(prospect.performance_profile?.performance_score)"
x-text="prospect.performance_profile?.performance_score ?? '—'"></span>
x-text="prospect.performance_profile?.performance_score + '/100'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Accessibility</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.accessibility_score + '/100'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Best Practices</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.best_practices_score + '/100'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SEO</span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.seo_score + '/100'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span>
<span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
<span x-text="prospect.performance_profile?.is_mobile_friendly == null ? '—' : prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600 dark:text-green-400' : prospect.performance_profile?.is_mobile_friendly === false ? 'text-red-600 dark:text-red-400' : 'text-gray-400'"
class="font-medium"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SEO Score</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
</div>
<p x-show="!prospect.performance_profile?.scan_error && prospect.performance_profile?.performance_score === 0 && prospect.performance_profile?.seo_score === 0"
class="text-sm text-gray-400 text-center py-4">No performance data — configure PAGESPEED_API_KEY in .env</p>
</div>
</div>
<!-- Tab: Security -->
<div x-show="activeTab === 'security'" class="space-y-6">
<!-- Action buttons -->
<div class="flex justify-end gap-3">
<button type="button" x-show="prospect.security_audit" @click="openSecurityReport()"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none">
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
Generate Report
</button>
<button type="button" @click="runSecurityAudit()" :disabled="auditRunning"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none disabled:opacity-50">
<span x-show="!auditRunning" x-html="$icon('shield-check', 'w-4 h-4 mr-2')"></span>
<span x-show="auditRunning" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="auditRunning ? 'Scanning...' : 'Run Security Audit'"></span>
</button>
</div>
<template x-if="prospect.security_audit">
<div class="grid gap-6 md:grid-cols-2">
<!-- Grade Card -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800 text-center">
<div class="text-5xl font-bold mb-2" :class="gradeColor(prospect.security_audit.grade)"
x-text="prospect.security_audit.grade"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">Security Grade</div>
<div class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="prospect.security_audit.score + '/100'"></div>
<!-- Severity counts -->
<div class="flex justify-center gap-3 mt-4">
<span x-show="prospect.security_audit.findings_count_critical > 0"
class="px-2 py-1 text-xs font-bold rounded bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
x-text="prospect.security_audit.findings_count_critical + ' critical'"></span>
<span x-show="prospect.security_audit.findings_count_high > 0"
class="px-2 py-1 text-xs font-bold rounded bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
x-text="prospect.security_audit.findings_count_high + ' high'"></span>
<span x-show="prospect.security_audit.findings_count_medium > 0"
class="px-2 py-1 text-xs font-bold rounded bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300"
x-text="prospect.security_audit.findings_count_medium + ' medium'"></span>
<span x-show="prospect.security_audit.findings_count_low > 0"
class="px-2 py-1 text-xs font-bold rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
x-text="prospect.security_audit.findings_count_low + ' low'"></span>
</div>
<p x-show="prospect.security_audit.scan_error" class="mt-3 text-xs text-red-500" x-text="prospect.security_audit.scan_error"></p>
</div>
<!-- Quick Info -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Quick Overview', icon='shield-check') }}
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">HTTPS</span>
<span class="font-medium" :class="prospect.security_audit.has_https ? 'text-green-600' : 'text-red-600'"
x-text="prospect.security_audit.has_https ? 'Yes' : 'No'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SSL Valid</span>
<span class="font-medium" :class="prospect.security_audit.has_valid_ssl ? 'text-green-600' : prospect.security_audit.has_valid_ssl === false ? 'text-red-600' : 'text-gray-400'"
x-text="prospect.security_audit.has_valid_ssl == null ? '—' : prospect.security_audit.has_valid_ssl ? 'Yes' : 'No'"></span>
</div>
<div x-show="prospect.security_audit.ssl_expires_at" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SSL Expires</span>
<span class="font-medium text-gray-700 dark:text-gray-300"
x-text="new Date(prospect.security_audit.ssl_expires_at).toLocaleDateString()"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Missing Headers</span>
<span class="font-medium text-gray-700 dark:text-gray-200"
x-text="(prospect.security_audit.missing_headers || []).length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Exposed Files</span>
<span class="font-medium"
:class="(prospect.security_audit.exposed_files || []).length > 0 ? 'text-red-600' : 'text-green-600'"
x-text="(prospect.security_audit.exposed_files || []).length"></span>
</div>
<div x-show="(prospect.security_audit.technologies || []).length > 0" class="pt-2 border-t border-gray-100 dark:border-gray-700">
<span class="text-gray-600 dark:text-gray-400 text-xs uppercase">Technologies</span>
<div class="flex flex-wrap gap-1 mt-1">
<template x-for="tech in prospect.security_audit.technologies || []" :key="tech">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded" x-text="tech"></span>
</template>
</div>
</div>
</div>
</div>
<!-- Findings List (full width) -->
<div class="md:col-span-2 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Findings', icon='clipboard-list') }}
<div class="space-y-2">
<template x-for="finding in (prospect.security_audit.findings || []).filter(f => !f.is_positive)" :key="finding.title">
<div class="flex items-start gap-3 py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span class="mt-0.5 px-2 py-0.5 text-xs font-bold rounded shrink-0"
:class="severityBadge(finding.severity)"
x-text="finding.severity"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="finding.title"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="finding.detail"></p>
</div>
</div>
</template>
<p x-show="(prospect.security_audit.findings || []).filter(f => !f.is_positive).length === 0"
class="text-sm text-green-600 text-center py-4">No security issues found</p>
</div>
</div>
</div>
</div>
</template>
<p x-show="!prospect.security_audit" class="text-sm text-gray-400 text-center py-8">No security audit yet. Click "Run Security Audit" to scan.</p>
</div>
<!-- Tab: Interactions -->

View File

@@ -132,6 +132,11 @@
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button type="button" @click="deleteProspect(p)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete">
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>

View File

@@ -34,6 +34,16 @@
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Contact Scrape
</button>
<button type="button" @click="startBatchJob('content_scrape')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none">
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
Content Scrape
</button>
<button type="button" @click="startBatchJob('security_audit')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none">
<span x-html="$icon('shield-check', 'w-4 h-4 mr-2')"></span>
Security Audit
</button>
<button type="button" @click="startBatchJob('score_compute')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>

View File

@@ -210,6 +210,18 @@
</div>
</header>
{# Preview mode banner (POC site previews via signed URL) #}
{% if request.state.is_preview|default(false) %}
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-weight:700;font-size:16px;">HostWizard</span>
<span style="opacity:0.9;">Preview Mode</span>
</div>
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
</div>
<style>header { margin-top: 48px !important; }</style>
{% endif %}
{# Mobile menu panel #}
<div x-show="mobileMenuOpen"
x-cloak

View File

@@ -0,0 +1,293 @@
# End-to-End Plan: Prospecting → Live Site
## Context
Full pipeline from discovering a prospect to delivering a live hosted website. Three workstreams in dependency order, with AI content enhancement deferred to a later phase.
## Work Order
```
Workstream 1: Fix Hosting Foundation ← FIRST (unblocks everything)
Workstream 2: Security Audit Pipeline ← sales tool, enriches prospect data
Workstream 3: POC Builder + Templates ← the wow factor (AI deferred)
Workstream 4: AI Content Enhancement ← DEFERRED (provider TBD)
```
## The Full Journey (target state)
```
1. DISCOVER → Prospect created (digital scan or manual capture)
2. ENRICH → HTTP check, tech scan, performance, contacts, security audit
3. SCORE → Opportunity score + security grade → prioritize leads
4. REPORT → Generate branded security report for sales meeting
5. DEMO → Launch live hacker console to demonstrate vulnerabilities
6. BUILD POC → Apply industry template, populate with scraped content
7. PREVIEW → Client sees POC at acme.hostwizard.lu
8. PROPOSE → Send proposal with security report + POC link
9. ACCEPT → Create merchant, assign store, create subscription
10. GO LIVE → Assign custom domain (acme.lu), DNS + SSL configured
```
Steps 1-3 work today. Steps 4-10 need the work below.
---
## Workstream 1: Fix Hosting Foundation
**Goal:** Make site creation work, fix the merchant/prospect requirement, clean up the lifecycle.
**Proposal:** `docs/proposals/hosting-site-creation-fix.md`
### 1.1 Fix Site Creation Schema + Service
| File | Change |
|---|---|
| `hosting/schemas/hosted_site.py` | Add `merchant_id: int | None`, `prospect_id: int | None`, `model_validator` requiring at least one |
| `hosting/services/hosted_site_service.py` | Rewrite `create()`: if merchant_id → use it; if prospect_id → auto-create merchant from prospect. Remove system merchant hack. Remove `create_from_prospect()`. |
| `hosting/routes/api/admin_sites.py` | Remove `POST /from-prospect/{prospect_id}` endpoint. Main `POST /sites` handles both paths. |
### 1.2 Fix Site Creation Template
| File | Change |
|---|---|
| `hosting/templates/.../site-new.html` | Add merchant autocomplete dropdown (search existing merchants), prospect autocomplete dropdown (search existing prospects). Validate at least one selected. Auto-fill business_name/contacts from selection. |
### 1.3 Simplify accept_proposal
| File | Change |
|---|---|
| `hosting/services/hosted_site_service.py` | `accept_proposal()` — merchant already exists at creation time, so remove merchant creation logic. Only needs: create subscription, mark prospect CONVERTED. |
### 1.4 Update Tests
| File | Change |
|---|---|
| `hosting/tests/conftest.py` | Update `hosted_site` fixture to pass `merchant_id` |
| `hosting/tests/unit/test_hosted_site_service.py` | Update create calls, add validation tests, remove `TestHostedSiteFromProspect` |
### Verification
```bash
python -m pytest app/modules/hosting/tests/ -x -q
# Manual: create site from /admin/hosting/sites/new with merchant + prospect
```
---
## Workstream 2: Security Audit Pipeline
**Goal:** Add security scanning to enrichment pipeline + report generation + live demo.
**Proposal:** `docs/proposals/security-audit-demo-poc-builder.md`
### Phase 2A: Security Audit Service (scan + store results)
**Already done:** `ProspectSecurityAudit` model, `last_security_audit_at` column, relationship.
| File | Action |
|---|---|
| `prospecting/services/security_audit_constants.py` | **CREATE** — Migrate TRANSLATIONS, EXPOSED_PATHS, SECURITY_HEADERS, SEVERITY_SCORES from `scripts/security-audit/audit.py` |
| `prospecting/services/security_audit_service.py` | **CREATE**`SecurityAuditService.run_audit(db, prospect)` with check_https, check_ssl, check_headers, check_exposed_files, check_cookies, check_server_info, check_technology |
| `prospecting/schemas/security_audit.py` | **CREATE** — Response schemas |
| `prospecting/migrations/versions/prospecting_002_security_audit.py` | **CREATE** — Table + column migration |
| `prospecting/services/prospect_service.py` | Add `get_pending_security_audit()` |
| `prospecting/routes/api/admin_enrichment.py` | Add `/security-audit/batch` + `/security-audit/{prospect_id}`. Add to `full_enrichment`. |
| `prospecting/static/admin/js/scan-jobs.js` | Add `security_audit: 'security-audit'` to batchRoutes |
| `prospecting/templates/.../scan-jobs.html` | Add "Security Audit" batch button |
| `prospecting/templates/.../prospect-detail.html` | Add "Security" tab: grade badge, score bar, findings by severity, "Run Audit" button |
| `prospecting/static/admin/js/prospect-detail.js` | Add `runSecurityAudit()`, tab handling |
### Phase 2B: Security Report Generation
| File | Action |
|---|---|
| `prospecting/services/security_report_service.py` | **CREATE**`generate_html_report(audit, prospect, language)` → standalone HTML. Migrate report template from `scripts/security-audit/audit.py` |
| `prospecting/routes/api/admin_enrichment.py` | Add `GET /security-audit/{prospect_id}/report` → HTMLResponse |
| `prospecting/templates/.../prospect-detail.html` | "Generate Report" button + language selector on security tab |
### Phase 2C: Live Demo Server
| File | Action |
|---|---|
| `prospecting/models/demo_session.py` | **CREATE** — DemoSession model (prospect_id, status, port, pid) |
| `prospecting/services/demo_service.py` | **CREATE** — start_demo (clone + spawn), stop_demo, stop_all. Migrate SiteCloner + DemoHandler from `scripts/security-audit/demo.py` |
| `prospecting/services/demo_constants.py` | **CREATE** — HACKER_JS, HACKER_HTML_TEMPLATE, etc. |
| `prospecting/routes/api/admin_demo.py` | **CREATE** — start/stop/list endpoints |
| `prospecting/templates/.../prospect-detail.html` | "Launch Demo" / "Stop Demo" buttons on security tab |
### Verification
```bash
python -m pytest app/modules/prospecting/tests/ -x -q
# Manual: run security audit on prospect 1
curl -X POST .../enrichment/security-audit/1
# Manual: generate report
curl .../enrichment/security-audit/1/report > report.html
# Manual: launch demo
curl -X POST .../demo/start/1
```
---
## Workstream 3: POC Builder + Industry Templates
**Goal:** One-click POC generation from prospect data using industry templates. Result is a near-final multi-page website.
**Architecture decision:** `docs/proposals/hosting-architecture-decision.md` — reuse CMS + Store + StoreDomain.
### Phase 3A: Content Scraping Enhancement
Before building POCs, we need more content from prospect sites than just contacts.
| File | Action |
|---|---|
| `prospecting/models/prospect.py` | Add `scraped_content_json` column (Text) — stores extracted page content |
| `prospecting/services/enrichment_service.py` | Add `scrape_content(db, prospect)` method: extract meta description, H1/H2 headings, first paragraphs, service/menu items, image URLs, business hours, social links using BeautifulSoup |
| `prospecting/services/prospect_service.py` | Add `get_pending_content_scrape()` |
| `prospecting/routes/api/admin_enrichment.py` | Add `/content-scrape/batch` + `/content-scrape/{prospect_id}` |
| Add to `full_enrichment` pipeline | After contact scrape, before scoring |
| Migration | Add `last_content_scrape_at`, `scraped_content_json` to prospects |
**Scraped content structure:**
```json
{
"meta_description": "Bati Rénovation — Construction et rénovation à Strasbourg",
"headings": ["Bati Rénovation", "Nos Services", "Nos Réalisations"],
"paragraphs": ["Entreprise spécialisée dans la rénovation...", ...],
"services": ["Rénovation intérieure", "Construction neuve", ...],
"images": ["https://site.fr/hero.jpg", "https://site.fr/project1.jpg"],
"social_links": {"facebook": "...", "instagram": "..."},
"business_hours": "Lun-Ven 8h-18h",
"languages_detected": ["fr"]
}
```
### Phase 3B: Template Infrastructure
| File | Action |
|---|---|
| `hosting/templates_library/manifest.json` | **CREATE** — Registry of all templates |
| `hosting/templates_library/generic/` | **CREATE** — Generic template (meta.json, pages/*.json, theme.json) |
| `hosting/templates_library/restaurant/` | **CREATE** — Restaurant template |
| `hosting/templates_library/construction/` | **CREATE** — Construction template |
| `hosting/templates_library/auto-parts/` | **CREATE** — Auto parts template |
| `hosting/templates_library/professional-services/` | **CREATE** — Professional services template |
| `hosting/services/template_service.py` | **CREATE**`list_templates()`, `get_template(id)`, `validate_template()` |
| `hosting/schemas/template.py` | **CREATE**`TemplateListResponse`, `TemplateDetailResponse` |
| `hosting/routes/api/admin_sites.py` | Add `GET /templates` endpoint |
### Phase 3C: POC Builder Service
| File | Action |
|---|---|
| `hosting/services/poc_builder_service.py` | **CREATE**`build_poc(db, prospect_id, template_id, merchant_id)`: 1) Load template, 2) Load scraped content from prospect, 3) Create HostedSite + Store, 4) Populate ContentPages from template with prospect data replacing placeholders, 5) Apply StoreTheme from template, 6) Return HostedSite |
| `hosting/routes/api/admin_sites.py` | Add `POST /poc/build` endpoint |
| `hosting/templates/.../site-new.html` | Add template selector (cards with previews) to creation flow |
**Placeholder replacement:**
```
{{business_name}} → prospect.business_name or scraped H1
{{city}} → prospect.city or extracted from address
{{phone}} → primary phone contact
{{email}} → primary email contact
{{address}} → address contact value
{{meta_description}} → scraped meta description
{{services}} → scraped service items (for service grid sections)
{{hero_image}} → first scraped image or template default
```
### Phase 3D: CMS Section Types for Hosting
The existing CMS sections (hero, features, pricing, cta) cover basics. Hosting templates need a few more:
| Section Type | Used By | Description |
|---|---|---|
| `services_grid` | construction, professional | Service cards with icons and descriptions |
| `gallery` | construction, restaurant | Image grid with lightbox |
| `team_grid` | professional | Team member cards (photo, name, role) |
| `menu_display` | restaurant | Menu categories with items and prices |
| `testimonials` | all | Customer review cards |
| `contact_form` | all | Configurable contact form (name, email, message) |
| `map_embed` | all | Google Maps embed from address |
| `hours_display` | restaurant, auto-parts | Business hours table |
| File | Action |
|---|---|
| `cms/schemas/homepage_sections.py` | Add new section type schemas |
| `app/templates/storefront/sections/` | **CREATE** — Jinja2 partials for each new section type |
| `app/templates/storefront/content-page.html` | Update section renderer to handle new types |
### Verification
```bash
python -m pytest app/modules/hosting/tests/ -x -q
python -m pytest app/modules/prospecting/tests/ -x -q
# Manual:
# 1. Create prospect for batirenovation-strasbourg.fr
# 2. Run full enrichment (includes content scrape)
# 3. Go to /admin/hosting/sites/new → select prospect + "construction" template
# 4. POC built → preview at subdomain
# 5. Accept → go live with custom domain
```
---
## Workstream 4: AI Content Enhancement (DEFERRED)
**Provider:** TBD (Claude API or OpenAI)
**Scope:** TBD (full rewrite vs enhance + fill gaps)
When ready, this adds an AI step to the POC builder:
1. After scraping content and before populating CMS pages
2. AI enhances scraped text: better headlines, professional descriptions, SEO meta
3. AI generates missing content: testimonials, about section, service descriptions
4. AI translates to additional languages (fr/de/en/lb)
5. Integrated as optional step — POC builder works without AI (just uses scraped text + template defaults)
| File | Action (future) |
|---|---|
| `app/core/config.py` or `hosting/config.py` | Add AI provider config (API key, model, temperature) |
| `hosting/services/ai_content_service.py` | **CREATE**`enhance_content(scraped, template, language)`, `generate_missing(template_sections, scraped)`, `translate(content, target_langs)` |
| `hosting/services/poc_builder_service.py` | Call AI service between scrape and populate steps |
| `.env.example` | Add `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL` |
---
## Dependency Graph
```
Workstream 1 (hosting fix)
├── 1.1 Schema + service ← no dependencies
├── 1.2 Template form ← needs 1.1
├── 1.3 Simplify accept ← needs 1.1
└── 1.4 Tests ← needs 1.1-1.3
Workstream 2 (security audit)
├── 2A Service + endpoints ← no dependencies (model done)
├── 2B Report generation ← needs 2A
└── 2C Live demo ← needs 2A conceptually
Workstream 3 (POC builder)
├── 3A Content scraping ← no dependencies
├── 3B Template files ← no dependencies
├── 3C POC builder service ← needs 1.1 + 3A + 3B
└── 3D CMS section types ← needs 3B (to know what sections templates use)
Workstream 4 (AI) ← DEFERRED
└── needs 3C
```
**Parallelizable:** 1.1 + 2A + 3A + 3B can all start simultaneously.
---
## Estimated Scope
| Workstream | New Files | Modified Files | ~Lines |
|---|---|---|---|
| 1 (hosting fix) | 0 | 5 | ~200 |
| 2A (security service) | 4 | 6 | ~1200 |
| 2B (report) | 1 | 2 | ~400 |
| 2C (demo) | 4 | 3 | ~500 |
| 3A (content scrape) | 0 | 4 | ~200 |
| 3B (templates) | ~20 JSON | 2 | ~500 |
| 3C (POC builder) | 2 | 3 | ~300 |
| 3D (CMS sections) | ~8 | 2 | ~400 |
| **Total** | **~40** | **~27** | **~3700** |

View File

@@ -0,0 +1,236 @@
# Architecture Decision: Hosting Sites Leverage CMS + Store + StoreDomain
## Decision
Hosted sites (POC and live) reuse the existing CMS module, Store entity, and StoreDomain routing — **no separate site builder module**.
## Rationale
The CMS module already provides everything a hosted site needs:
- **ContentPage** — store-scoped pages with three-tier inheritance (platform defaults → store overrides)
- **MediaFile** — store-scoped media library
- **StoreTheme** — per-store colors, fonts, logo, layout, custom CSS
- **Storefront rendering** — Jinja2 templates that resolve store context and render CMS content
Building a parallel system would duplicate all of this.
## How It Works
### POC Phase (DRAFT → POC_READY → PROPOSAL_SENT)
1. `HostedSite.create()` creates a Store on the `hosting` platform
2. Store inherits **default CMS pages** from the hosting platform (homepage, about, contact, etc.)
3. Admin selects an **industry template** (e.g., "restaurant", "construction") which applies:
- Pre-built page sections (hero, services, gallery, testimonials, CTA)
- Industry-appropriate theme (colors, fonts, layout)
- Placeholder content that gets replaced with prospect data
4. Admin customizes ContentPages + StoreTheme during POC phase
5. POC preview accessible at subdomain: `acme.hostwizard.lu`
### Go Live (ACCEPTED → LIVE)
1. `go_live(domain)` calls `store_domain_service.add_domain()` — already implemented
2. Custom domain `acme.lu` maps to the Store via `StoreDomain`
3. `StoreContextMiddleware` resolves `acme.lu` → Store → CMS content
4. Caddy + Cloudflare handle SSL and routing — already configured for wildcard subdomains
### Content Flow
```
Prospect scanned (prospecting module)
↓ business_name, contacts, address, tech_profile
HostedSite created → Store created on hosting platform
Industry template applied → ContentPages populated with prospect data
↓ homepage hero: "Acme Construction — Strasbourg"
↓ contact page: scraped email, phone, address
↓ theme: construction industry colors + layout
Admin refines content in CMS editor
POC preview: acme.hostwizard.lu
Client approves → go live: acme.lu
```
## Industry Template System
### What a Template Is
A template is NOT a separate rendering engine. It is a **preset bundle** that populates CMS entities for a new Store:
- **ContentPages**: pre-built page slugs with section structures (hero, services, gallery, etc.)
- **StoreTheme**: industry-appropriate colors, fonts, layout style
- **Media placeholders**: stock images appropriate to the industry
- **Section blueprints**: JSON structures for homepage sections
### Template Registry
```
app/modules/hosting/templates_library/
├── manifest.json # Registry of all templates
├── restaurant/
│ ├── meta.json # Name, description, preview image, tags
│ ├── pages/ # ContentPage seed data (JSON per page)
│ │ ├── homepage.json # Sections: hero, menu-highlights, testimonials, reservation-cta
│ │ ├── about.json # Our story, team, values
│ │ ├── menu.json # Menu categories with placeholder items
│ │ └── contact.json # Map, hours, reservation form
│ └── theme.json # Colors, fonts, layout config
├── construction/
│ ├── meta.json
│ ├── pages/
│ │ ├── homepage.json # Sections: hero, services, projects-gallery, testimonials, cta
│ │ ├── services.json # Service cards (renovation, new build, etc.)
│ │ ├── projects.json # Portfolio gallery
│ │ └── contact.json # Quote request form, address, phone
│ └── theme.json
├── auto-parts/
│ ├── meta.json
│ ├── pages/
│ │ ├── homepage.json # Sections: hero, brands, categories, promotions
│ │ ├── catalog.json # Category grid
│ │ └── contact.json # Store locations, hours
│ └── theme.json
├── professional-services/ # Lawyers, accountants, consultants
│ ├── meta.json
│ ├── pages/
│ │ ├── homepage.json # Sections: hero, expertise, team, testimonials
│ │ ├── services.json
│ │ ├── team.json
│ │ └── contact.json
│ └── theme.json
└── generic/ # Fallback for any industry
├── meta.json
├── pages/
│ ├── homepage.json
│ ├── about.json
│ └── contact.json
└── theme.json
```
### Template Meta Format
```json
{
"id": "restaurant",
"name": "Restaurant & Dining",
"description": "Elegant template for restaurants, cafés, and bars",
"preview_image": "restaurant-preview.jpg",
"tags": ["food", "dining", "hospitality"],
"languages": ["en", "fr", "de"],
"pages": ["homepage", "about", "menu", "contact"],
"default_theme": {
"theme_name": "modern",
"colors": {
"primary": "#b45309",
"secondary": "#78350f",
"accent": "#f59e0b",
"background": "#fffbeb",
"text": "#1c1917"
},
"font_family_heading": "Playfair Display",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "transparent"
}
}
```
### Page Section Format (homepage.json example)
```json
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"sections": {
"hero": {
"type": "hero",
"headline": "{{business_name}}",
"subheadline": "Quality dining in {{city}}",
"cta_text": "Reserve a Table",
"cta_link": "/contact",
"background_image": "placeholder-restaurant-hero.jpg"
},
"features": {
"type": "features_grid",
"title": "Our Specialties",
"items": [
{"icon": "utensils", "title": "Fine Dining", "description": "..."},
{"icon": "wine-glass", "title": "Wine Selection", "description": "..."},
{"icon": "cake", "title": "Pastry", "description": "..."}
]
},
"testimonials": {
"type": "testimonials",
"title": "What Our Guests Say",
"items": []
},
"cta": {
"type": "cta_banner",
"headline": "Ready to visit?",
"cta_text": "Contact Us",
"cta_link": "/contact"
}
}
}
```
`{{business_name}}`, `{{city}}`, `{{phone}}`, `{{email}}` are replaced with prospect data at POC creation time.
## What Needs to Be Built
### Phase 1 — Template Infrastructure (in hosting module)
| Component | Purpose |
|---|---|
| `hosting/templates_library/` directory | Template files (JSON) |
| `hosting/services/template_service.py` | Load manifest, list templates, validate |
| `hosting/services/poc_builder_service.py` | Apply template to Store: create ContentPages, set StoreTheme, replace placeholders with prospect data |
| `hosting/schemas/template.py` | `TemplateListResponse`, `TemplateDetailResponse` |
| API: `GET /admin/hosting/templates` | List available templates with previews |
| API: `POST /admin/hosting/poc/build` | Build POC: `{prospect_id, template_id, merchant_id}` |
### Phase 2 — Template Content
Create 5 industry templates:
1. **Restaurant** — hero, menu highlights, testimonials, reservation CTA
2. **Construction** — hero, services, project gallery, quote CTA
3. **Auto Parts** — hero, brand grid, product categories, store locator
4. **Professional Services** — hero, expertise areas, team, case studies
5. **Generic** — clean, minimal, works for any business
Each template needs:
- 3-4 page JSONs with sections
- Theme JSON (colors, fonts)
- Meta JSON (name, description, tags, preview)
- Placeholder images (can use free stock photos initially)
### Phase 3 — Storefront Rendering Enhancement
The existing storefront templates render `ContentPage.sections` as HTML. Verify:
- All section types used by templates are supported by the storefront renderer
- Homepage sections (hero, features_grid, testimonials, cta_banner) render correctly
- Theme colors/fonts apply to the storefront
If new section types are needed (e.g., `menu`, `project_gallery`, `team_grid`), add them to the storefront section renderer.
## What Already Exists (No Work Needed)
- Store creation with subdomain
- StoreDomain custom domain assignment + verification
- StoreContextMiddleware (subdomain + custom domain + path-based routing)
- CMS ContentPage three-tier hierarchy
- StoreTheme with colors, fonts, layout, custom CSS
- MediaFile upload and serving
- Storefront page rendering
- Caddy wildcard SSL for `*.hostwizard.lu`
- Cloudflare proxy + WAF for custom domains
- `go_live()` already calls `store_domain_service.add_domain()`
## Relationship to Other Proposals
- **`hosting-site-creation-fix.md`**: Must be implemented first — site creation needs merchant_id or prospect_id (no system merchant hack)
- **`security-audit-demo-poc-builder.md` Phase 4**: This document replaces Phase 4 with a more detailed architecture. The POC builder is now "apply industry template to Store CMS" rather than a vague "build better site"

View File

@@ -0,0 +1,48 @@
# Hosting: Cascade Delete HostedSite → Store
## Problem
Deleting a HostedSite leaves the associated Store orphaned in the database. The Store's subdomain remains taken, so creating a new site with the same prospect/business fails with "slug already exists".
## Root Cause
- `hosted_site_service.delete()` does a hard delete on the HostedSite only
- The Store (created by `hosted_site_service.create()`) is not deleted
- `stores.subdomain` has a partial unique index (`WHERE deleted_at IS NULL`)
- The orphaned Store is still active → subdomain collision on re-create
## Recommendation
When deleting a HostedSite, also delete the associated Store:
```python
def delete(self, db: Session, site_id: int) -> bool:
site = self.get_by_id(db, site_id)
store = site.store
db.delete(site)
if store:
# Soft-delete or hard-delete the store created for this site
soft_delete(store) # or db.delete(store)
db.flush()
```
### Considerations
- **Soft vs hard delete**: If using soft-delete, the subdomain gets freed (partial unique index filters `deleted_at IS NULL`). If hard-deleting, cascade will also remove StorePlatform, ContentPages, StoreTheme, etc.
- **CMS content**: Deleting the Store cascades to ContentPages (created by POC builder) — this is desired since the POC content belongs to that store
- **Merchant**: The merchant created from the prospect should NOT be deleted — it may be used by other stores or relinked later
- **Safety**: Only delete stores that were created by the hosting module (check if store has a HostedSite backref). Don't delete stores that existed independently.
## Files to modify
| File | Change |
|---|---|
| `hosting/services/hosted_site_service.py` | Update `delete()` to also soft-delete/hard-delete the associated Store |
| `hosting/tests/unit/test_hosted_site_service.py` | Update delete test to verify Store is also deleted |
## Quick workaround (for now)
Manually delete the orphaned store from the DB:
```sql
DELETE FROM stores WHERE subdomain = 'batirenovation-strasbourg' AND id NOT IN (SELECT store_id FROM hosted_sites);
```

View File

@@ -0,0 +1,145 @@
# Security Audit + Live Demo + POC Builder Integration
## Context
The prospecting module scans websites for tech stack, performance, and contacts — but not security posture. Meanwhile, `scripts/security-audit/` has a standalone audit tool (audit.py) that checks HTTPS, headers, exposed files, cookies, etc., and a demo tool (demo.py) that clones a site and lets you demonstrate XSS/cookie theft/defacement live.
**Goal:** Integrate both into the platform as proper module features, plus architect a future POC site builder.
## Progress
- [x] Phase 1 foundation: `ProspectSecurityAudit` model, `last_security_audit_at` column, relationship on Prospect
- [ ] Phase 1 remaining: constants, service, migration, endpoints, frontend
- [ ] Phase 2: report generation
- [ ] Phase 3: live demo server
- [ ] Phase 4: POC builder (architecture only)
## Phase 1: Security Audit in Enrichment Pipeline
Slot security audit into the existing scan pipeline (alongside http-check, tech-scan, performance, contact-scrape).
### New files to create
| File | Purpose |
|---|---|
| `prospecting/models/security_audit.py` | **DONE**`ProspectSecurityAudit` model (1:1 on Prospect) |
| `prospecting/services/security_audit_service.py` | `SecurityAuditService.run_audit(db, prospect)` — migrates `SecurityAudit` class logic from `scripts/security-audit/audit.py` (check_https, check_ssl, check_headers, check_exposed_files, check_cookies, check_server_info, check_technology) |
| `prospecting/services/security_audit_constants.py` | TRANSLATIONS, EXPOSED_PATHS, SECURITY_HEADERS, SEVERITY_SCORES, GRADE_COLORS moved from audit.py |
| `prospecting/schemas/security_audit.py` | Pydantic schemas: `SecurityAuditResponse`, `SecurityAuditSingleResponse`, `SecurityAuditBatchResponse` |
| `prospecting/migrations/versions/prospecting_002_security_audit.py` | Creates `prospect_security_audits` table, adds `last_security_audit_at` to `prospects` |
### Files to modify
| File | Change |
|---|---|
| `prospecting/models/prospect.py` | **DONE**`last_security_audit_at` column + `security_audit` relationship |
| `prospecting/models/__init__.py` | **DONE** — Export `ProspectSecurityAudit` |
| `prospecting/services/prospect_service.py` | Add `get_pending_security_audit()` query |
| `prospecting/routes/api/admin_enrichment.py` | Add `/security-audit/batch` + `/security-audit/{prospect_id}` endpoints. Add to `full_enrichment` pipeline. |
| `prospecting/tasks/scan_tasks.py` | Add `batch_security_audit` Celery task + add to `full_enrichment` task |
| `prospecting/static/admin/js/scan-jobs.js` | Add `security_audit: 'security-audit'` to batchRoutes |
| `prospecting/templates/.../scan-jobs.html` | Add "Security Audit" batch button |
| `prospecting/templates/.../prospect-detail.html` | Add "Security" tab with grade badge, score bar, findings by severity, "Run Audit" button |
| `prospecting/static/admin/js/prospect-detail.js` | Add `runSecurityAudit()`, `severityColor()`, `gradeColor()` methods, `security_audit` tab handling |
| `prospecting/schemas/prospect.py` | Add `security_audit` field to `ProspectDetailResponse` |
### Key design decisions
- Findings stored as JSON (same as `tech_stack_json`, `lighthouse_json`) — variable structure
- Denormalized severity counts for dashboard queries without JSON parsing
- Reuse same `ScanBatchResponse` schema for batch endpoint
- `SECURITY_AUDIT` already exists in `JobType` enum — no enum change needed
## Phase 2: Security Report Generation
Generate branded bilingual HTML reports from stored audit data.
### New files
| File | Purpose |
|---|---|
| `prospecting/services/security_report_service.py` | `generate_html_report(audit, prospect, language, contact)` → standalone HTML string. Migrates `generate_report()` from audit.py (~300 lines of HTML template). |
### Files to modify
| File | Change |
|---|---|
| `prospecting/routes/api/admin_enrichment.py` | Add `GET /security-audit/{prospect_id}/report?language=auto&download=false``HTMLResponse` |
| `prospecting/templates/.../prospect-detail.html` | "Generate Report" button + language selector on security tab |
| `prospecting/static/admin/js/prospect-detail.js` | `openSecurityReport(lang)` method |
## Phase 3: Live Demo Server
Integrate `demo.py`'s site cloner + hacker console as a managed background process.
### New files
| File | Purpose |
|---|---|
| `prospecting/models/demo_session.py` | `DemoSession` model — prospect_id, status, port, pid, target_url, target_domain, started_at, stopped_at, error_message |
| `prospecting/services/demo_service.py` | `DemoService` — start_demo (clone + spawn server), stop_demo, stop_all, get_active. Uses `multiprocessing.Process` to run HTTPServer. Port range 9000-9099. |
| `prospecting/services/demo_constants.py` | HACKER_JS, HACKER_HTML_TEMPLATE, VULNERABLE_SEARCH_BAR, SPA_SCRIPT_DOMAINS migrated from demo.py |
| `prospecting/schemas/demo_session.py` | `DemoSessionResponse` (includes computed hacker_url, site_url) |
| `prospecting/routes/api/admin_demo.py` | `POST /demo/start/{prospect_id}`, `POST /demo/stop/{session_id}`, `GET /demo/active`, `POST /demo/stop-all` |
| `prospecting/migrations/versions/prospecting_003_demo_sessions.py` | Creates `prospect_demo_sessions` table |
### Files to modify
| File | Change |
|---|---|
| `prospecting/routes/api/admin.py` | Include demo router |
| `prospecting/config.py` | Add `demo_port_range_start/end`, `demo_max_concurrent`, `demo_auto_stop_minutes` |
| `prospecting/templates/.../prospect-detail.html` | "Launch Demo" / "Stop Demo" buttons + active demo indicator on security tab |
| `prospecting/static/admin/js/prospect-detail.js` | `startDemo()`, `stopDemo()`, `openHackerConsole()`, periodic status check |
### Key design decisions
- `multiprocessing.Process` (not subprocess) — can import SiteCloner/DemoHandler directly
- Auto-stop after 60 minutes (configurable)
- On app startup: cleanup stale RUNNING sessions from previous crashes
- Max 5 concurrent demos (configurable)
## Phase 4: POC Site Builder (Architecture Only — No Implementation)
Separate from security demo. Builds a better version of the client's site as proof-of-concept.
### Where it fits
- Triggered FROM prospect detail page ("Build POC Site" button)
- Creates artifacts IN the hosting module (`HostedSite` with status=DRAFT → POC_READY)
- Uses `HostedSite.prospect_id` FK (already exists) to link back
### Planned structure (future work)
- `hosting/services/poc_builder_service.py``create_poc(db, prospect_id, template, config)`
- `hosting/poc_templates/` — directory of starter templates with manifest
- `POST /admin/hosting/poc/build/{prospect_id}` — trigger POC generation
- `GET /admin/hosting/poc/templates` — list available templates
- HostedSite additions: `poc_template`, `poc_source_audit_id`, `poc_config_json` columns
## Implementation Order
```
Phase 1 (security audit pipeline) ← IN PROGRESS
Phase 2 (report generation) ← depends on Phase 1 model
↓ (can parallel with Phase 3)
Phase 3 (live demo server) ← independent, but logically follows
Phase 4 (POC builder) ← architecture only, deferred
```
## Verification
```bash
# Phase 1
python -m pytest app/modules/prospecting/tests/ -x -q --timeout=30
# Manual: Run enrichment on prospect 1, check security tab shows grade/findings
curl -X POST .../enrichment/security-audit/1 # single
curl -X POST .../enrichment/security-audit/batch # batch
# Phase 2
# Open report in browser:
curl .../enrichment/security-audit/1/report > report.html && open report.html
# Phase 3
# Start demo, open hacker console, verify attacks work, stop demo
curl -X POST .../demo/start/1 # returns port + URLs
curl -X POST .../demo/stop/{session_id}
```

17
main.py
View File

@@ -491,6 +491,23 @@ for route_info in storefront_page_routes:
)
# =============================================================================
# HOSTING PUBLIC PAGES (POC preview)
# =============================================================================
try:
from app.modules.hosting.routes.pages.public import router as hosting_public_router
app.include_router(
hosting_public_router,
prefix="",
tags=["hosting-public"],
include_in_schema=False,
)
logger.info("Registered hosting public page routes")
except ImportError:
pass # Hosting module not installed
# ============================================================================
# PLATFORM ROUTING (via PlatformContextMiddleware)
#

View File

@@ -128,6 +128,21 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
store = getattr(request.state, "store", None)
# Case 0: Preview token bypass (POC site previews)
preview_token = request.query_params.get("_preview")
if preview_token and store:
from app.core.preview_token import verify_preview_token
if verify_preview_token(preview_token, store.id):
request.state.is_preview = True
request.state.subscription = None
request.state.subscription_tier = None
logger.info(
"[STOREFRONT_ACCESS] Preview token valid for store '%s'",
store.subdomain,
)
return await call_next(request)
# Case 1: No store detected at all
if not store:
return self._render_unavailable(request, "not_found")

View File

@@ -21,6 +21,7 @@ python-multipart==0.0.20
# Data processing
pandas==2.2.3
requests==2.32.3
beautifulsoup4==4.14.3
# Image processing
Pillow>=10.0.0