diff --git a/.env.example b/.env.example index 023cbe2..1b5f78e 100644 --- a/.env.example +++ b/.env.example @@ -6,13 +6,22 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small AI_PROVIDER=kimi-code KIMI_CODE_API_KEY= KIMI_CODE_BASE_URL=https://api.kimi.com/coding/v1 -KIMI_RESEARCH_MODELS=kimi-for-coding,k3,kimi-for-coding-highspeed -KIMI_SYNTHESIS_MODELS=kimi-for-coding,kimi-for-coding-highspeed,k3 +KIMI_RESEARCH_MODELS=k3,k3-256k +KIMI_SYNTHESIS_MODELS=k3-256k,k3 SEARXNG_URL=http://searxng:8080 SEARXNG_SECRET=replace-with-a-random-internal-secret UNIPILE_DSN=https://api37.unipile.com:16796 UNIPILE_API_KEY= UNIPILE_LINKEDIN_ACCOUNT_ID= +UNIPILE_WHATSAPP_ACCOUNT_ID= +UNIPILE_WEBHOOK_SECRET= +UNIPILE_CHAT_SYNC_ENABLED=true +CALENDAR_WEBHOOK_SIGNING_KEY= +PUBLIC_WEBHOOK_BASE_URL=http://localhost:3001 +OUTBOUND_LINKEDIN_DAILY_LIMIT=20 +OUTBOUND_EMAIL_DAILY_LIMIT=50 +OUTBOUND_WHATSAPP_DAILY_LIMIT=30 +BOOKING_URL= CRAWLER_SERVICE_URL=http://127.0.0.1:8000 CRAWLER_API_KEY= SEARCH_FALLBACK_ENABLED=true @@ -35,6 +44,8 @@ BOOTSTRAP_WORKSPACE_SLUG=ignition-ai BOOTSTRAP_WORKSPACE_NAME=IgnitionAI PORT=3001 WORKER_ID=research-worker-1 +DAILY_PROSPECTING_TIME=06:00 +DAILY_PROSPECTING_TIMEZONE=Europe/Paris JOB_LEASE_MS=60000 JOB_BATCH_SIZE=1 JOB_POLL_INTERVAL_MS=1000 diff --git a/.gitignore b/.gitignore index 7ae0159..422e746 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ node_modules/ .next/ dist/ coverage/ +__pycache__/ +.pytest_cache/ +.venv/ +*.py[cod] *.tsbuildinfo *.log .env diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3e9b017..cd877d3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { createResearchDocumentHttpHandler } from "@outbound/interface/http/rese import { createCrmHttpHandler } from "@outbound/interface/http/crm-handler"; import { createDiscoveryHttpHandler } from "@outbound/interface/http/discovery-handler"; import { createSequenceHttpHandler } from "@outbound/interface/http/sequence-handler"; +import { createCampaignHttpHandler } from "@outbound/interface/http/campaign-handler"; import { ProviderUnavailableError, UnipileProspectSource, @@ -19,6 +20,19 @@ import { WorkspaceAiSettingsApplication } from "@outbound/application/workspaces import { PostgresWorkspaceAiSettingsRepository } from "@outbound/infrastructure/workspaces/postgres-workspace-ai-settings-repository"; import { createWorkspaceAiSettingsHttpHandler } from "@outbound/interface/http/workspace-ai-settings-handler"; import { resolveResearchModelPolicyFromEnvironment } from "@outbound/infrastructure/ai/langchain-research-agent-executor"; +import { CrawlerClient } from "@outbound/infrastructure/ai/crawler-client"; +import { CrawlerProspectEnricher } from "@outbound/infrastructure/crm/crawler-prospect-enricher"; +import { UnipileWebhookIngestor } from "@outbound/infrastructure/campaigns/unipile-webhook-ingestor"; +import { createUnipileWebhookHttpHandler } from "@outbound/interface/http/unipile-webhook-handler"; +import { PostgresCalendarIntegration } from "@outbound/infrastructure/calendar/postgres-calendar-integration"; +import { createCalendarConnectionHttpHandler } from "@outbound/interface/http/calendar-connection-handler"; +import { createCalendarWebhookHttpHandler } from "@outbound/interface/http/calendar-webhook-handler"; +import { PostgresOpportunityRepository } from "@outbound/infrastructure/pipeline/postgres-opportunity-repository"; +import { createOpportunityHttpHandler } from "@outbound/interface/http/opportunity-handler"; +import { LangChainConversationDraftImprover } from "@outbound/infrastructure/campaigns/langchain-conversation-draft-improver"; +import { PostgresUnipileChannelConnections } from "@outbound/infrastructure/channels/postgres-unipile-channel-connections"; +import { createChannelConnectionHttpHandler } from "@outbound/interface/http/channel-connection-handler"; +import { PostgresChannelCapabilityReassessment } from "@outbound/infrastructure/campaigns/channel-capability-reassessment"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -74,10 +88,27 @@ const crm = createCrmHttpHandler({ }); const unipileDsn = process.env.UNIPILE_DSN ?? ""; const unipileApiKey = process.env.UNIPILE_API_KEY ?? ""; +const unipileChannelConnections = unipileDsn && unipileApiKey + ? new PostgresUnipileChannelConnections(database.db, { dsn: unipileDsn, apiKey: unipileApiKey }) + : null; +const channelConnection = createChannelConnectionHttpHandler({ + connections: unipileChannelConnections, + contextResolver: auth.contextResolver, + reassessment: new PostgresChannelCapabilityReassessment(database.db), +}); +const discoveryCrawler = + process.env.CRAWLER_SERVICE_URL && process.env.CRAWLER_API_KEY + ? new CrawlerClient({ + baseUrl: process.env.CRAWLER_SERVICE_URL, + apiKey: process.env.CRAWLER_API_KEY, + maxConcurrentPageReads: 2, + }) + : null; const discovery = createDiscoveryHttpHandler({ database: database.db, contextResolver: auth.contextResolver, - prospectSource: () => { + jobQueue: queue, + prospectSource: (workspaceId) => { if (!unipileDsn || !unipileApiKey) { return { async searchPeople() { @@ -94,13 +125,51 @@ const discovery = createDiscoveryHttpHandler({ ...(process.env.UNIPILE_LINKEDIN_ACCOUNT_ID ? { accountId: process.env.UNIPILE_LINKEDIN_ACCOUNT_ID } : {}), + ...(process.env.UNIPILE_WHATSAPP_ACCOUNT_ID + ? { whatsappAccountId: process.env.UNIPILE_WHATSAPP_ACCOUNT_ID } + : {}), + ...(unipileChannelConnections + ? { resolveWhatsappAccountId: () => unipileChannelConnections.selectedAccountId(workspaceId, "whatsapp") } + : {}), }); }, + prospectEnricher: () => + discoveryCrawler ? new CrawlerProspectEnricher(discoveryCrawler) : null, }); const sequenceHandler = createSequenceHttpHandler({ database: database.db, contextResolver: auth.contextResolver, }); +const campaignHandler = createCampaignHttpHandler({ + database: database.db, + contextResolver: auth.contextResolver, + jobQueue: queue, + draftImprover: new LangChainConversationDraftImprover( + database.db, + process.env, + workspaceAiSettingsRepository, + ), +}); +const unipileWebhook = createUnipileWebhookHttpHandler({ + ingestor: new UnipileWebhookIngestor(database.db), + secret: process.env.UNIPILE_WEBHOOK_SECRET ?? "", +}); +const calendarSigningKey = process.env.CALENDAR_WEBHOOK_SIGNING_KEY + ?? requiredSecretEnvironment("BETTER_AUTH_SECRET"); +const calendarIntegration = new PostgresCalendarIntegration(database.db, calendarSigningKey); +const calendarConnection = createCalendarConnectionHttpHandler({ + integration: calendarIntegration, + contextResolver: auth.contextResolver, + publicWebhookBaseUrl: process.env.PUBLIC_WEBHOOK_BASE_URL ?? requiredEnvironment("BETTER_AUTH_URL"), +}); +const calendarWebhook = createCalendarWebhookHttpHandler({ + integration: calendarIntegration, + signingKey: calendarSigningKey, +}); +const opportunityHandler = createOpportunityHttpHandler({ + repository: new PostgresOpportunityRepository(database.db), + contextResolver: auth.contextResolver, +}); const port = positiveIntegerEnvironment("PORT", 3000); const server = Bun.serve({ port, @@ -108,10 +177,15 @@ const server = Bun.serve({ async fetch(request) { const pathname = new URL(request.url).pathname; if (pathname.startsWith("/api/auth/")) return auth.handle(request); + if (pathname === "/api/v1/webhooks/unipile") return unipileWebhook(request); + if (pathname.startsWith("/api/v1/webhooks/calendar/")) return calendarWebhook(request); + if (pathname === "/api/v1/calendar-connection") return calendarConnection(request); + if (pathname.startsWith("/api/v1/channel-connections/")) return channelConnection(request); + if (pathname.startsWith("/api/v1/opportunities")) return opportunityHandler(request); if (pathname === "/api/v1/workspaces") return workspace(request); if (pathname === "/api/v1/workspace-ai-settings") return workspaceAiSettings(request); if (pathname.startsWith("/api/v1/research-documents")) return documents(request); - if (pathname.startsWith("/api/v1/companies") || pathname.startsWith("/api/v1/contacts")) { + if (pathname.startsWith("/api/v1/companies") || pathname.startsWith("/api/v1/contacts") || pathname.startsWith("/api/v1/prospects")) { return crm(request); } if (pathname.startsWith("/api/v1/icp-versions") || pathname.startsWith("/api/v1/discovery-runs")) { @@ -120,6 +194,14 @@ const server = Bun.serve({ if (pathname.startsWith("/api/v1/sequences")) { return sequenceHandler(request); } + if ( + pathname.startsWith("/api/v1/campaigns") || + pathname.startsWith("/api/v1/prospecting-plans") || + pathname.startsWith("/api/v1/channel-assessments") + || pathname.startsWith("/api/v1/conversations") + ) { + return campaignHandler(request); + } if (pathname === "/health/live") return Response.json({ status: "ok" }); if (pathname === "/health/ready") { try { diff --git a/apps/crawler/src/crawler_service/__pycache__/__init__.cpython-312.pyc b/apps/crawler/src/crawler_service/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 2762cc6..0000000 Binary files a/apps/crawler/src/crawler_service/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/__pycache__/config.cpython-312.pyc b/apps/crawler/src/crawler_service/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 4d2360a..0000000 Binary files a/apps/crawler/src/crawler_service/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/__pycache__/main.cpython-312.pyc b/apps/crawler/src/crawler_service/__pycache__/main.cpython-312.pyc deleted file mode 100644 index e76ba39..0000000 Binary files a/apps/crawler/src/crawler_service/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/api/__pycache__/__init__.cpython-312.pyc b/apps/crawler/src/crawler_service/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b55127e..0000000 Binary files a/apps/crawler/src/crawler_service/api/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/api/__pycache__/routes.cpython-312.pyc b/apps/crawler/src/crawler_service/api/__pycache__/routes.cpython-312.pyc deleted file mode 100644 index 5d2dec2..0000000 Binary files a/apps/crawler/src/crawler_service/api/__pycache__/routes.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/api/__pycache__/schemas.cpython-312.pyc b/apps/crawler/src/crawler_service/api/__pycache__/schemas.cpython-312.pyc deleted file mode 100644 index 77198c7..0000000 Binary files a/apps/crawler/src/crawler_service/api/__pycache__/schemas.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/api/routes.py b/apps/crawler/src/crawler_service/api/routes.py index fbf8b72..287c3c4 100644 --- a/apps/crawler/src/crawler_service/api/routes.py +++ b/apps/crawler/src/crawler_service/api/routes.py @@ -296,6 +296,15 @@ async def crawl_selected_pages(request: CrawlPagesRequest): Returns a job ID for tracking progress. """ urls = [str(u) for u in request.urls] + if request.idempotencyKey: + existing = job_manager.get_job_by_idempotency_key(request.idempotencyKey) + if existing: + return CrawlPagesStartResponse( + success=True, + id=existing.id, + urlCount=len(urls), + message="Existing idempotent crawl job returned", + ) # Acquire a concurrency slot BEFORE creating anything — same rule as # /crawl: no slot, no job, and release_slot() is only called for slots @@ -323,6 +332,7 @@ async def crawl_selected_pages(request: CrawlPagesRequest): include_images=request.includeImages, exclude_patterns=[], include_patterns=[], + idempotency_key=request.idempotencyKey, ) # Start the job diff --git a/apps/crawler/src/crawler_service/api/schemas.py b/apps/crawler/src/crawler_service/api/schemas.py index e7d43c0..1a9c241 100644 --- a/apps/crawler/src/crawler_service/api/schemas.py +++ b/apps/crawler/src/crawler_service/api/schemas.py @@ -70,6 +70,7 @@ class CrawlRequest(BaseModel): description="URL patterns to include (regex)", ) correlationId: str | None = Field(default=None, max_length=200) + idempotencyKey: str | None = Field(default=None, min_length=8, max_length=500) _validate_patterns = field_validator( "excludePatterns", @@ -197,6 +198,7 @@ class CrawlPagesRequest(BaseModel): description="Extract image URLs from pages", ) correlationId: str | None = Field(default=None, max_length=200) + idempotencyKey: str | None = Field(default=None, min_length=8, max_length=500) class CrawlPagesStartResponse(BaseModel): diff --git a/apps/crawler/src/crawler_service/core/__pycache__/__init__.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b32f047..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/crawler.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/crawler.cpython-312.pyc deleted file mode 100644 index ea37980..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/crawler.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/discovery.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/discovery.cpython-312.pyc deleted file mode 100644 index 3dd0f7b..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/discovery.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/domain_limiter.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/domain_limiter.cpython-312.pyc deleted file mode 100644 index 38a5483..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/domain_limiter.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/job_manager.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/job_manager.cpython-312.pyc deleted file mode 100644 index 89843f6..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/job_manager.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/request_safety.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/request_safety.cpython-312.pyc deleted file mode 100644 index df984ae..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/request_safety.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/search.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/search.cpython-312.pyc deleted file mode 100644 index f086af1..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/search.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/sse_emitter.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/sse_emitter.cpython-312.pyc deleted file mode 100644 index b88d100..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/sse_emitter.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/__pycache__/url_safety.cpython-312.pyc b/apps/crawler/src/crawler_service/core/__pycache__/url_safety.cpython-312.pyc deleted file mode 100644 index 90f0532..0000000 Binary files a/apps/crawler/src/crawler_service/core/__pycache__/url_safety.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/core/job_manager.py b/apps/crawler/src/crawler_service/core/job_manager.py index df7fab9..43193c5 100644 --- a/apps/crawler/src/crawler_service/core/job_manager.py +++ b/apps/crawler/src/crawler_service/core/job_manager.py @@ -67,6 +67,7 @@ class JobManager: def __init__(self): self._jobs: dict[str, CrawlJob] = {} + self._idempotency_keys: dict[str, str] = {} self._semaphore = asyncio.Semaphore(settings.max_concurrent_crawls) self._lock = asyncio.Lock() @@ -86,8 +87,14 @@ def create_job( include_images: bool = True, exclude_patterns: list[str] | None = None, include_patterns: list[str] | None = None, + idempotency_key: str | None = None, ) -> CrawlJob: """Create a new crawl job.""" + if idempotency_key: + existing_id = self._idempotency_keys.get(idempotency_key) + existing = self._jobs.get(existing_id) if existing_id else None + if existing: + return existing job_id = str(uuid.uuid4()) job = CrawlJob( id=job_id, @@ -101,8 +108,15 @@ def create_job( event_queue=asyncio.Queue(), ) self._jobs[job_id] = job + if idempotency_key: + self._idempotency_keys[idempotency_key] = job_id return job + def get_job_by_idempotency_key(self, key: str) -> CrawlJob | None: + """Return the current in-memory job for an idempotent request.""" + job_id = self._idempotency_keys.get(key) + return self._jobs.get(job_id) if job_id else None + def get_job(self, job_id: str) -> CrawlJob | None: """Get a job by ID.""" return self._jobs.get(job_id) diff --git a/apps/crawler/src/crawler_service/core/request_safety.py b/apps/crawler/src/crawler_service/core/request_safety.py index 28d62e5..8fb1d8e 100644 --- a/apps/crawler/src/crawler_service/core/request_safety.py +++ b/apps/crawler/src/crawler_service/core/request_safety.py @@ -32,21 +32,81 @@ def collected_at() -> str: async def install_safe_request_interceptor(page, **_kwargs): """Abort every browser request whose target is not publicly routable.""" + context = getattr(page, "context", None) + new_cdp_session = getattr(context, "new_cdp_session", None) + if callable(new_cdp_session): + try: + client = await new_cdp_session(page) + + async def guard_cdp(event): + request_id = event["requestId"] + target = event["request"]["url"] + scheme = urlparse(target).scheme + if scheme in ("data", "blob", "about") or await is_url_allowed_async(target): + await client.send( + "Fetch.continueRequest", + {"requestId": request_id}, + ) + else: + await client.send( + "Fetch.failRequest", + { + "requestId": request_id, + "errorReason": "BlockedByClient", + }, + ) + + client.on("Fetch.requestPaused", guard_cdp) + await client.send( + "Fetch.enable", + { + "patterns": [ + {"urlPattern": "*", "requestStage": "Request"}, + ] + }, + ) + # Keep the CDP session alive for the page lifetime. + setattr(page, "_ignition_safe_cdp_session", client) + return page + except Exception: + # Non-Chromium adapters and test doubles use Playwright routing. + pass + async def guard(route, request): target = request.url scheme = urlparse(target).scheme if scheme in ("data", "blob", "about"): - await route.continue_() + await _continue_safely(route) return if await is_url_allowed_async(target): - await route.continue_() + await _continue_safely(route) else: await route.abort("blockedbyclient") - await page.route("**/*", guard) + # Browser-context routing also sees redirected requests created by a page + # route fulfillment. Page-level routing alone can miss that transition. + if context is not None and hasattr(context, "route"): + await context.route("**/*", guard) + else: + await page.route("**/*", guard) return page +async def _continue_safely(route): + """Continue through any other route handlers before reaching the network. + + Playwright's ``continue_`` bypasses older handlers. ``fallback`` preserves + the interception chain and is therefore required when another adapter + fulfills a public response that redirects or embeds a private target. + """ + + fallback = getattr(route, "fallback", None) + if fallback is not None: + await fallback() + else: + await route.continue_() + + def configure_safe_crawler(crawler) -> None: crawler.crawler_strategy.set_hook( "on_page_context_created", diff --git a/apps/crawler/src/crawler_service/models/__pycache__/__init__.cpython-312.pyc b/apps/crawler/src/crawler_service/models/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 84edf05..0000000 Binary files a/apps/crawler/src/crawler_service/models/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/src/crawler_service/models/__pycache__/events.cpython-312.pyc b/apps/crawler/src/crawler_service/models/__pycache__/events.cpython-312.pyc deleted file mode 100644 index d0bfdd0..0000000 Binary files a/apps/crawler/src/crawler_service/models/__pycache__/events.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/tests/__pycache__/__init__.cpython-312.pyc b/apps/crawler/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index eed990f..0000000 Binary files a/apps/crawler/tests/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/apps/crawler/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc b/apps/crawler/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index d5767b4..0000000 Binary files a/apps/crawler/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/apps/crawler/tests/__pycache__/test_outbound_search.cpython-312-pytest-9.0.2.pyc b/apps/crawler/tests/__pycache__/test_outbound_search.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 2b12981..0000000 Binary files a/apps/crawler/tests/__pycache__/test_outbound_search.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/apps/crawler/tests/__pycache__/test_security.cpython-312-pytest-9.0.2.pyc b/apps/crawler/tests/__pycache__/test_security.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index e80f3ea..0000000 Binary files a/apps/crawler/tests/__pycache__/test_security.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/apps/crawler/tests/__pycache__/test_url_safety.cpython-312-pytest-9.0.2.pyc b/apps/crawler/tests/__pycache__/test_url_safety.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 88c8f27..0000000 Binary files a/apps/crawler/tests/__pycache__/test_url_safety.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/apps/crawler/tests/test_api.py b/apps/crawler/tests/test_api.py index ace3d78..581dfe0 100644 --- a/apps/crawler/tests/test_api.py +++ b/apps/crawler/tests/test_api.py @@ -73,3 +73,36 @@ async def fake_execute_crawl(job): # Cancel the job to clean up job_id = data["id"] client.delete(f"/crawl/{job_id}") + + +@pytest.mark.asyncio +async def test_selective_crawl_reuses_idempotency_key(client: TestClient, monkeypatch): + """The Bun orchestrator can safely replay a lost selective crawl request.""" + + async def fake_execute_selective_crawl(job, urls): + await asyncio.sleep(0.05) + + monkeypatch.setattr( + "crawler_service.api.routes.execute_selective_crawl", + fake_execute_selective_crawl, + ) + payload = { + "urls": ["https://example.com/a"], + "includeImages": False, + "idempotencyKey": "run-stage-page-example-a", + } + first = client.post("/crawl/pages", json=payload) + second = client.post("/crawl/pages", json=payload) + different = client.post( + "/crawl/pages", + json={**payload, "idempotencyKey": "run-stage-page-example-b"}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert different.status_code == 200 + assert first.json()["id"] == second.json()["id"] + assert different.json()["id"] != first.json()["id"] + + client.delete(f"/crawl/{first.json()['id']}") + client.delete(f"/crawl/{different.json()['id']}") diff --git a/apps/crawler/tests/test_security.py b/apps/crawler/tests/test_security.py index faf05f7..a508fbf 100644 --- a/apps/crawler/tests/test_security.py +++ b/apps/crawler/tests/test_security.py @@ -18,11 +18,13 @@ import pytest from fastapi.testclient import TestClient +from playwright.async_api import async_playwright from crawler_service.config import settings from crawler_service.core.crawler import CrawlerEngine, SelectiveCrawlerEngine from crawler_service.core.discovery import DiscoveryEngine from crawler_service.core.job_manager import CrawlJob, job_manager +from crawler_service.core.request_safety import install_safe_request_interceptor from crawler_service.main import app PRIVATE_URL = "http://169.254.169.254/latest/meta-data/" @@ -146,6 +148,84 @@ async def test_crawler_engine_keeps_content_after_public_redirect(): assert engine._errors == [] +async def test_real_browser_never_connects_to_private_redirect_or_subresource(): + """Black-box guard: a private target observes zero TCP connections.""" + + connections = 0 + + async def private_target(_reader, writer): + nonlocal connections + connections += 1 + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(private_target, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + private_url = f"http://127.0.0.1:{port}/metadata" + proxy_requests = [] + + async def public_proxy(reader, writer): + request = await reader.readuntil(b"\r\n\r\n") + request_line = request.split(b"\r\n", 1)[0].decode("ascii", "replace") + proxy_requests.append(request_line) + if "/redirect" in request_line: + response = ( + "HTTP/1.1 302 Found\r\n" + f"Location: {private_url}\r\n" + "Content-Length: 0\r\nConnection: close\r\n\r\n" + ).encode() + else: + body = f''.encode() + response = ( + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n\r\n" + ).encode() + body + writer.write(response) + await writer.drain() + writer.close() + await writer.wait_closed() + + proxy = await asyncio.start_server(public_proxy, "127.0.0.1", 0) + proxy_port = proxy.sockets[0].getsockname()[1] + try: + async with async_playwright() as playwright: + browser = await playwright.chromium.launch( + headless=True, + proxy={"server": f"http://127.0.0.1:{proxy_port}"}, + ) + try: + redirect_page = await browser.new_page() + await install_safe_request_interceptor(redirect_page) + try: + await redirect_page.goto( + "http://1.1.1.1/redirect", + wait_until="networkidle", + ) + except Exception: + pass + await asyncio.sleep(0.05) + assert connections == 0 + + subresource_page = await browser.new_page() + await install_safe_request_interceptor(subresource_page) + await subresource_page.goto( + "http://1.1.1.1/page", + wait_until="networkidle", + ) + await asyncio.sleep(0.05) + assert connections == 0 + assert all("127.0.0.1" not in request for request in proxy_requests), proxy_requests + finally: + await browser.close() + finally: + proxy.close() + await proxy.wait_closed() + server.close() + await server.wait_closed() + + # --------------------------------------------------------------------------- # 3. API-key authentication # --------------------------------------------------------------------------- diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx b/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx new file mode 100644 index 0000000..dbe6014 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx @@ -0,0 +1,243 @@ +import { AlertTriangle, ArrowLeft, Building2, Clock3, ExternalLink, Mail, Phone, RefreshCw, Search, Send, UserRound } from "lucide-react"; +import Link from "next/link"; +import { getCampaign } from "@/lib/api"; +import { prospectDetailHref } from "@/lib/prospect-navigation"; +import { CampaignAutoRefresh } from "../campaign-auto-refresh"; + +export const metadata = { title: "Campagne ICP" }; +export const dynamic = "force-dynamic"; + +export default async function CampaignDetailPage({ + params, +}: { + params: Promise<{ workspaceSlug: string; campaignId: string }>; +}) { + const { workspaceSlug, campaignId } = await params; + const campaign = await getCampaign(workspaceSlug, campaignId); + const campaignPath = `/w/${workspaceSlug}/campaigns/${campaignId}`; + const refreshing = ["sourcing", "enriching", "composing"].includes(campaign.automationStage); + + return ( + <> + + + Campagnes + +
+
+
+ + {automationLabel(campaign.automationStage, campaign.discoveryStatus)} + + + {campaign.discoveryStatus === "running" + ? "passage en cours" + : campaign.discoveryStatus === "completed" + ? "passage initial terminé" + : campaign.discoveryStatus === "failed" + ? "sourcing legacy échoué" + : "faisabilité mesurée"} + + {campaign.channel ? {campaign.channel} : null} +
+

{campaign.icpName}

+

+ Score de faisabilité {campaign.assessmentScore ?? 0}/100. {campaign.prospectCount} cibles actuellement affectées. +

+
+
+ + {campaign.automationStage === "attention" || campaign.discoveryStatus === "failed" ? ( +
+ {campaign.automationErrorMessage ?? campaign.discoveryErrorMessage ?? campaign.automationErrorCode ?? campaign.discoveryErrorCode ?? "L’autopilote est suspendu sur une exception fournisseur."} +
+ ) : null} + + {campaign.sourcingPool ? ( + + ) : null} + +
+
+
+

Prospects enrichis

+ {campaign.prospects.length} +
+
+ {campaign.prospects.length === 0 ? ( +

+ {campaign.discoveryStatus === "running" + ? "La recherche est en cours. Cette page se met à jour automatiquement." + : campaign.discoveryRunId + ? "Aucun profil suffisamment fiable n’a été retenu." + : "La campagne a été créée après le test de faisabilité. Le sourcing complet des cibles sera lancé par le pipeline propre à ce canal."} +

+ ) : ( +
    + {campaign.prospects.map((prospect) => ( +
  • +
    + + {prospect.providerData.candidateKind === "company_endpoint" ? : } + +
    + {prospect.contactId ? ( + + {prospect.fullName} + + ) : ( + {prospect.fullName} + )} + {[prospect.headline, prospect.companyName].filter(Boolean).join(" · ") || "Fonction à confirmer"} + {prospect.providerData.candidateKind === "company_endpoint" ? ( + Point de contact entreprise + ) : null} +
    + {prospect.eligible ? `Score ICP ${prospect.score ?? 0}/100` : prospect.state === "excluded" ? "exclu" : "contact sourcé"} +
    +
    + {prospect.linkedinUrl ? : null} + {prospect.channels.email.value ? : null} + {prospect.channels.whatsapp.value ? : null} +
    + {prospect.channels.whatsapp.status === "verified" ? ( +

    + Contact WhatsApp vérifié{reachabilityDate(prospect.providerData) ? ` le ${reachabilityDate(prospect.providerData)}` : ""}{prospect.providerData.reachabilitySource === "cache" ? " · contrôle encore valide" : " · contrôle direct"} +

    + ) : null} + {prospect.icpFit.matches.length ? ( +

    {prospect.icpFit.matches.join(" · ")}

    + ) : null} +
  • + ))} +
+ )} +
+
+ + +
+ + ); +} + +function Channel({ href, icon: Icon, label }: { href: string; icon: typeof Mail; label: string }) { + return {label}; +} + +function channelLabel(kind: string): string { + return ({ manual_task: "Validation manuelle", linkedin_invite: "Invitation LinkedIn", linkedin_message: "Message LinkedIn", email: "Email", whatsapp: "WhatsApp" } as Record)[kind] ?? kind; +} + +function automationLabel(stage: string, discoveryStatus: string | null): string { + if (stage === "sourcing") { + if (discoveryStatus === "running") return "recherche en cours"; + if (discoveryStatus === "completed") return "passage terminé"; + if (discoveryStatus === "failed") return "recherche échouée"; + return "recherche non lancée"; + } + return ({ + enriching: "enrichissement et déduplication", + composing: "personnalisation IA", + scheduled: "envois planifiés", + running: "campagne active", + completed: "campagne terminée", + attention: "exception autopilote", + } as Record)[stage] ?? "autopilote"; +} + +function WhatsappSourcingPoolPanel({ pool }: { pool: NonNullable>["sourcingPool"]> }) { + const actionRequired = pool.actionRequired || pool.status === "action_required"; + const running = pool.status === "running" || pool.status === "scheduled"; + const title = actionRequired + ? "Reconnecter le compte WhatsApp" + : running + ? "Passage du jour en cours" + : pool.status === "not_started" + ? "Premier passage programmé" + : pool.status === "failed" + ? "Nouvelle tentative automatique" + : "Passage du jour terminé"; + const Icon = actionRequired ? AlertTriangle : running ? RefreshCw : Search; + const cause = actionRequired + ? "La vérification des numéros attend une reconnexion du compte sélectionné." + : pool.verificationPending > 0 + ? `${pool.verificationPending} mobile(s) admissible(s) attendent encore leur vérification WhatsApp.` + : pool.admissibleObserved === 0 && pool.status !== "not_started" + ? "Aucun mobile professionnel admissible n’a été observé pendant ce passage." + : `${pool.verifiedObserved} contact(s) WhatsApp vérifié(s) observé(s) dans le pool partagé.`; + return ( +
+
+
+

Pool de sourcing partagé

+

{title}

+
+ {pool.contactsAssignedToday} affecté(s) à cette campagne aujourd’hui +
+
+

{cause}

+
+ + + +
+
+ Dernier passage : {formatPassDate(pool.lastPassAt)} + Prochain passage : {formatPassDate(pool.nextPassAt)} · heure de Paris + Diagnostic : {pool.pageAttempts}/{pool.pageLimit} pages · {pool.verificationAttempts}/{pool.verificationLimit} contrôles +
+

+ Cette étape recherche et importe uniquement. Elle n’envoie aucun message ; les séquences de campagne sont gérées séparément. +

+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return
{value}{label}
; +} + +function formatPassDate(value: string | null): string { + if (!value) return "à venir"; + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: "Europe/Paris", + }).format(new Date(value)); +} + +function reachabilityDate(providerData: Readonly>): string | null { + const value = providerData.reachabilityCheckedAt; + if (typeof value !== "string") return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "Europe/Paris", + }).format(date); +} diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/actions.ts b/apps/web/app/w/[workspaceSlug]/campaigns/actions.ts new file mode 100644 index 0000000..d066a4a --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/campaigns/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { + archiveChannelCampaign, + enableProspectingChannel, + restartCampaignDiscovery, + retryChannelAssessment, +} from "@/lib/api"; + +export async function restartCampaignDiscoveryAction( + workspaceSlug: string, + campaignId: string, +): Promise { + await restartCampaignDiscovery(workspaceSlug, campaignId); + revalidatePath(`/w/${workspaceSlug}/campaigns`); + revalidatePath(`/w/${workspaceSlug}/campaigns/${campaignId}`); +} + +export async function enableProspectingChannelAction( + workspaceSlug: string, + planId: string, + channel: "linkedin" | "email" | "whatsapp", +): Promise { + await enableProspectingChannel(workspaceSlug, planId, channel); + revalidatePath(`/w/${workspaceSlug}/campaigns`); + revalidatePath(`/w/${workspaceSlug}/campaigns/plans/${planId}`); +} + +export async function retryChannelAssessmentAction( + workspaceSlug: string, + planId: string, + assessmentId: string, +): Promise { + await retryChannelAssessment(workspaceSlug, assessmentId); + revalidatePath(`/w/${workspaceSlug}/campaigns`); + revalidatePath(`/w/${workspaceSlug}/campaigns/plans/${planId}`); +} + +export async function archiveChannelCampaignAction( + workspaceSlug: string, + planId: string, + campaignId: string, +): Promise { + await archiveChannelCampaign(workspaceSlug, campaignId); + revalidatePath(`/w/${workspaceSlug}/campaigns`); + revalidatePath(`/w/${workspaceSlug}/campaigns/plans/${planId}`); +} diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/campaign-auto-refresh.tsx b/apps/web/app/w/[workspaceSlug]/campaigns/campaign-auto-refresh.tsx new file mode 100644 index 0000000..b47cbd4 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/campaigns/campaign-auto-refresh.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export function CampaignAutoRefresh({ enabled }: { enabled: boolean }) { + const router = useRouter(); + useEffect(() => { + if (!enabled) return; + const timer = window.setInterval(() => router.refresh(), 4_000); + return () => window.clearInterval(timer); + }, [enabled, router]); + return null; +} diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/page.tsx b/apps/web/app/w/[workspaceSlug]/campaigns/page.tsx new file mode 100644 index 0000000..ece3002 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/campaigns/page.tsx @@ -0,0 +1,185 @@ +import { + AtSign, + ArrowRight, + Archive, + CheckCircle2, + LoaderCircle, + Mail, + MessageCircle, + Target, + Users, +} from "lucide-react"; +import Link from "next/link"; +import { + getProspectingPlan, + listCampaigns, + listProspectingPlans, + type CampaignSummary, + type ProspectingPlanDetail, +} from "@/lib/api"; +import { CampaignAutoRefresh } from "./campaign-auto-refresh"; + +export const metadata = { title: "Campagnes" }; +export const dynamic = "force-dynamic"; + +export default async function CampaignsPage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string }>; + searchParams: Promise<{ runId?: string }>; +}) { + const { workspaceSlug } = await params; + const { runId } = await searchParams; + const [planList, campaignList] = await Promise.all([ + listProspectingPlans(workspaceSlug), + listCampaigns(workspaceSlug), + ]); + const planSummaries = planList.data.filter((plan) => !runId || plan.icpRunId === runId); + const plans = await Promise.all( + planSummaries.map((plan) => getProspectingPlan(workspaceSlug, plan.id)), + ); + const campaigns = campaignList.data.filter((campaign) => campaign.status !== "archived"); + const assessingCount = plans.filter((plan) => plan.status === "assessing").length; + const legacyCount = campaignList.data.filter( + (campaign) => campaign.status === "archived" && campaign.planId === null, + ).length; + + return ( + <> + 0 + || campaigns.some((campaign) => ["sourcing", "enriching", "composing"].includes(campaign.automationStage)) + } /> +
+
+

Campagnes

+

+ Chaque ICP devient une campagne autonome. Ouvrez-la pour suivre ses prospects et ses messages. +

+
+ {plans.length} campagnes +
+ + {assessingCount > 0 ? ( +
+ + L’autopilote évalue encore les canaux de {assessingCount} ICP. Les campagnes retenues apparaîtront ici automatiquement. +
+ ) : null} + + {plans.length === 0 ? ( +
+
+ +

Aucune campagne prête

+

+ Lancez une étude ICP. L’autopilote choisira les canaux utiles et créera les campagnes sans configuration manuelle. +

+ + Trouver mon ICP + +
+
+ ) : ( +
+
+
+

Prospection en pilote automatique

+

Recherche, enrichissement, personnalisation et relances sont suivis campagne par campagne.

+
+ Autopilote actif +
+
+ Campagne + État + Canal + Prospects · score + +
+
+ {plans.map((plan) => { + const activeCampaigns = campaigns.filter((campaign) => campaign.planId === plan.id); + const channels = activeCampaigns + .map((campaign) => campaign.channel) + .filter((channel): channel is NonNullable => channel !== null); + const prospectCount = plan.campaigns + .filter((campaign) => campaign.status !== "archived") + .reduce((total, campaign) => total + campaign.prospectCount, 0); + const score = bestScore(activeCampaigns); + return ( + +
+ + + +
+

{plan.icpName}

+

{plan.name}

+
+
+
+ État + {planStatus(plan, activeCampaigns)} +
+
+ Canal + + {channels.length ? channels.map((channel) => { + const ChannelIcon = channelIcon(channel); + return {channel}; + }) : en préparation} + +
+
+ Prospects + {prospectCount} + · {score}/100 +
+ + + ); + })} +
+
+ )} + + {legacyCount ? ( +
{legacyCount} campagnes historiques archivées et conservées pour audit.
+ ) : null} + + ); +} + +function channelIcon(channel: CampaignSummary["channel"]) { + if (channel === "linkedin") return AtSign; + if (channel === "email") return Mail; + if (channel === "whatsapp") return MessageCircle; + return Target; +} + +function planBadge(campaigns: readonly CampaignSummary[]): string { + if (campaigns.some((campaign) => campaign.automationStage === "attention")) return "badge badge-danger"; + if (campaigns.some((campaign) => ["running", "completed"].includes(campaign.automationStage))) return "badge badge-success"; + return "badge badge-warning"; +} + +function planStatus(plan: ProspectingPlanDetail, campaigns: readonly CampaignSummary[]): string { + if (campaigns.some((campaign) => campaign.automationStage === "attention")) return "À surveiller"; + if (campaigns.some((campaign) => campaign.automationStage === "running")) return "Active"; + if (campaigns.length && campaigns.every((campaign) => campaign.automationStage === "completed")) return "Terminée"; + if (campaigns.some((campaign) => campaign.automationStage === "scheduled")) return "Planifiée"; + if (campaigns.some((campaign) => campaign.automationStage === "composing")) return "Personnalisation"; + if (campaigns.some((campaign) => campaign.automationStage === "enriching")) return "Enrichissement"; + if (campaigns.some((campaign) => campaign.discoveryStatus === "running")) return "Recherche en cours"; + if (campaigns.some((campaign) => campaign.automationStage === "sourcing")) return "Recherche non lancée"; + return plan.status === "assessing" ? "Préparation" : "Prête"; +} + +function bestScore(campaigns: readonly CampaignSummary[]): number { + return campaigns.reduce((best, campaign) => Math.max(best, campaign.assessmentScore ?? 0), 0); +} diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/plans/[planId]/page.tsx b/apps/web/app/w/[workspaceSlug]/campaigns/plans/[planId]/page.tsx new file mode 100644 index 0000000..2ce74a9 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/campaigns/plans/[planId]/page.tsx @@ -0,0 +1,647 @@ +import { + ArrowLeft, + AtSign, + Bot, + Calendar, + CheckCircle2, + Clock, + ExternalLink, + Flame, + Mail, + MessageCircle, + RefreshCw, + Send, + Target, + UserRound, + Users, + X, +} from "lucide-react"; +import Link from "next/link"; +import { isActionableCampaignException } from "@outbound/application/campaigns/campaign-engagement"; +import { resolveCampaignAutopilotPolicy } from "@outbound/domain/campaigns/campaign-autopilot-policy"; +import { + getCampaign, + getCampaignAutopilotDashboard, + getCampaignConversation, + getCampaignEngagement, + getProspectingPlan, + type CampaignConversationDetail, + type CampaignAutopilotDashboard, + type CampaignDetail, + type CampaignEngagementOverview, + type CampaignProspect, + type CampaignProspectEngagement, + type CampaignSummary, + type ProspectEngagementState, +} from "@/lib/api"; +import { aggregateCampaignEngagement } from "@/lib/campaign-engagement"; +import { prospectDetailHref } from "@/lib/prospect-navigation"; +import { CampaignAutoRefresh } from "../../campaign-auto-refresh"; + +export const metadata = { title: "Campagne" }; +export const dynamic = "force-dynamic"; + +const CHANNELS = ["linkedin", "email", "whatsapp"] as const; +type Channel = typeof CHANNELS[number]; +type AggregatedProspect = CampaignProspect & { readonly campaignChannels: readonly Channel[] }; + +export default async function CampaignPage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string; planId: string }>; + searchParams: Promise<{ prospect?: string }>; +}) { + const { workspaceSlug, planId } = await params; + const { prospect: selectedProspectKey } = await searchParams; + const plan = await getProspectingPlan(workspaceSlug, planId); + const activeCampaigns = plan.campaigns.filter((campaign) => campaign.status !== "archived"); + const [campaignDetails, engagementViews, dashboards] = await Promise.all([ + Promise.all(activeCampaigns.map((campaign) => getCampaign(workspaceSlug, campaign.id))), + Promise.all(activeCampaigns.map((campaign) => getCampaignEngagement(workspaceSlug, campaign.id))), + Promise.all(activeCampaigns.map((campaign) => getCampaignAutopilotDashboard(workspaceSlug, campaign.id))), + ]); + const engagement = aggregateCampaignEngagement(engagementViews); + const prospects = aggregateProspects(campaignDetails); + const engagementByProspect = new Map( + engagement.prospects.map((item) => [item.contactId ?? item.candidateId, item]), + ); + const campaignPath = `/w/${workspaceSlug}/campaigns/plans/${planId}`; + const selectedProspect = selectedProspectKey + ? prospects.find((item) => prospectKey(item) === selectedProspectKey) ?? null + : null; + const selectedEngagement = selectedProspectKey + ? engagementByProspect.get(selectedProspectKey) ?? null + : null; + const selectedConversation = selectedEngagement?.conversationId + ? await getCampaignConversation( + workspaceSlug, + selectedEngagement.campaignId, + selectedEngagement.conversationId, + ) + : null; + const refreshing = plan.status === "assessing" + || campaignDetails.some((campaign) => ["sourcing", "enriching", "composing", "scheduled", "running"].includes(campaign.automationStage)); + const exceptions = campaignDetails.filter(isActionableCampaignException); + + return ( + <> + + + Campagnes + + +
+
+
+ {campaignStatus(plan.status, campaignDetails)} + {activeCampaigns.map((campaign) => ( + campaign.channel ? {campaign.channel} : null + ))} +
+

{plan.icpName}

+

+ L’activité commerciale, les réponses et les décisions IA restent regroupées dans cette campagne. +

+
+
+ + {exceptions.length ? ( +
+ {exceptions.length} exception{exceptions.length > 1 ? "s" : ""} technique{exceptions.length > 1 ? "s" : ""}.{" "} + {exceptions.map((campaign) => campaign.automationErrorMessage ?? campaign.automationErrorCode).filter(Boolean).join(" · ")} +
+ ) : null} + +
+ + + + + +
+ + + +
+
+
+
+

Prospects

+

Cliquez sur un prospect pour ouvrir son activité sans quitter la campagne.

+
+ {prospects.length} +
+
+ {prospects.length === 0 ? ( +

+ {campaignDetails.some((campaign) => campaign.discoveryStatus === "running") + ? "La recherche est en cours. Cette page se met à jour automatiquement." + : "La recherche est terminée sans cible suffisamment fiable."} +

+ ) : ( +
    + {prospects.map((prospect) => { + const key = prospectKey(prospect); + const activity = engagementByProspect.get(key) ?? null; + const selected = selectedProspectKey === key; + return ( +
  • +
    + +
    + + {prospect.fullName} + + + {[prospect.headline, prospect.companyName].filter(Boolean).join(" · ") || "Fonction à confirmer"} + +
    + +
    + + + {activity?.lastMessage ? ( + <> + + + {activity.lastMessage.direction === "inbound" ? "Dernière réponse" : "Dernier message"} + {formatDate(activity.lastMessage.occurredAt)} + + {activity.lastMessage.body} + + ) : ( + Aucun message envoyé + )} + + +
    + {prospect.linkedinUrl ? : null} + {prospect.channels.email.value ? : null} + {prospect.channels.whatsapp.value ? : null} + {prospect.contactId ? ( + + Fiche CRM + + ) : null} +
    + {activity?.relaunchesCancelled ? ( +

    Relances annulées après la réponse

    + ) : null} +
  • + ); + })} +
+ )} +
+
+ + +
+ + ); +} + +function CampaignAutomationJourney({ + campaigns, + dashboards, + engagements, +}: { + campaigns: readonly CampaignDetail[]; + dashboards: readonly CampaignAutopilotDashboard[]; + engagements: readonly CampaignEngagementOverview[]; +}) { + if (!campaigns.length) return null; + return ( +
+
+
+

Déroulé automatique

+

Chaque canal avance seul. Une réponse arrête immédiatement les relances et passe la main au Setter IA.

+
+ sans validation manuelle +
+
+ {campaigns.map((campaign) => { + if (!campaign.channel) return null; + const channel = campaign.channel; + const Icon = channelIcon(channel); + const engagement = engagements.find((item) => item.campaignId === campaign.id); + const dashboard = dashboards.find((item) => item.campaignId === campaign.id); + const pendingFollowUps = engagement?.prospects.reduce((total, item) => total + item.pendingFollowUps, 0) ?? 0; + const policy = resolveCampaignAutopilotPolicy(campaign.autopilotPolicy, channel); + return ( +
+
+
+

{channelLabel(channel)}

+

{policy.schedule.windowStart}–{policy.schedule.windowEnd} · heure du destinataire

+
+ + {dashboard?.health === "attention" ? "à surveiller" : policy.enabled ? "actif" : "pause"} + +
+ + {dashboard ? ( +
+ Étape actuelle + {autopilotStepLabel(dashboard.currentStep)} +
+ ) : null} + +
    +
  1. + + RechercheTous les jours à 06:00 + {dashboard?.counts.discovered ?? campaign.prospectCount} +
  2. + {campaign.steps.map((step) => ( +
  3. + {step.position} + {sequenceStepLabel(step.kind, step.position)}Personnalisation IA · J+{step.delayDays} +
  4. + ))} +
  5. + + Réponse & qualificationK3 répond, qualifie et propose le rendez-vous +
  6. +
+ +
+ + + +
+ {pendingFollowUps > 0 ?

{pendingFollowUps} relance{pendingFollowUps > 1 ? "s" : ""} planifiée{pendingFollowUps > 1 ? "s" : ""}

: null} + {dashboard?.exceptions.length ? ( +

+ {dashboard.exceptions.reduce((total, item) => total + item.count, 0)} exception{dashboard.exceptions.length > 1 ? "s" : ""} technique{dashboard.exceptions.length > 1 ? "s" : ""} détectée{dashboard.exceptions.length > 1 ? "s" : ""}. +

+ ) : null} +
+ ); + })} +
+
+ ); +} + +function autopilotStepLabel(step: CampaignAutopilotDashboard["currentStep"]): string { + return ({ + research: "Recherche de prospects", + enrichment: "Enrichissement et déduplication", + composition: "Personnalisation par K3", + outreach: "Envois et relances", + setter: "Qualification des réponses", + meeting: "Prise de rendez-vous", + completed: "Campagne terminée", + attention: "Correction automatique en cours", + } as const)[step]; +} + +function JourneyMetric({ label, value }: { label: string; value: number }) { + return
{value}{label}
; +} + +function sequenceStepLabel(kind: string, position: number): string { + if (kind === "linkedin_invite") return "Invitation LinkedIn"; + if (kind === "linkedin_message") return position === 1 ? "Premier message" : "Relance LinkedIn"; + if (kind === "email") return position === 1 ? "Premier email" : "Relance email"; + if (kind === "whatsapp") return position === 1 ? "Premier WhatsApp" : "Relance WhatsApp"; + return "Action automatique"; +} + +function EmailAutopilotSummary({ campaign }: { campaign: CampaignDetail }) { + const policy = resolveCampaignAutopilotPolicy(campaign.autopilotPolicy, "email"); + const activeDays = policy.schedule.activeDays.map((day) => ({ + 1: "lun", + 2: "mar", + 3: "mer", + 4: "jeu", + 5: "ven", + 6: "sam", + 7: "dim", + })[day] ?? String(day)).join(", "); + return ( +
+
+ Autopilote email + {policy.enabled ? "automatique" : "en pause"} +
+
+ + + + +
+
+ ); +} + +function AutopilotSetting({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function ProspectConversationPanel({ + campaignPath, + conversation, + engagement, + prospect, + workspaceSlug, +}: { + campaignPath: string; + conversation: CampaignConversationDetail | null; + engagement: CampaignProspectEngagement | null; + prospect: AggregatedProspect | null; + workspaceSlug: string; +}) { + const fullName = prospect?.fullName ?? engagement?.fullName ?? "Prospect"; + return ( +
+
+
+

{fullName}

+

{prospect?.companyName ?? engagement?.companyName ?? "Entreprise à confirmer"}

+
+ +
+ + {conversation ? ( + <> +
+ {conversation.messages.map((message) => ( +
+
+

{message.body}

+

+ {message.direction === "outbound" ? message.senderType === "ai" ? "Réponse IA" : "Outbound" : "Prospect"} · {formatDate(message.occurredAt)} +

+
+
+ ))} +
+ +
+ {conversation.decision ? ( +
+
+ Décision K3 + {Math.round(conversation.decision.confidence * 100)}% +
+

{intentLabel(conversation.decision.intent)} · {actionLabel(conversation.decision.action)}

+

{conversation.decision.rationale}

+

{conversation.decision.provider ?? "IA"} · {conversation.decision.model ?? "modèle configuré"}

+
+ ) : null} + + {conversation.automatedReply ? ( +
+
+ Réponse automatique + + {replyStatusLabel(conversation.automatedReply.status)} + +
+

{conversation.automatedReply.body}

+
+ ) : null} + + {conversation.relaunchesCancelled ? ( +

{conversation.cancelledFollowUps} relance{conversation.cancelledFollowUps > 1 ? "s" : ""} annulée{conversation.cancelledFollowUps > 1 ? "s" : ""}

+ ) : conversation.pendingFollowUps > 0 ? ( +

{conversation.pendingFollowUps} relance{conversation.pendingFollowUps > 1 ? "s" : ""} planifiée{conversation.pendingFollowUps > 1 ? "s" : ""}

+ ) : null} + + {conversation.opportunity ? ( +

{opportunityLabel(conversation.opportunity.stage)}

+ ) : null} + + {conversation.meeting ? ( +
+
+ Rendez-vous + + {meetingStatusLabel(conversation.meeting.status)} + +
+ {conversation.meeting.bookedStartAt ? ( +

{formatDate(conversation.meeting.bookedStartAt)}

+ ) : null} + {conversation.meeting.status === "offered" && conversation.meeting.proposedSlots.length ? ( +
    + {conversation.meeting.proposedSlots.map((slot) =>
  1. {slot.position}. {slot.label}
  2. )} +
+ ) : null} + {conversation.meeting.meetingUrl ? Ouvrir le rendez-vous : null} +
+ ) : null} +
+ + ) : ( +
+ +

Aucune réponse reçue

+

+ {engagement?.sentCount + ? `${engagement.sentCount} message${engagement.sentCount > 1 ? "s" : ""} envoyé${engagement.sentCount > 1 ? "s" : ""}. ${engagement.pendingFollowUps} relance${engagement.pendingFollowUps > 1 ? "s" : ""} encore planifiée${engagement.pendingFollowUps > 1 ? "s" : ""}.` + : "Ce prospect n’a pas encore été contacté."} +

+ {prospect?.contactId ? Ouvrir la fiche CRM : null} +
+ )} +
+ ); +} + +function Metric({ icon: Icon, label, value, tone }: { icon: typeof Target; label: string; value: number; tone?: "success" | "signal" }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function aggregateProspects(campaigns: readonly CampaignDetail[]): AggregatedProspect[] { + const prospects = new Map(); + for (const campaign of campaigns) { + if (!campaign.channel) continue; + for (const prospect of campaign.prospects) { + const key = prospectKey(prospect); + const current = prospects.get(key); + const campaignChannels = Array.from(new Set([...(current?.campaignChannels ?? []), campaign.channel])); + if (!current || (prospect.score ?? 0) > (current.score ?? 0)) prospects.set(key, { ...prospect, campaignChannels }); + else prospects.set(key, { ...current, campaignChannels }); + } + } + return Array.from(prospects.values()).sort((left, right) => (right.score ?? 0) - (left.score ?? 0)); +} + +function prospectKey(prospect: CampaignProspect): string { + return prospect.contactId + ?? prospect.linkedinUrl + ?? prospect.channels.email.normalizedValue + ?? prospect.channels.whatsapp.normalizedValue + ?? prospect.candidateId; +} + +function ContactLink({ href, icon: Icon, label }: { href: string; icon: typeof Mail; label: string }) { + return {label}; +} + +function ProspectStateBadge({ eligible, state }: { eligible: boolean; state: ProspectEngagementState }) { + if (!eligible) return exclu; + const className = state === "meeting" || state === "qualified" + ? "badge badge-success" + : state === "refused" + ? "badge badge-danger" + : state === "replied" + ? "badge badge-signal" + : state === "sent" + ? "badge badge-warning" + : "badge"; + return {stateLabel(state)}; +} + +function stateLabel(state: ProspectEngagementState): string { + return ({ not_contacted: "non contacté", sent: "envoyé", replied: "répondu", qualified: "qualifié", refused: "refus", meeting: "rendez-vous" })[state]; +} + +function meetingStatusLabel(status: string): string { + return ({ + offered: "créneaux proposés", + booked: "réservé", + rescheduled: "déplacé", + cancelled: "annulé", + expired: "expiré", + superseded: "remplacé", + } as Record)[status] ?? status; +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat("fr-FR", { + dateStyle: "short", + timeStyle: "short", + timeZone: "Europe/Paris", + }).format(new Date(value)); +} + +function channelIcon(channel: Channel) { + return channel === "linkedin" ? AtSign : channel === "email" ? Mail : MessageCircle; +} + +function channelLabel(channel: Channel): string { + return channel === "linkedin" ? "LinkedIn" : channel === "email" ? "Email" : "WhatsApp"; +} + +function campaignBadge(campaigns: readonly CampaignDetail[]): string { + if (campaigns.some(isActionableCampaignException)) return "badge badge-danger"; + if (campaigns.some((campaign) => ["running", "completed"].includes(campaign.automationStage))) return "badge badge-success"; + return "badge badge-warning"; +} + +function campaignStatus(planStatus: string, campaigns: readonly CampaignDetail[]): string { + if (campaigns.some(isActionableCampaignException)) return "Exception technique"; + if (campaigns.some((campaign) => campaign.automationStage === "running")) return "Prospection active"; + if (campaigns.some((campaign) => campaign.automationStage === "scheduled")) return "Envois planifiés"; + if (campaigns.some((campaign) => campaign.automationStage === "composing")) return "Personnalisation IA"; + if (campaigns.some((campaign) => campaign.automationStage === "enriching")) return "Enrichissement"; + if (campaigns.some((campaign) => campaign.discoveryStatus === "running")) return "Recherche en cours"; + if (campaigns.length && campaigns.every((campaign) => campaign.automationErrorCode === "NO_PROSPECTS_FOUND")) return "Aucune cible trouvée"; + if (campaigns.length && campaigns.every((campaign) => ["completed", "attention"].includes(campaign.automationStage))) return "Prospection terminée"; + if (campaigns.some((campaign) => campaign.automationStage === "sourcing")) return "Recherche non lancée"; + return planStatus === "assessing" ? "Préparation" : "Prête"; +} + +function channelBadge(recommendation: string | null | undefined, campaign: CampaignSummary | undefined): string { + if (campaign && isActionableCampaignException(campaign)) return "badge badge-danger"; + if (campaign?.automationErrorCode === "NO_PROSPECTS_FOUND") return "badge"; + if (campaign) return "badge badge-success"; + return recommendation === "optional" ? "badge" : "badge"; +} + +function channelStatus(status: string | undefined, recommendation: string | null | undefined, campaign: CampaignSummary | undefined): string { + if (campaign && isActionableCampaignException(campaign)) return "incident"; + if (campaign?.automationErrorCode === "NO_PROSPECTS_FOUND") return "aucune cible"; + if (campaign?.discoveryStatus === "running") return "recherche"; + if (campaign?.automationStage === "sourcing" && !campaign.discoveryRunId) return "non lancée"; + if (campaign) return "actif"; + if (status === "running" || status === "pending") return "évaluation"; + if (status === "failed") return "indisponible"; + return recommendation === "optional" ? "optionnel" : "non retenu"; +} + +function channelDescription(status: string | undefined, campaign: CampaignDetail | undefined): string { + if (!campaign) return status === "running" ? "Faisabilité en cours de mesure." : "Canal non retenu par l’autopilote."; + if (campaign.discoveryStatus === "running") return "La recherche de prospects est réellement en cours."; + if (campaign.automationStage === "sourcing" && !campaign.discoveryRunId) return "La recherche n’a pas encore été lancée."; + if (campaign.automationErrorCode === "NO_PROSPECTS_FOUND") return "Recherche terminée sans prospect suffisamment fiable."; + return `${campaign.prospects.length} cibles · ${campaign.steps.length} étapes · score ${campaign.assessmentScore ?? 0}/100`; +} + +function intentLabel(intent: string): string { + return ({ positive: "Intérêt positif", question: "Question", objection: "Objection", not_interested: "Pas intéressé", unsubscribe: "Désinscription", meeting_request: "Demande de rendez-vous", other: "Autre" } as Record)[intent] ?? intent; +} + +function actionLabel(action: string): string { + return ({ reply: "réponse", stop: "arrêt", booking: "réservation" } as Record)[action] ?? action; +} + +function replyStatusLabel(status: string): string { + return ({ scheduled: "planifiée", sending: "envoi", sent: "envoyée", failed: "échec", cancelled: "annulée" } as Record)[status] ?? status; +} + +function opportunityLabel(stage: string): string { + return stage === "meeting_requested" ? "Rendez-vous demandé" : stage === "meeting_booked" ? "Rendez-vous réservé" : "Prospect qualifié"; +} diff --git a/apps/web/app/w/[workspaceSlug]/inbox/page.tsx b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx new file mode 100644 index 0000000..d467158 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx @@ -0,0 +1,274 @@ +import { AlertTriangle, AtSign, Bot, CalendarDays, Clock, Flame, Inbox, Mail, MessageCircle, Search, UserRound } from "lucide-react"; +import Link from "next/link"; +import { ProspectActivityDrawer } from "@/components/prospect-activity-drawer"; +import { getProspectView, listProspectViews, type ProspectViewSummary } from "@/lib/api"; +import { + buildInboxChannelHref, + buildInboxHref, + inboxPeriod, + inboxReadState, + inboxScope, + matchesInboxPeriod, + matchesInboxReadState, + matchesInboxScope, +} from "@/lib/inbox-filters"; + +export const metadata = { title: "Messagerie" }; +export const dynamic = "force-dynamic"; + +type InboxView = "all" | "replies" | "hot" | "waiting" | "errors"; + +export default async function InboxPage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string }>; + searchParams: Promise<{ + search?: string; + channel?: string; + view?: string; + scope?: string; + period?: string; + read?: string; + prospect?: string; + }>; +}) { + const { workspaceSlug } = await params; + const query = await searchParams; + const view = inboxView(query.view); + const scope = inboxScope(query.scope); + const period = inboxPeriod(query.period); + const readState = inboxReadState(query.read); + const result = await listProspectViews(workspaceSlug, { + ...(query.search ? { search: query.search } : {}), + }); + const contacted = result.data.filter((prospect) => prospect.latestActivity !== null); + const threads = contacted + .filter((prospect) => !query.channel || prospect.latestActivity?.channel === query.channel) + .filter((prospect) => matchesView(prospect, view)) + .filter((prospect) => matchesInboxScope(prospect, scope)) + .filter((prospect) => matchesInboxPeriod(prospect, period)) + .filter((prospect) => matchesInboxReadState(prospect, readState)) + .sort((left, right) => priority(right) - priority(left) || activityTime(right) - activityTime(left)); + const selected = query.prospect ? await getProspectView(workspaceSlug, query.prospect) : null; + const listHref = buildInboxHref(workspaceSlug, query); + const replyCount = contacted.filter((prospect) => prospect.latestActivity?.direction === "inbound").length; + const hotCount = contacted.filter(isHot).length; + const waitingCount = contacted.filter((prospect) => !prospect.conversation && prospect.latestActivity?.direction === "outbound").length; + + return ( + <> +
+
+

Messagerie

+

Messages envoyés, réponses prioritaires et décisions K3 dans une seule file.

+
+
+ Agenda + Setter IA actif +
+
+ +
+ + + + +
+ + + +
+
+ + + + + + +
+ + Effacer +
+
+
+ +
+
+

Activité commerciale

Les réponses et rendez-vous remontent automatiquement en tête.

+ {threads.length} +
+ {threads.length ? ( +
+ {threads.map((prospect) => { + const activity = prospect.latestActivity!; + const ChannelIcon = channelIcon(activity.channel); + return ( + +
+ +
+
+ {prospect.firstName} {prospect.lastName} + {isHot(prospect) ? chaud : null} + {prospect.conversation?.channel === "linkedin" && !prospect.conversation.campaignId ? hors campagne : null} + {(prospect.conversation?.unreadCount ?? 0) > 0 ? {prospect.conversation!.unreadCount} non lu{prospect.conversation!.unreadCount > 1 ? "s" : ""} : null} +
+

{prospect.currentEmployment ? `${prospect.currentEmployment.title} · ${prospect.currentEmployment.companyName}` : prospect.icpMatches[0]?.companyName ?? "Entreprise à confirmer"}

+

{activity.body ?? activityLabel(activity.status)}

+
+
+ {channelLabel(activity.channel)} + {activityLabel(activity.status)} + + ); + })} +
+ ) : ( +

Aucune activité avec ces filtres

Les prochains envois et réponses apparaîtront automatiquement ici.

+ )} +
+ + {selected ? : null} + + ); +} + +function InboxMetric({ icon: Icon, label, value, tone }: { icon: typeof Inbox; label: string; value: number; tone?: "success" | "signal" }) { + return
{label}
{value}
; +} + +function InboxChannelTab({ + active, + count, + href, + icon: Icon, + label, +}: { + active: boolean; + count: number; + href: string; + icon: typeof Inbox; + label: string; +}) { + return ( + + {label}{count} + + ); +} + +function matchesView(prospect: ProspectViewSummary, view: InboxView): boolean { + if (view === "all") return true; + if (view === "replies") return prospect.latestActivity?.direction === "inbound"; + if (view === "hot") return isHot(prospect); + if (view === "waiting") return !prospect.conversation && prospect.latestActivity?.direction === "outbound"; + return prospect.latestActivity?.status === "failed"; +} + +function isHot(prospect: ProspectViewSummary): boolean { + return Boolean(prospect.meeting) + || ["positive", "meeting_request"].includes(prospect.conversation?.decision?.intent ?? ""); +} + +function priority(prospect: ProspectViewSummary): number { + if (prospect.meeting?.status === "booked") return 60; + if (prospect.conversation?.decision?.intent === "meeting_request") return 50; + if (prospect.conversation?.decision?.intent === "positive") return 45; + if (prospect.latestActivity?.direction === "inbound") return 40; + if (prospect.latestActivity?.status === "failed") return 30; + if (prospect.latestActivity?.status === "executing") return 20; + return 10; +} + +function activityTime(prospect: ProspectViewSummary): number { + return new Date(prospect.latestActivity?.occurredAt ?? 0).getTime(); +} + +function activityBadge(activity: NonNullable): string { + if (activity.direction === "inbound") return "badge badge-signal"; + if (activity.status === "sent") return "badge badge-success"; + if (activity.status === "failed") return "badge badge-danger"; + return "badge badge-warning"; +} + +function activityLabel(status: string): string { + return ({ received: "Réponse reçue", sent: "Envoyé", scheduled: "Relance planifiée", executing: "Envoi en cours", failed: "Échec", cancelled: "Annulé", skipped: "Ignoré" } as Record)[status] ?? status; +} + +function channelIcon(channel: "linkedin" | "email" | "whatsapp") { + return channel === "linkedin" ? AtSign : channel === "email" ? Mail : MessageCircle; +} + +function channelLabel(channel: "linkedin" | "email" | "whatsapp") { + return channel === "linkedin" ? "LinkedIn" : channel === "email" ? "Email" : "WhatsApp"; +} + +function inboxView(value: string | undefined): InboxView { + return value === "replies" || value === "hot" || value === "waiting" || value === "errors" ? value : "all"; +} diff --git a/apps/web/app/w/[workspaceSlug]/pipeline/page.tsx b/apps/web/app/w/[workspaceSlug]/pipeline/page.tsx new file mode 100644 index 0000000..965d8f8 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/pipeline/page.tsx @@ -0,0 +1,128 @@ +import { CalendarCheck, CheckCircle2, CircleDollarSign, Flame, Kanban, RotateCcw, UserRound } from "lucide-react"; +import Link from "next/link"; +import { ProspectActivityDrawer } from "@/components/prospect-activity-drawer"; +import { + getPipeline, + getProspectView, + type PipelineOpportunity, +} from "@/lib/api"; + +export const metadata = { title: "Pipeline" }; +export const dynamic = "force-dynamic"; + +const columns = [ + { id: "qualified", title: "Qualifiés", description: "Intérêt confirmé", icon: Flame }, + { id: "meeting", title: "Rendez-vous", description: "Demandés ou réservés", icon: CalendarCheck }, + { id: "follow_up", title: "À suivre", description: "Après rendez-vous ou no-show", icon: RotateCcw }, + { id: "closed", title: "Clôturés", description: "Gagnés ou perdus", icon: CheckCircle2 }, +] as const; + +export default async function PipelinePage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string }>; + searchParams: Promise<{ prospect?: string }>; +}) { + const { workspaceSlug } = await params; + const query = await searchParams; + const pipeline = await getPipeline(workspaceSlug); + const selected = query.prospect ? await getProspectView(workspaceSlug, query.prospect) : null; + const listHref = `/w/${workspaceSlug}/pipeline`; + + return ( + <> +
+
+
Revenu
+

Pipeline commercial

+

Les décisions K3 et les rendez-vous déplacent automatiquement les opportunités.

+
+ Autopilote actif +
+ +
+ + + + + +
+ + {pipeline.data.length ? ( +
+ {columns.map((column) => { + const items = pipeline.data.filter((item) => item.column === column.id); + const Icon = column.icon; + return ( +
+
+
+

{column.title}

+

{column.description}

+
+ {items.length} +
+
+ {items.map((opportunity) => ( + + ))} + {!items.length ?
Aucune opportunité
: null} +
+
+ ); + })} +
+ ) : ( +
+ +

Le pipeline se remplira automatiquement

+

Une réponse positive, une demande de rendez-vous ou une réservation créera la première opportunité.

+ Voir la Messagerie +
+ )} + + {selected ? : null} + + ); +} + +function OpportunityCard({ opportunity, workspaceSlug }: { opportunity: PipelineOpportunity; workspaceSlug: string }) { + return ( + +
+ +
+ {opportunity.firstName} {opportunity.lastName} +

{opportunity.jobTitle ?? "Fonction à confirmer"}{opportunity.companyName ? ` · ${opportunity.companyName}` : ""}

+
+
+
+ {stageLabel(opportunity.stage)} + {opportunity.icpName ? {opportunity.icpName} : null} +
+ {opportunity.meeting ?

{formatDate(opportunity.meeting.startAt)}

: null} + {opportunity.nextAction ?

{opportunity.nextAction}

: null} +

{opportunity.history.length} transition{opportunity.history.length > 1 ? "s" : ""} auditée{opportunity.history.length > 1 ? "s" : ""}

+ + ); +} + +function Metric({ label, value, tone }: { label: string; value: number; tone?: "signal" | "success" }) { + return

{label}

{value}
; +} + +function stageLabel(stage: string): string { + return ({ qualified: "Qualifié", meeting_requested: "RDV demandé", meeting_booked: "RDV réservé", meeting_no_show: "À replanifier", meeting_completed: "RDV terminé", won: "Gagné", lost: "Perdu" } as Record)[stage] ?? stage; +} + +function stageBadge(stage: string): string { + if (stage === "won" || stage === "meeting_booked") return "badge badge-success"; + if (stage === "lost" || stage === "meeting_no_show") return "badge badge-warning"; + if (stage === "meeting_requested") return "badge badge-signal"; + return "badge"; +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Paris" }).format(new Date(value)); +} diff --git a/apps/web/app/w/[workspaceSlug]/prospects/[contactId]/page.tsx b/apps/web/app/w/[workspaceSlug]/prospects/[contactId]/page.tsx index cac200b..9450f78 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/[contactId]/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/prospects/[contactId]/page.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, Ban, Briefcase, Mail, Phone, Plus, TriangleAlert, UserRound import Link from "next/link"; import { notFound } from "next/navigation"; import { getContact, listCompanies, OutboundApiError } from "@/lib/api"; +import { resolveProspectReturn } from "@/lib/prospect-navigation"; import { addEmploymentAction, addIdentityAction, suppressContactAction } from "../actions"; export const metadata = { title: "Prospect" }; @@ -22,10 +23,14 @@ const VERIFICATION_BADGE: Record = export default async function ContactDetailPage({ params, + searchParams, }: { params: Promise<{ workspaceSlug: string; contactId: string }>; + searchParams: Promise<{ returnTo?: string }>; }) { const { workspaceSlug, contactId } = await params; + const { returnTo } = await searchParams; + const returnLink = resolveProspectReturn(workspaceSlug, returnTo); let contact; try { [contact] = await Promise.all([getContact(workspaceSlug, contactId)]); @@ -44,10 +49,10 @@ export default async function ContactDetailPage({
- Retour aux prospects + {returnLink.label}
@@ -187,14 +192,14 @@ export default async function ContactDetailPage({

- La suppression marque toutes les coordonnées de ce contact comme inéligibles, - de façon persistante, y compris face à un futur réimport. + Cette action bloque durablement toutes les coordonnées de ce contact, notamment + son numéro WhatsApp. La recherche automatique ne pourra pas le réimporter dans ce workspace.

diff --git a/apps/web/app/w/[workspaceSlug]/prospects/actions.ts b/apps/web/app/w/[workspaceSlug]/prospects/actions.ts index 9f43b60..449ce1a 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/prospects/actions.ts @@ -5,9 +5,21 @@ import { addContactEmployment, addContactIdentity, createContact, + improveConversationDraft, suppressContact, + sendConversationCommand, } from "@/lib/api"; +export async function improveProspectMessageAction( + workspaceSlug: string, + conversationId: string, + draft: string, +) { + const body = draft.trim(); + if (!body) throw new Error("Le brouillon est obligatoire."); + return improveConversationDraft(workspaceSlug, conversationId, body); +} + export async function createContactAction(workspaceSlug: string, formData: FormData) { const firstName = String(formData.get("firstName") ?? "").trim(); const lastName = String(formData.get("lastName") ?? "").trim(); @@ -28,6 +40,25 @@ export async function createContactAction(workspaceSlug: string, formData: FormD revalidatePath(`/w/${workspaceSlug}/prospects`); } +export async function sendProspectMessageAction( + workspaceSlug: string, + contactId: string, + conversationId: string, + formData: FormData, +) { + const mode = String(formData.get("mode") ?? "manual"); + if (mode !== "manual" && mode !== "setter") throw new Error("Mode d’envoi invalide."); + const body = String(formData.get("body") ?? "").trim(); + if (mode === "manual" && !body) throw new Error("Le message est obligatoire."); + await sendConversationCommand(workspaceSlug, conversationId, { + mode, + ...(body ? { body } : {}), + }); + revalidatePath(`/w/${workspaceSlug}/prospects`); + revalidatePath(`/w/${workspaceSlug}/prospects/${contactId}`); + revalidatePath(`/w/${workspaceSlug}/inbox`); +} + export async function addIdentityAction( workspaceSlug: string, contactId: string, diff --git a/apps/web/app/w/[workspaceSlug]/prospects/discover/actions.ts b/apps/web/app/w/[workspaceSlug]/prospects/discover/actions.ts index b72c998..e58be9e 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/discover/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/prospects/discover/actions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { importDiscoveryCandidate, launchDiscoveryRun, @@ -15,8 +16,9 @@ export async function launchDiscoveryAction( formData: FormData, ) { const limit = Math.min(100, Math.max(1, Number(formData.get("limit") ?? 25) || 25)); - await launchDiscoveryRun(workspaceSlug, versionId, limit); + const run = await launchDiscoveryRun(workspaceSlug, versionId, limit); revalidatePath(PAGE(workspaceSlug)); + redirect(`${PAGE(workspaceSlug)}?versionId=${versionId}&runId=${run.id}`); } export async function retryDiscoveryAction( @@ -24,8 +26,9 @@ export async function retryDiscoveryAction( runId: string, _formData: FormData, ) { - await retryDiscoveryRun(workspaceSlug, runId); + const run = await retryDiscoveryRun(workspaceSlug, runId); revalidatePath(PAGE(workspaceSlug)); + redirect(`${PAGE(workspaceSlug)}?versionId=${run.icpVersionId}&runId=${run.id}`); } export async function importCandidateAction( diff --git a/apps/web/app/w/[workspaceSlug]/prospects/discover/discovery-launch-form.tsx b/apps/web/app/w/[workspaceSlug]/prospects/discover/discovery-launch-form.tsx new file mode 100644 index 0000000..973ec20 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/prospects/discover/discovery-launch-form.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Eye, LoaderCircle, Search } from "lucide-react"; +import Link from "next/link"; +import { useFormStatus } from "react-dom"; + +export function DiscoveryLaunchForm({ + action, + activeRunHref, + runsHref, +}: { + action: (formData: FormData) => void | Promise; + activeRunHref: string | null; + runsHref: string; +}) { + return ( +
+ + + + {activeRunHref ? : null} + {activeRunHref ? "Suivre la recherche" : "Ses runs"} + + + ); +} + +function LaunchButton({ active }: { active: boolean }) { + const { pending } = useFormStatus(); + const busy = active || pending; + return ( + + ); +} diff --git a/apps/web/app/w/[workspaceSlug]/prospects/discover/page.tsx b/apps/web/app/w/[workspaceSlug]/prospects/discover/page.tsx index 886567c..d4b341b 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/discover/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/prospects/discover/page.tsx @@ -2,8 +2,9 @@ import { ArrowLeft, Check, ExternalLink, + Link2, + LoaderCircle, RotateCcw, - Search, Target, TriangleAlert, UserRoundPlus, @@ -19,6 +20,8 @@ import { launchDiscoveryAction, retryDiscoveryAction, } from "./actions"; +import { DiscoveryRunAutoRefresh } from "./run-auto-refresh"; +import { DiscoveryLaunchForm } from "./discovery-launch-form"; export const metadata = { title: "Découverte de prospects" }; export const dynamic = "force-dynamic"; @@ -38,14 +41,25 @@ export default async function DiscoverPage({ }) { const { workspaceSlug } = await params; const { versionId, runId } = await searchParams; - const [versions, runs] = await Promise.all([ + const [versions, allRuns] = await Promise.all([ listIcpVersions(workspaceSlug), - listDiscoveryRuns(workspaceSlug, versionId), + listDiscoveryRuns(workspaceSlug), ]); + const runs = { + data: versionId + ? allRuns.data.filter((run) => run.icpVersionId === versionId) + : allRuns.data, + }; const selectedRun = runId ? await getDiscoveryRun(workspaceSlug, runId) : null; + const activeRunByVersion = new Map( + allRuns.data + .filter((run) => run.status === "running") + .map((run) => [run.icpVersionId, run]), + ); return ( <> + run.status === "running")} />
- ICP publié → candidats LinkedIn + ICP publié → profils LinkedIn

Découverte de prospects

- Seule une version ICP publiée peut lancer une recherche. Les filtres envoyés au - fournisseur sont enregistrés, et chaque candidat montre ses correspondances et - écarts avant import. + Seule une version ICP publiée peut lancer cette recherche LinkedIn. Les filtres + envoyés à Unipile sont enregistrés. Email et WhatsApp utilisent leurs propres + recherches entreprises depuis les campagnes correspondantes.

{versions.data.length} version{versions.data.length > 1 ? "s" : ""} publiée{versions.data.length > 1 ? "s" : ""}
+ {selectedRun?.status === "running" ? ( +
+ +
+ Recherche LinkedIn lancée + + Unipile recherche jusqu’à {Number(selectedRun.filters?.limit ?? 25)} profils. La page se met à jour automatiquement et vous pouvez la quitter sans perdre le job. + +
+
+ ) : null} +
@@ -85,6 +116,7 @@ export default async function DiscoverPage({ ) : ( versions.data.map((version) => { const launch = launchDiscoveryAction.bind(null, workspaceSlug, version.id); + const activeRun = activeRunByVersion.get(version.id); return (

-
- - - - Ses runs - -
+
); }) @@ -189,7 +207,9 @@ export default async function DiscoverPage({

) : selectedRun.candidates.length === 0 ? (

- {selectedRun.status === "failed" + {selectedRun.status === "running" + ? "Recherche LinkedIn en cours. Vous pouvez quitter cette page : le job continue en arrière-plan." + : selectedRun.status === "failed" ? "Le fournisseur était indisponible : aucune liste vide trompeuse — relancez le run." : "Aucun candidat retourné par le fournisseur pour ces filtres."}

@@ -207,12 +227,27 @@ export default async function DiscoverPage({

{[candidate.companyName, candidate.location].filter(Boolean).join(" · ") || "—"}

+ {candidate.companyWebsite ? ( + + {candidate.companyDomain ?? "Site de l’entreprise"} + + + ) : null}
- {candidate.linkedinUrl ? ( - - LinkedIn - - ) : null} +
+
+ } + label="LinkedIn" + channel={candidate.channels.linkedin} + external + />
{candidate.icpFit.matches.map((match) => ( @@ -251,3 +286,68 @@ export default async function DiscoverPage({ ); } + +type CandidateChannel = { + readonly value: string | null; + readonly status: "verified" | "found" | "unverified" | "unavailable"; + readonly evidenceUrl?: string | null; + readonly observedAt?: string | null; +}; + +const CHANNEL_STATUS: Record = { + verified: { label: "vérifié", className: "text-success" }, + found: { label: "trouvé", className: "text-brand-blue" }, + unverified: { label: "à vérifier", className: "text-warning" }, + unavailable: { label: "indisponible", className: "text-muted" }, +}; + +function ChannelCard({ + channel, + href, + icon, + label, + external = false, +}: { + channel: CandidateChannel; + href: string | null; + icon: React.ReactNode; + label: string; + external?: boolean; +}) { + const status = CHANNEL_STATUS[channel.status]; + const className = "min-w-0 rounded-lg border border-line bg-surface-soft p-2.5"; + return ( +
+ + {icon} {label} + + {href ? ( + + {channel.value} + + ) : ( + + {channel.value ?? "Non trouvé"} + + )} + + {status.label} + + {channel.evidenceUrl ? ( + + Voir la preuve + + ) : null} +
+ ); +} diff --git a/apps/web/app/w/[workspaceSlug]/prospects/discover/run-auto-refresh.tsx b/apps/web/app/w/[workspaceSlug]/prospects/discover/run-auto-refresh.tsx new file mode 100644 index 0000000..71d88fe --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/prospects/discover/run-auto-refresh.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export function DiscoveryRunAutoRefresh({ active }: { active: boolean }) { + const router = useRouter(); + + useEffect(() => { + if (!active) return; + const timer = window.setInterval(() => router.refresh(), 3_000); + return () => window.clearInterval(timer); + }, [active, router]); + + return null; +} diff --git a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx index e8217cb..0fdac84 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx @@ -1,7 +1,7 @@ -import { UserRoundPlus, Users } from "lucide-react"; +import { AtSign, Filter, Mail, MessageCircle, Search, UserRound } from "lucide-react"; import Link from "next/link"; -import { listCompanies, listContacts } from "@/lib/api"; -import { createContactAction } from "./actions"; +import { getProspectView, listProspectViews } from "@/lib/api"; +import { ProspectActivityDrawer } from "@/components/prospect-activity-drawer"; export const metadata = { title: "Prospects" }; export const dynamic = "force-dynamic"; @@ -11,133 +11,114 @@ export default async function ProspectsPage({ searchParams, }: { params: Promise<{ workspaceSlug: string }>; - searchParams: Promise<{ search?: string }>; + searchParams: Promise<{ search?: string; icp?: string; channel?: string; prospect?: string }>; }) { const { workspaceSlug } = await params; - const { search } = await searchParams; - const [contacts, companies] = await Promise.all([ - listContacts(workspaceSlug, search), - listCompanies(workspaceSlug), - ]); - const create = createContactAction.bind(null, workspaceSlug); + const query = await searchParams; + const result = await listProspectViews(workspaceSlug, { + ...(query.search ? { search: query.search } : {}), + ...(query.icp ? { icpVersionId: query.icp } : {}), + ...(query.channel ? { channel: query.channel } : {}), + }); + const selected = query.prospect ? await getProspectView(workspaceSlug, query.prospect) : null; + const listHref = prospectListHref(workspaceSlug, query); return ( <> -
+

Prospects

- Une identité canonique par personne, suivie malgré ses changements d’employeur. + ICP, canaux disponibles, avis IA et conversations dans une seule vue.

- - Découvrir via ICP publié - - {contacts.data.length} contacts + {result.data.length} affiché{result.data.length > 1 ? "s" : ""} + Nouvelle recherche
-
-
-
-
- - -
-
-
- {contacts.data.length === 0 ? ( -

- Aucun prospect pour le moment. Créez le premier contact ou attendez la - découverte ICP. -

- ) : ( - - - - - - - - - - {contacts.data.map((contact) => ( - - - - - - ))} - -
NomEmploi courantStatut
- - {contact.firstName} {contact.lastName} - - - {contact.currentEmployment - ? `${contact.currentEmployment.title} · ${contact.currentEmployment.companyName}` - : "—"} - - - {contact.status === "suppressed" ? "supprimé" : "actif"} - -
- )} -
-
+
+
+ + + + +
+
- + )} + + + {selected ? : null}
); } + +function ChannelBadges({ channels }: { channels: { linkedin: boolean; email: boolean; whatsapp: boolean } }) { + return <>{channels.linkedin ? LinkedIn : null}{channels.email ? Email : null}{channels.whatsapp ? WhatsApp : null}; +} + +function prospectListHref(workspaceSlug: string, query: { search?: string; icp?: string; channel?: string }) { + const params = new URLSearchParams(); + if (query.search) params.set("search", query.search); + if (query.icp) params.set("icp", query.icp); + if (query.channel) params.set("channel", query.channel); + return `/w/${workspaceSlug}/prospects${params.size ? `?${params.toString()}` : ""}`; +} diff --git a/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx b/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx index 924a80e..9fcfd60 100644 --- a/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx @@ -80,7 +80,13 @@ export default async function ResearchProgressPage({ {run.id.slice(0, 13)}

- {run.status === "failed" ? "Étude ICP interrompue" : "Étude ICP en cours"} + {run.status === "failed" + ? "Étude ICP interrompue" + : run.status === "partial" + ? "Rapport ICP partiel disponible" + : ["completed", "ready_for_review"].includes(run.status) + ? "Étude ICP terminée" + : "Étude ICP en cours"}

Les résultats validés restent disponibles si une source ou une étape échoue. @@ -264,10 +270,11 @@ export default async function ResearchProgressPage({

Le livrable ICP apparaîtra ici

- Publication et prospection resteront soumises à une validation humaine. + Le rapport est vérifié automatiquement. Si le résultat ne vous convient pas, + vous pourrez relancer une nouvelle étude.

- {run.status === "ready_for_review" ? ( + {["ready_for_review", "completed", "partial"].includes(run.status) ? ( Ouvrir le rapport diff --git a/apps/web/app/w/[workspaceSlug]/research/[runId]/report/actions.ts b/apps/web/app/w/[workspaceSlug]/research/[runId]/report/actions.ts deleted file mode 100644 index 3333731..0000000 --- a/apps/web/app/w/[workspaceSlug]/research/[runId]/report/actions.ts +++ /dev/null @@ -1,132 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { - correctIcpProposal, - publishIcpVersion, - researchMore, - reviewFinding, - reviewIcpProposal, -} from "@/lib/api"; - -const REPORT_PATH = (workspaceSlug: string, runId: string) => - `/w/${workspaceSlug}/research/${runId}/report`; - -export async function approveProposal( - workspaceSlug: string, - runId: string, - proposalId: string, - _formData: FormData, -) { - await reviewIcpProposal(workspaceSlug, runId, "approve-icp", proposalId, null); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function rejectProposal( - workspaceSlug: string, - runId: string, - proposalId: string, - formData: FormData, -) { - await reviewIcpProposal( - workspaceSlug, - runId, - "reject-icp", - proposalId, - String(formData.get("reason") ?? "ICP rejeté lors de la revue humaine."), - ); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function confirmFinding( - workspaceSlug: string, - runId: string, - findingId: string, - _formData: FormData, -) { - await reviewFinding(workspaceSlug, runId, findingId, { decision: "confirmed" }); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function correctFinding( - workspaceSlug: string, - runId: string, - findingId: string, - formData: FormData, -) { - const statement = String(formData.get("statement") ?? "").trim(); - if (!statement) throw new Error("Une correction de finding exige une reformulation."); - await reviewFinding(workspaceSlug, runId, findingId, { - decision: "corrected", - statement, - }); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function rejectFinding( - workspaceSlug: string, - runId: string, - findingId: string, - formData: FormData, -) { - const reason = String(formData.get("reason") ?? "").trim(); - await reviewFinding(workspaceSlug, runId, findingId, { - decision: "rejected", - reason: - reason.length >= 3 - ? reason - : "Contradiction non résolue identifiée lors de la revue humaine.", - }); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function correctProposal( - workspaceSlug: string, - runId: string, - proposalId: string, - formData: FormData, -) { - const fields: Record = {}; - const name = String(formData.get("name") ?? "").trim(); - if (name) fields.name = name; - for (const listField of ["buyingCommittee", "problems", "signals", "exclusions", "unknowns"] as const) { - const raw = formData.get(listField); - if (raw !== null) { - fields[listField] = String(raw) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - } - } - const criteria = String(formData.get("criteria") ?? "").trim(); - if (criteria) { - fields.criteria = JSON.parse(criteria); - } - if (Object.keys(fields).length === 0) return; - await correctIcpProposal(workspaceSlug, runId, proposalId, fields); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function publishProposal( - workspaceSlug: string, - runId: string, - proposalId: string, - _formData: FormData, -) { - await publishIcpVersion(workspaceSlug, runId, proposalId); - revalidatePath(REPORT_PATH(workspaceSlug, runId)); -} - -export async function requestMoreResearch( - workspaceSlug: string, - runId: string, - formData: FormData, -) { - const fromStage = String(formData.get("fromStage") ?? ""); - const reason = String(formData.get("reason") ?? "").trim(); - if (reason.length < 10) { - throw new Error("Décrivez la recherche complémentaire attendue (10 caractères minimum)."); - } - await researchMore(workspaceSlug, runId, fromStage, reason); - revalidatePath(`/w/${workspaceSlug}/research/${runId}`); -} diff --git a/apps/web/app/w/[workspaceSlug]/research/[runId]/report/page.tsx b/apps/web/app/w/[workspaceSlug]/research/[runId]/report/page.tsx index 711cddf..c5915ff 100644 --- a/apps/web/app/w/[workspaceSlug]/research/[runId]/report/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/research/[runId]/report/page.tsx @@ -1,46 +1,28 @@ import { ArrowLeft, - Check, + ArrowRight, + BriefcaseBusiness, + Building2, CheckCircle2, ExternalLink, - FileText, - Lock, - PencilLine, - Search, - ShieldCheck, + FileCheck2, + MapPin, + RefreshCw, + SearchCheck, + Sparkles, + Target, TriangleAlert, UsersRound, - X, } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { getResearchReport, listWorkspaces, OutboundApiError } from "@/lib/api"; -import { - approveProposal, - confirmFinding, - correctFinding, - correctProposal, - publishProposal, - rejectFinding, - rejectProposal, - requestMoreResearch, -} from "./actions"; +import type { ReactNode } from "react"; +import { getResearchReport, OutboundApiError } from "@/lib/api"; +import { campaignsHref } from "./report-links"; export const metadata = { title: "Rapport ICP" }; export const dynamic = "force-dynamic"; -const REVIEW_BADGE: Record = { - pending: { label: "À revoir", className: "badge" }, - unreviewed: { label: "À revoir", className: "badge" }, - accepted: { label: "Accepté", className: "badge badge-success" }, - confirmed: { label: "Confirmé", className: "badge badge-success" }, - approved: { label: "Approuvé", className: "badge badge-success" }, - corrected: { label: "Corrigé", className: "badge badge-signal" }, - reworded: { label: "Reformulé", className: "badge badge-signal" }, - hypothesis: { label: "Hypothèse", className: "badge badge-warning" }, - rejected: { label: "Rejeté", className: "badge badge-danger" }, -}; - export default async function ResearchReportPage({ params, }: { @@ -48,570 +30,363 @@ export default async function ResearchReportPage({ }) { const { workspaceSlug, runId } = await params; let report; - let role: string = "viewer"; try { - [report] = await Promise.all([getResearchReport(workspaceSlug, runId)]); - const workspaces = await listWorkspaces(); - role = workspaces.find((workspace) => workspace.slug === workspaceSlug)?.role ?? "viewer"; + report = await getResearchReport(workspaceSlug, runId); } catch (error) { if (error instanceof OutboundApiError && error.status === 404) notFound(); throw error; } + const isV3 = report.run.brief.researchVersion === 3; + const ranking = object(report.stageOutputs.objective_ranking); const review = object(report.stageOutputs.evidence_review); - const buyerLandscape = object(report.stageOutputs.buyer_landscape_discovery); - const buyerSegments = array(buyerLandscape.buyerSegments).map(object); const commercialReadiness = object(review.commercialReadiness); - const proposals = report.proposals.map(object); - const versions = (report.versions ?? []).map(object); - const publishedProposalIds = new Set(versions.map((version) => text(version.proposalId))); - const contradictions = stringArray(review.unresolvedContradictions); - const allUnknowns = [ - ...new Set([ - ...stringArray(object(report.stageOutputs.product_analysis).unknowns), - ...proposals.flatMap((proposal) => stringArray(proposal.unknowns)), - ]), - ]; - const evidenceById = new Map(report.evidence.map((item) => [item.id, item])); - const canPublish = ["admin", "owner"].includes(role); - const canReview = ["operator", "reviewer", "admin", "owner"].includes(role); - const readyForReview = report.run.status === "ready_for_review"; - const moreResearch = requestMoreResearch.bind(null, workspaceSlug, runId); + const proposals = report.proposals + .map(object) + .sort((left, right) => numeric(left.rank) - numeric(right.rank)); + const decision = text(commercialReadiness.decision); + const hasReservations = isV3 ? text(ranking.status) !== "complete" : decision !== "ready"; + const summary = isV3 ? text(ranking.summary) : text(review.executiveSummary); + const coverage = object(ranking.coverage); + const missingStages = stringArray(ranking.missingStages); + const restartHref = `/w/${workspaceSlug}/strategy/product-reading`; + const campaignsUrl = campaignsHref(workspaceSlug, report.versions); return ( - <> -
+
+
- +
-
-
-
-
-

- - Synthèse exécutive -

-
-
- {text(commercialReadiness.decision) ? ( -
- - {text(commercialReadiness.decision) === "ready" - ? "ICP prospectables" - : "Recherche marché insuffisante"} - -

{text(commercialReadiness.rationale)}

-
- ) : null} -

- {text(review.executiveSummary) || - "La synthèse finale sera disponible après l’audit des preuves."} + {isV3 && text(ranking.status) === "partial" ? ( +

+
+
- -
-
-

Carte concurrentielle

- {report.competitors.length} -
-
- - - - - - - - - - - {report.competitors.map((competitor, index) => ( - - - - - - - ))} - -
ActeurRelationRationaleConfiance
- {competitor.url ? ( - - {text(competitor.name)} - - ) : ( - text(competitor.name) - )} - {text(competitor.relation)}{text(competitor.rationale)}{Math.round(number(competitor.confidence) * 100)} %
-
-
+
+
+ ) : null} - {buyerSegments.length ? ( -
-
-
-

- - Paysage des acheteurs -

-

- Utilisateurs finaux, partenaires et équipes capables de construire sont séparés. -

-
- {buyerSegments.length} segments -
-
- {buyerSegments.map((segment, index) => { - const buildVsBuy = object(segment.buildVsBuy); - return ( -
-
- {text(segment.name)} - {buyerTypeLabel(text(segment.buyerType))} - {Math.round(number(segment.confidence) * 100)} % -
-

{text(segment.description)}

-
- - - -
-
Build vs buy
-
- Capacité à construire : {Math.round(number(buildVsBuy.buildAbility))}/100 -
-
- Volonté d’acheter : {Math.round(number(buildVsBuy.willingnessToBuy))}/100 -
-
-
-
- ); - })} -
-
- ) : null} +
+
+ + +
+

+ Synthèse +

+

+ {summary || + "L’étude a analysé votre produit, son environnement concurrentiel et les marchés accessibles."} +

+ {!isV3 && text(commercialReadiness.rationale) ? ( +

{text(commercialReadiness.rationale)}

+ ) : null} +
+
+
-
-
-
-

ICP proposés

-

- Classés par cohérence produit et qualité des preuves. Comparez, corrigez, - approuvez, puis publiez. -

-
- {proposals.length} propositions -
-
- {proposals.map((proposal) => { - const id = text(proposal.id); - const status = text(proposal.reviewStatus) || "pending"; - const isPublished = publishedProposalIds.has(id); - const publishedVersion = versions.find( - (version) => text(version.proposalId) === id, - ); - const approve = approveProposal.bind(null, workspaceSlug, runId, id); - const reject = rejectProposal.bind(null, workspaceSlug, runId, id); - const correct = correctProposal.bind(null, workspaceSlug, runId, id); - const publish = publishProposal.bind(null, workspaceSlug, runId, id); - const proposalCriteria = object(proposal.criteria); - const scorecard = object(proposalCriteria.scorecard); - return ( -
-
- #{text(proposal.rank)} -

{text(proposal.name)}

- {Math.round(number(proposal.confidence) * 100)} % - {buyerTypeLabel(text(proposalCriteria.buyerType))} - - Score {Math.round(number(scorecard.total))}/100 - - - {proposal.humanEdited ? ( - - - Corrigé humainement - - ) : null} - {isPublished ? ( - - - Publié · v{text(publishedVersion?.version)} - - ) : null} -
-
- - - - - - - - -
- {canReview && !isPublished ? ( -
-
- - Corriger cette proposition - -
- -
- - - - - -