From fd9026519c2be7563efb1e15bbda05ab979a1e8b Mon Sep 17 00:00:00 2001 From: Electron Rare Bot Date: Thu, 19 Mar 2026 14:37:45 +0000 Subject: [PATCH] chore: restore lead integration docs and ops tooling --- docs/README.md | 84 ++++---- docs/feature-map-ecosystem.md | 44 ++++ docs/lead-integration-spec-2026-03-19.md | 42 ++++ docs/oss-lead-form-veille-2026-03-19.md | 43 ++++ docs/project-master-todos.md | 25 +++ scripts/site-ops-tui.sh | 141 +++++++++++++ src/components/sections/Contact.astro | 6 +- src/pages/404.astro | 244 +++++++++++++++++++++++ src/pages/api/submit-lead.ts | 218 ++++++++++++++++++++ 9 files changed, 805 insertions(+), 42 deletions(-) create mode 100644 docs/feature-map-ecosystem.md create mode 100644 docs/lead-integration-spec-2026-03-19.md create mode 100644 docs/oss-lead-form-veille-2026-03-19.md create mode 100755 scripts/site-ops-tui.sh create mode 100644 src/pages/404.astro create mode 100644 src/pages/api/submit-lead.ts diff --git a/docs/README.md b/docs/README.md index ef324b2..11f3c43 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,72 +1,76 @@ # Docs README - Source de vérité -Updated: 2026-03-14 +Updated: 2026-03-19 Status: active -Repo: `/home/zacus/electron-rare-preview-ovh` +Repo: /root/electron-rare-site ## Etat réel du projet -- Site Astro publie sur OVH via GitHub Actions FTP -- Preview publique: `https://www.lelectronrare.fr/preview/` -- Production publique: `https://www.lelectronrare.fr/` -- Surface publique active: `/`, `/formation/`, `/mentions-legales/`, `robots.txt`, `sitemap.xml` -- Le lab n'est plus une surface publique produit +- Site Astro public en production sur Photon (Docker SSR + Traefik). +- URL publique active: https://www.lelectronrare.fr/ (route www.lelectronrare.fr et lelectronrare.fr). +- Exposition opérationnelle vérifiée: /, /formation/, /mentions-legales/, /404, robots.txt, sitemap.xml. +- Point de soumission lead: POST `/api/submit-lead` (route SSR -> Frappe CRM Lead), avec fallback automatique vers le CRM custom tant que nécessaire. +- Déploiement local d’atelier public retiré de la surface visible. - Positionnement actif: - systemes electroniques specifiques - familles visibles: electronique, automatisme, energie, stockage, optimisation - - l'embarque reste une competence, pas le titre principal - - les projets multi-techniques s'appuient sur les partenaires adaptes + - l'embarque reste une compétence de support, pas la promesse principale - Hero actif: - - titre: `Systemes electroniques specifiques, du besoin au livrable fiable` - - fond: oscilloscope en plan rapproche, pas atelier large ni texture carnet legacy - - surcouches: white-contrast propres, lisibles, sans retour a l'imaginaire `studio/figma` + - titre: Systemes electroniques specifiques, du besoin au livrable fiable + - visuel: oscilloscope en plan rapproche, lisibilite white-contrast + - surface de marque conforme au manifeste de production ## Lire dans cet ordre -1. `project-master-todos.md` -2. `design-memory-2026-03-14-white-contrast.md` -3. `da-01-implementation-backlog.md` -4. `EXECUTION-IMMÉDIATE-2026-03-02.md` -5. `SPRINT-PLAYBOOK-2026-03-02.md` -6. `ovh-ftp-preview-solution-2026-03-14.md` +1. project-master-todos.md +2. feature-map-ecosystem.md +3. docs/lead-integration-spec-2026-03-19.md +4. design-memory-2026-03-14-white-contrast.md +5. da-01-implementation-backlog.md +6. EXECUTION-IMMÉDIATE-2026-03-02.md +7. SPRINT-PLAYBOOK-2026-03-02.md +8. oss-lead-form-veille-2026-03-19.md ## Docs actives -- `project-master-todos.md` +- project-master-todos.md - backlog court terme, priorites, etat reel -- `design-memory-2026-03-14-white-contrast.md` +- lead-integration-spec-2026-03-19.md + - spec d'intégration du formulaire contact vers Frappe CRM + fallback legacy +- oss-lead-form-veille-2026-03-19.md + - veille OSS sur alternatives formulaire/lead + recommandation +- feature-map-ecosystem.md + - cartographie produit/processus + diagrammes Mermaid +- design-memory-2026-03-14-white-contrast.md - memoire DA et positionnement actif -- `da-01-implementation-backlog.md` +- da-01-implementation-backlog.md - backlog de consolidation design / UX / contenu -- `EXECUTION-IMMÉDIATE-2026-03-02.md` +- EXECUTION-IMMÉDIATE-2026-03-02.md - resume executif et ordre de sortie -- `SPRINT-PLAYBOOK-2026-03-02.md` - - commandes et checks operatoires -- `ovh-ftp-preview-solution-2026-03-14.md` - - runbook de deploy OVH FTP via GitHub Actions -- `migration-legacy-to-astro.md` - - regle de non-retour vers le legacy +- SPRINT-PLAYBOOK-2026-03-02.md + - runbook de commandes et checks operatoires +- pipeline-commercial/crm-open-source-benchmark.md + - veille OSS CRM/backoffice + décision de choix +- pipeline-commercial/AGENTS_AND_SKILLS_MATRIX_2026-03-19.md + - répartition agents, missions, plan de lot et priorités ## Docs historiques a ne plus traiter comme source de vérité - archive principale: - - `docs/archive/2026-03-legacy-plans/README.md` - - `docs/archive/2026-03-legacy-plans/` + - docs/archive/2026-03-legacy-plans/README.md + - docs/archive/2026-03-legacy-plans/ Contenu archive: -- anciens runbooks GitHub Pages et double hebergement +- anciens runbooks GitHub Pages / FTP - audits et quick references de debut mars 2026 - variantes Figma, wireflows, benchmarks et backlogs d'assets -- anciens snapshots KPI, GTM et copy sheets +- historiques KPI, GTM et copy sheets -Ces documents restent utiles comme archive de contexte, mais ils ont ete ecrits a un moment ou: -- GitHub Pages etait encore traite comme prod active -- le lab etait encore public -- le positionnement etait plus etroit ou plus experimental +Ces documents restent utiles pour contexte, mais ne gouvernent plus les opérations actives. ## Regles de mise à jour -- Toute nouvelle decision produit ou deploiement doit d'abord mettre a jour `project-master-todos.md` -- Toute decision de positionnement, DA ou surface publique doit mettre a jour `design-memory-2026-03-14-white-contrast.md` -- Toute instruction operatoire doit etre recalee dans `SPRINT-PLAYBOOK-2026-03-02.md` -- Toute doc qui devient trompeuse doit etre soit mise a jour, soit marquee comme historique +- Toute nouvelle decision produit ou deploiement doit d'abord mettre a jour project-master-todos.md. +- Toute decision de positionnement, DA ou surface publique doit mettre a jour design-memory-2026-03-14-white-contrast.md. +- Toute instruction operatoire doit etre recalee dans SPRINT-PLAYBOOK-2026-03-02.md. +- Toute doc devenue trompeuse doit etre soit mise a jour, soit archivee. diff --git a/docs/feature-map-ecosystem.md b/docs/feature-map-ecosystem.md new file mode 100644 index 0000000..50299c8 --- /dev/null +++ b/docs/feature-map-ecosystem.md @@ -0,0 +1,44 @@ +# Feature Map Ecosysteme - Electron Rare Site SSR + +Date: 2026-03-19 +Source de verite: `docs/README.md` + `src/*` + +## Portée active + +- Home conversion: hero, approche, preuves, missions, FAQ. +- Parcours lead: formulaire de contact + fallback mail. +- Exploitation: logs TUI, vérifications endpoint, déploiement via container. + +## Carte des fonctionnalités + +| Bloc | Objectif | Fichiers | Etat | +|---|---|---|---| +| Contact form direct | Générer un lead qualifié | `src/components/sections/Contact.astro`, `src/pages/api/submit-lead.ts` | Actif | +| Routing public | Pages et health en prod | `src/pages/index.astro`, `src/pages/formation.astro`, `src/pages/404.astro`, `src/pages/mentions-legales.astro` | Actif | +| Observabilité opérationnelle | Supervision du site en production | `scripts/site-ops-tui.sh` | Actif | +| Ops & déploiement | Build SSR + Docker + Traefik | `Dockerfile`, `infra/docker-compose.site.yml` | Actif | +| SEO / publication | Canonicals + sitemaps + robots | `src/lib/site.ts`, `scripts/build-astro-external.mjs`, `src/pages/sitemap.xml.ts` | Actif | + +## Mermaid - Surface active + +```mermaid +flowchart TD + V[Visiteur] -->|/ | H[Page Home] + V -->|/formation| F[Landing Formation] + V -->|/mentions-legales| L[Landing Mentions] + V -->|formulaire| C[Contact.astro] + C -->|POST| A[src/pages/api/submit-lead] + A -->|/api/resource/CRM Lead| CRM[Frappe CRM] + A -->|fallback| LEGACY[CRM custom] + CRM -->|dashboards| Ops[Reporting / pilotage] +``` + +## Mermaid - Contrôle opérationnel + +```mermaid +flowchart LR + T[site-ops-tui.sh] -->|status| S[Services docker] + T -->|endpoint| H[HTTP /api/submit-lead] + T -->|logs| L[docker logs + purge] + T -->|analyse| R[Résumé erreurs] +``` diff --git a/docs/lead-integration-spec-2026-03-19.md b/docs/lead-integration-spec-2026-03-19.md new file mode 100644 index 0000000..b3c4557 --- /dev/null +++ b/docs/lead-integration-spec-2026-03-19.md @@ -0,0 +1,42 @@ +# Spec d'integration lead — Site Public -> Frappe CRM + +Date: 2026-03-19 + +## Objectif + +Acheminer les soumissions du formulaire contact en production directement vers Frappe CRM Lead, tout en gardant un fallback sécurisé vers l'ancien CRM custom tant que la bascule n'est pas totalement close. + +## Flux cible + +1. Visiteur remplit `src/components/sections/Contact.astro`. +2. Clic sur `Envoi` envoie un `POST /api/submit-lead` (JSON). +3. `src/pages/api/submit-lead.ts`: + - valide champs (`name`, `email`, `consent`). + - mappe vers payload Frappe. + - tente `POST ${FRAPPE_URL}/api/resource/CRM Lead`. + - fallback vers `${CRM_FALLBACK_URL}/api/intake/lead` si Frappe indisponible. +4. Réponse UI: + - succès: écran de succès local + - échec: message d’erreur + trace de tracking + +## Contrats techniques + +- Environnement requis (runtime Docker): + - `FRAPPE_URL` + - `FRAPPE_API_KEY` + - `FRAPPE_API_SECRET` + - `CRM_FALLBACK_URL` (optionnel) +- Endpoint front: `CONTACT_FORM_ENDPOINT` + - `import.meta.env.PUBLIC_API_URL` + `/api/submit-lead` (prod) + - fallback `'/api/submit-lead'` + +## Risques connus + +- Si la vérification CORS doit couvrir un nouveau hostname, ajouter l’origine dans `ALLOWED_ORIGINS`. +- Les erreurs côté lead peuvent remonter sans détail JSON détaillé dans le client (redondance volontaire pour éviter fuite de secrets). + +## Critères d’acceptation + +- Un `POST /api/submit-lead` valide crée un lead dans Frappe. +- En cas de KO Frappe, fallback custom répond `2xx` si activé. +- Logs applicatifs montrent un parcours traceable sans fuite de secret. diff --git a/docs/oss-lead-form-veille-2026-03-19.md b/docs/oss-lead-form-veille-2026-03-19.md new file mode 100644 index 0000000..d667321 --- /dev/null +++ b/docs/oss-lead-form-veille-2026-03-19.md @@ -0,0 +1,43 @@ +# Veille OSS: alternatives pour soumission de leads (2026-03-19) + +## Contexte + +Basculement du formulaire de contact du site vers une API serveur Frappe CRM, avec fallback legacy. + +## Travaux de veille web (références) + +- **Frappe Framework / CRM API** + - API REST resource : `POST /api/resource/:doctype` selon docs Framework (`Frappe Lead` est un Doctype dédié en CRM). + - Authentification token basée sur `Authorization: token api_key:api_secret`. + - Sources: [Frappe REST API](https://docs.frappe.io/framework/user/en/api/rest), [Token Auth v15](https://docs.frappe.io/framework/v15/user/en/guides/integration/rest_api/token_based_authentication), [CRM Lead](https://docs.frappe.io/crm/lead). + +- **n8n** (orchestration webhooks) + - Intéressant comme pont d'intégration si logique métier/IA/relance doit s'intercaler. + - Docs webhook + self-hosted. + - Sources: [n8n Webhook node docs](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), [n8n Docs home](https://docs.n8n.io/). + +- **Formbricks** + - Plateforme de formulaires/feedback open-source avec mode self-host. + - Peut couvrir le besoin formulaire + analyses côté produit plus riche qu'un simple endpoint. + - Sources: [Formbricks repo](https://github.com/formbricks/formbricks), [Open Source page](https://formbricks.com/docs/overview/open-source). + +- **OhMyForm** + - Alternative formulaires type TypeForm/TellForm, orientée simplicité. + - Sources: [OhMyForm repo](https://github.com/ohmyform/ohmyform). + +- **Supabase (REST API + BaaS)** + - Très bon quand on veut centraliser auth/stockage/realtime dans une API PostgREST self-hostée. + - Sources: [Supabase API docs](https://supabase.com/docs/guides/api), [Architecture](https://supabase.com/docs/architecture). + +## Comparatif rapide (positionnement par usage) + +- Besoin immédiat: **Frappe direct** reste la moins risquée (aligné stack actuelle). +- Besoin d'orchestration complexe: **n8n** intéressant en complément, si on veut découpler des canaux. +- Besoin formulaire produit + analytics complet: **Formbricks** ou **OhMyForm** à évaluer selon charge d'exploitation. +- Besoin de backend complet et API SQL-centric: **Supabase** (overhead plus élevé) pour une couche générique. + +## Actions proposées + +1. Garder Frappe CRM comme source de vérité lead. +2. Utiliser n8n uniquement si déclenchements externes (Slack, ticketing, scoring IA) deviennent nécessaires. +3. Reporter une version de bench trimestrielle de coût opérationnel (CPU, RAM, maintenance, équipe). diff --git a/docs/project-master-todos.md b/docs/project-master-todos.md index 0dd39bd..bb9a932 100644 --- a/docs/project-master-todos.md +++ b/docs/project-master-todos.md @@ -10,6 +10,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA - Direction artistique active: `white-contrast`, claire par defaut, sans bouton de bascule public. - Positionnement actif: systemes electroniques specifiques, avec familles visibles electronique, automatisme, energie, stockage et optimisation; pas limite a l'embarque, avec partenaires mobilises quand le projet devient multi-technique. - Surface a maintenir: Hero, Approche, Cas concrets, photo strip, video strip, Missions, FAQ, Contact, footer. +- Endpoint lead production: `POST /api/submit-lead` (SSR -> Frappe CRM Lead, fallback optionnel `CRM_FALLBACK_URL`). ## Verites de reference - Point d'entree documentaire: `docs/README.md` @@ -19,6 +20,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA - Theme/layout: `src/layouts/BaseLayout.astro`, `src/styles/global.css`, `src/styles/home-workbench.css` - Canonical/base/deploiement: `src/lib/site.ts`, `scripts/build-astro-external.mjs`, `docs/ovh-ftp-preview-solution-2026-03-14.md` - Tracking actif: `src/lib/tracking.ts` + emitters inline dans `src/layouts/BaseLayout.astro` +- Endpoint opérationnel lead: `src/pages/api/submit-lead.ts` + intégration formulaire `src/components/sections/Contact.astro`. ## Etat courant - [x] Homepage OVH re-synchronisee dans le repo Astro. @@ -39,6 +41,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA - [x] Preview OVH republie avec le positionnement `electronique · automatisme · energie · stockage · optimisation` (`23098262445`). - [x] Production OVH republiee avec le meme positionnement (`23098335508`). - [x] `https://www.lelectronrare.fr/lab/` et `https://www.lelectronrare.fr/preview/lab/` repondent maintenant `404`. +- [x] `POST /api/submit-lead` actif et pointé vers Frappe CRM Lead en production (`electron-rare-site`). - [x] Le hero blanc utilise maintenant un vrai visuel oscilloscope, avec recadrage et overlays recales pour la DA claire. - [x] Preview OVH republie avec le hero oscilloscope recadre et le recadrage memoire/docs (`23099694984`). - [x] Production OVH republiee avec le meme hero oscilloscope (`23099760106`). @@ -97,3 +100,25 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA - Toute doc active doit parler du site Astro/OVH actuel, pas d'une ancienne homepage statique. - Les ancres de reference sont celles du code actuel, pas `#projets`. - Le theme de reference est clair/white-contrast par defaut. + + +## Deploiement SSR Photon (2026-03-19) +- [x] Creer un dockerfile multi-stage pour build + runtime serve (production legere). +- [x] Creer le compose site et la route Traefik associee. +- [x] Ajouter un .env.example d'exemple pour deploiement et activer FRAPPE_* + PUBLIC_SITE_URL. + - [x] Route Traefik externe: https://www.lelectronrare.fr et lelectronrare.fr (source réelle: /root/zacus-stack/config/traefik/dynamic/electron-rare-site.yml) +- [x] Rebuilder/redeployer le service electron-rare-site sur port 4321. +- [x] Verifier routes: /, /404, /mentions-legales => 200. +- [x] Ajouter la page mentions-legales et la page 404. +- [x] Brancher le formulaire de contact vers Frappe CRM en production. + +## Carte de flux lead (prod) + +```mermaid +flowchart LR + V[Visiteur] -->|Complète formulaire| F[Contact.astro /api/submit-lead] + F -->|POST JSON| A[src/pages/api/submit-lead] + A -->|POST| R[Frappe CRM Lead] + A -->|fallback (si Frappe KO)| C[CRM custom legacy] + R -->|Lead stocke| D[Dashboard CRM + pipeline] +``` diff --git a/scripts/site-ops-tui.sh b/scripts/site-ops-tui.sh new file mode 100755 index 0000000..67608a5 --- /dev/null +++ b/scripts/site-ops-tui.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +SITE_SERVICE="electron-rare-site" +TRAEFIK_SERVICE="zacus-traefik" +COMPOSE_FILE="/root/electron-rare-site/infra/docker-compose.site.yml" +LOG_DIR="/root/electron-rare-site/artifacts/ops" +LOG_FILE="$LOG_DIR/site-ops-tui.log" +DOMAIN="${DOMAIN:-https://www.lelectronrare.fr}" +SITE_BASE_PATH="${SITE_BASE_PATH:-}" +API_ENDPOINT="${API_ENDPOINT:-${DOMAIN}${SITE_BASE_PATH}/api/submit-lead}" + +mkdir -p "$LOG_DIR" + +log() { + echo "$(date -Iseconds) $*" | tee -a "$LOG_FILE" +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing command: $1" >&2 + exit 1 + } +} + +status_panel() { + log "STATUS: check docker services" + docker compose -f "$COMPOSE_FILE" ps --services --filter "name=$SITE_SERVICE" | xargs -r -I{} docker compose -f "$COMPOSE_FILE" ps {} + log "STATUS: service inspect" + docker inspect -f 'service={{.Name}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$SITE_SERVICE" "$TRAEFIK_SERVICE" 2>/dev/null || true +} + +endpoint_panel() { + log "HTTP checks for $DOMAIN" + for path in "" "/404" "/mentions-legales"; do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 6 "$DOMAIN$path" || true) + log "GET $path => $code" + echo "GET $path => $code" + done + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 -X POST "$API_ENDPOINT" \ + -H 'Content-Type: application/json' \ + -d '{"name":"ops-test","email":"ops@test.lelectronrare.fr","needType":"technique","sourceChannel":"site","sourceDetail":"ops_health","consent":true}' || true) + log "POST $API_ENDPOINT => $code" + echo "POST $API_ENDPOINT => $code" +} + +analyze_logs() { + log "LOG ANALYSE: $LOG_FILE" + if [ ! -f "$LOG_FILE" ]; then + echo "Aucun log local: $LOG_FILE" + return + fi + total=$(wc -l < "$LOG_FILE") + errors=$(grep -ciE 'error|fatal|exception|traceback' "$LOG_FILE" || true) + warn=$(grep -ciE 'warn|warn:' "$LOG_FILE" || true) + echo "Total lignes: $total" + echo "Lignes erreur: $errors" + echo "Lignes warn: $warn" + if [ "$errors" -gt 0 ]; then + echo "--- top erreurs ---" + grep -niE 'error|fatal|exception|traceback' "$LOG_FILE" | head -n 20 + fi +} + +logs_panel() { + log "TAIL logs: $SITE_SERVICE (40 lines)" + docker logs --tail 40 "$SITE_SERVICE" | tee -a "$LOG_FILE" + echo "---" + log "TAIL logs: $TRAEFIK_SERVICE (40 lines)" + docker logs --tail 40 "$TRAEFIK_SERVICE" | tee -a "$LOG_FILE" +} + +cleanup_logs() { + local keep_days="${1:-7}" + log "CLEANUP: docker json logs if writable" + for svc in "$SITE_SERVICE" "$TRAEFIK_SERVICE"; do + logpath=$(docker inspect -f '{{.LogPath}}' "$svc" 2>/dev/null || true) + if [ -n "$logpath" ] && [ -f "$logpath" ]; then + : > "$logpath" + log "truncated $logpath for $svc" + else + log "no log file for $svc" + fi + done + + if [ -d "$LOG_DIR" ]; then + find "$LOG_DIR" -type f -name "*.log" -mtime +"$keep_days" -print -delete | while IFS= read -r removed; do + log "deleted old local artifact log: $removed" + done + fi + log "CLEANUP complete (keep $keep_days days)" +} + +menu() { + echo "1) status" + echo "2) logs" + echo "3) endpoints" + echo "4) cleanup" + echo "5) full" + echo "6) analyze logs" + echo "0) quit" + read -r -p "choix: " choice + case "$choice" in + 1) status_panel ;; + 2) logs_panel ;; + 3) endpoint_panel ;; + 4) read -r -p "jours a garder [7]: " days; cleanup_logs "${days:-7}" ;; + 5) status_panel; logs_panel; endpoint_panel ;; + 6) analyze_logs ;; + *) exit 0 ;; + esac +} + +case "${1:-menu}" in + status) + require_cmd docker + status_panel + ;; + logs) + require_cmd docker + logs_panel + ;; + endpoint*|http) + require_cmd curl + endpoint_panel + ;; + cleanup) + require_cmd docker + cleanup_logs "${2:-7}" + ;; + run) + require_cmd docker + require_cmd curl + status_panel + logs_panel + endpoint_panel + ;; + menu|*) + menu + ;; +esac diff --git a/src/components/sections/Contact.astro b/src/components/sections/Contact.astro index 7f60d4a..fda8e1b 100644 --- a/src/components/sections/Contact.astro +++ b/src/components/sections/Contact.astro @@ -13,6 +13,8 @@ Ce que j'attends : Contraintes (délai, budget) :`; const defaultMailtoHref = `mailto:contact@lelectronrare.fr?subject=${encodeURIComponent("Contact L'Électron Rare")}&body=${encodeURIComponent(DEFAULT_MESSAGE)}`; +const apiBase = (import.meta.env.PUBLIC_API_URL || '').replace(/\/$/, ''); +const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/submit-lead'; ---
-
+
+ + diff --git a/src/pages/api/submit-lead.ts b/src/pages/api/submit-lead.ts new file mode 100644 index 0000000..fb52132 --- /dev/null +++ b/src/pages/api/submit-lead.ts @@ -0,0 +1,218 @@ +import type { APIRoute } from 'astro'; + +interface LeadSubmission { + name: string; + email: string; + phone?: string; + organization?: string; + sourceChannel?: string; + sourceDetail?: string; + pagePath?: string; + needType?: string; +} + +interface FrappePayload { + first_name: string; + last_name?: string; + email: string; + mobile_no?: string; + organization?: string; + custom_source_channel: string; + custom_source_detail?: string; + custom_need_type: string; + custom_segment: string; +} + +// Strict email validation (RFC 5322 simplified) +const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + +// Rate limiting: simple in-memory sliding window (per IP) +const rateLimitMap = new Map(); +const RATE_LIMIT = 10; +const RATE_WINDOW_MS = 3600_000; // 1 hour + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const timestamps = rateLimitMap.get(ip) ?? []; + const recent = timestamps.filter(t => now - t < RATE_WINDOW_MS); + if (recent.length >= RATE_LIMIT) return true; + recent.push(now); + rateLimitMap.set(ip, recent); + return false; +} + +// Allowed origins for CORS +const ALLOWED_ORIGINS = new Set([ + 'https://www.lelectronrare.fr', + 'https://lelectronrare.fr', + 'https://electron-rare.github.io', + 'https://electron.saillant.cc', +]); + +function corsHeaders(origin: string | null): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (origin && ALLOWED_ORIGINS.has(origin)) { + headers['Access-Control-Allow-Origin'] = origin; + } + return headers; +} + +export const POST: APIRoute = async ({ request, clientAddress }) => { + const origin = request.headers.get('origin'); + + // CORS check — reject unknown origins (allow null for same-origin) + if (origin && !ALLOWED_ORIGINS.has(origin)) { + return new Response(JSON.stringify({ error: 'forbidden' }), { + status: 403, + headers: corsHeaders(null), + }); + } + + // Rate limiting + const ip = clientAddress || request.headers.get('x-forwarded-for') || 'unknown'; + if (isRateLimited(ip)) { + return new Response(JSON.stringify({ error: 'too many requests' }), { + status: 429, + headers: { ...corsHeaders(origin), 'Retry-After': '3600' }, + }); + } + + const FRAPPE_URL = import.meta.env.FRAPPE_URL || process.env.FRAPPE_URL; + const FRAPPE_API_KEY = import.meta.env.FRAPPE_API_KEY || process.env.FRAPPE_API_KEY; + const FRAPPE_API_SECRET = import.meta.env.FRAPPE_API_SECRET || process.env.FRAPPE_API_SECRET; + const CRM_FALLBACK_URL = import.meta.env.CRM_FALLBACK_URL || process.env.CRM_FALLBACK_URL; + + if (!FRAPPE_URL || !FRAPPE_API_KEY || !FRAPPE_API_SECRET) { + console.error('[submit-lead] Missing Frappe env vars'); + return new Response(JSON.stringify({ error: 'server misconfigured' }), { + status: 500, + headers: corsHeaders(origin), + }); + } + + let body: LeadSubmission; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: 'invalid JSON body' }), { + status: 400, + headers: corsHeaders(origin), + }); + } + + // Validate required fields + const name = typeof body.name === 'string' ? body.name.trim() : ''; + const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; + + if (!name || name.length < 2 || name.length > 200) { + return new Response(JSON.stringify({ error: 'name must be 2-200 characters' }), { + status: 400, + headers: corsHeaders(origin), + }); + } + + if (!email || !EMAIL_RE.test(email)) { + return new Response(JSON.stringify({ error: 'valid email required' }), { + status: 400, + headers: corsHeaders(origin), + }); + } + + // Split full name into first/last + const nameParts = name.split(/\s+/); + const firstName = nameParts[0]; + const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined; + + const sourceChannel = body.sourceChannel || 'site'; + const needType = body.needType || 'technique'; + + const segment = + needType === 'formation' + ? 'formation' + : sourceChannel === 'fat' || sourceChannel === 'hemisphere' + ? 'culture' + : 'industrie'; + + const frappePayload: FrappePayload = { + first_name: firstName, + email, + custom_source_channel: sourceChannel, + custom_need_type: needType, + custom_segment: segment, + }; + + if (lastName) frappePayload.last_name = lastName; + if (body.phone) frappePayload.mobile_no = body.phone; + if (body.organization) frappePayload.organization = body.organization; + if (body.sourceDetail || body.pagePath) { + frappePayload.custom_source_detail = body.sourceDetail || body.pagePath; + } + + console.info('[submit-lead] Submitting lead', { email, sourceChannel, needType, segment }); + + const FRAPPE_HOST = import.meta.env.FRAPPE_HOST_HEADER || process.env.FRAPPE_HOST_HEADER || ''; + + try { + const frappeHeaders: Record = { + 'Content-Type': 'application/json', + Authorization: `token ${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}`, + }; + if (FRAPPE_HOST) frappeHeaders['Host'] = FRAPPE_HOST; + + const resp = await fetch(`${FRAPPE_URL}/api/resource/CRM Lead`, { + method: 'POST', + headers: frappeHeaders, + body: JSON.stringify(frappePayload), + }); + + if (!resp.ok) { + // Log status only — never log response body (may contain secrets) + console.error('[submit-lead] Frappe returned', resp.status); + + // Dual-write fallback + if (CRM_FALLBACK_URL) { + console.info('[submit-lead] Attempting CRM fallback'); + try { + const fallbackResp = await fetch(`${CRM_FALLBACK_URL}/api/intake/lead`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + email, + phone: body.phone, + organization: body.organization, + sourceChannel, + needType, + }), + }); + if (fallbackResp.ok) { + return new Response(JSON.stringify({ ok: true, via: 'fallback' }), { + status: 201, + headers: corsHeaders(origin), + }); + } + console.error('[submit-lead] Fallback returned', fallbackResp.status); + } catch { + console.error('[submit-lead] Fallback network error'); + } + } + + return new Response(JSON.stringify({ error: 'submission failed' }), { + status: 502, + headers: corsHeaders(origin), + }); + } + + console.info('[submit-lead] Lead created'); + return new Response(JSON.stringify({ ok: true }), { + status: 201, + headers: corsHeaders(origin), + }); + } catch { + console.error('[submit-lead] Network error'); + return new Response(JSON.stringify({ error: 'internal error' }), { + status: 500, + headers: corsHeaders(origin), + }); + } +};