From c09e4a0392a7277e22b5aaa4a907a3209fd934dd Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Tue, 4 Aug 2026 21:45:57 +0200 Subject: [PATCH 1/4] feat: secure autonomous outbound workflow Isolate integration tests from the live worker and make time-dependent job scheduling deterministic. --- .env.example | 15 +- .gitignore | 4 + apps/api/src/index.ts | 69 +- .../__pycache__/__init__.cpython-312.pyc | Bin 184 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 2063 -> 0 bytes .../__pycache__/main.cpython-312.pyc | Bin 3338 -> 0 bytes .../api/__pycache__/__init__.cpython-312.pyc | Bin 188 -> 0 bytes .../api/__pycache__/routes.cpython-312.pyc | Bin 14665 -> 0 bytes .../api/__pycache__/schemas.cpython-312.pyc | Bin 9493 -> 0 bytes .../crawler/src/crawler_service/api/routes.py | 10 + .../src/crawler_service/api/schemas.py | 2 + .../core/__pycache__/__init__.cpython-312.pyc | Bin 189 -> 0 bytes .../core/__pycache__/crawler.cpython-312.pyc | Bin 22884 -> 0 bytes .../__pycache__/discovery.cpython-312.pyc | Bin 10082 -> 0 bytes .../domain_limiter.cpython-312.pyc | Bin 2594 -> 0 bytes .../__pycache__/job_manager.cpython-312.pyc | Bin 11124 -> 0 bytes .../request_safety.cpython-312.pyc | Bin 3731 -> 0 bytes .../core/__pycache__/search.cpython-312.pyc | Bin 7816 -> 0 bytes .../__pycache__/sse_emitter.cpython-312.pyc | Bin 7622 -> 0 bytes .../__pycache__/url_safety.cpython-312.pyc | Bin 3470 -> 0 bytes .../src/crawler_service/core/job_manager.py | 14 + .../__pycache__/__init__.cpython-312.pyc | Bin 191 -> 0 bytes .../models/__pycache__/events.cpython-312.pyc | Bin 5769 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 170 -> 0 bytes .../test_api.cpython-312-pytest-9.0.2.pyc | Bin 8068 -> 0 bytes ...tbound_search.cpython-312-pytest-9.0.2.pyc | Bin 10602 -> 0 bytes ...test_security.cpython-312-pytest-9.0.2.pyc | Bin 33251 -> 0 bytes ...st_url_safety.cpython-312-pytest-9.0.2.pyc | Bin 4320 -> 0 bytes apps/crawler/tests/test_api.py | 33 + .../campaigns/[campaignId]/page.tsx | 152 + .../w/[workspaceSlug]/campaigns/actions.ts | 48 + .../campaigns/campaign-auto-refresh.tsx | 14 + .../app/w/[workspaceSlug]/campaigns/page.tsx | 185 + .../campaigns/plans/[planId]/page.tsx | 647 ++ apps/web/app/w/[workspaceSlug]/inbox/page.tsx | 274 + .../app/w/[workspaceSlug]/pipeline/page.tsx | 128 + .../prospects/[contactId]/page.tsx | 9 +- .../w/[workspaceSlug]/prospects/actions.ts | 31 + .../prospects/discover/actions.ts | 7 +- .../discover/discovery-launch-form.tsx | 51 + .../prospects/discover/page.tsx | 168 +- .../prospects/discover/run-auto-refresh.tsx | 16 + .../app/w/[workspaceSlug]/prospects/page.tsx | 207 +- .../[workspaceSlug]/research/[runId]/page.tsx | 13 +- .../research/[runId]/report/actions.ts | 132 - .../research/[runId]/report/page.tsx | 907 +- .../research/[runId]/report/report-links.ts | 14 + .../w/[workspaceSlug]/settings/ai/page.tsx | 26 +- .../settings/calendar/actions.ts | 23 + .../settings/calendar/page.tsx | 123 + .../strategy/product-reading/actions.ts | 3 +- .../strategy/product-reading/brief-form.tsx | 10 + .../strategy/product-reading/page.tsx | 14 +- apps/web/components/app-shell.tsx | 114 +- apps/web/components/conversation-composer.tsx | 110 + .../components/prospect-activity-drawer.tsx | 156 + apps/web/lib/api.ts | 660 +- apps/web/lib/campaign-engagement.ts | 68 + apps/web/lib/inbox-filters.ts | 107 + apps/web/lib/prospect-navigation.ts | 34 + apps/worker/src/index.ts | 203 +- apps/worker/src/research-worker.ts | 44 +- .../2026-08-01-icp-v3-evidence-led-design.md | 618 ++ docs/architecture/API_CONTRACT.md | 21 +- docs/architecture/F009_BACKEND_RUNBOOK.md | 8 + docs/architecture/FLOWS.md | 42 +- docs/product/DECISIONS.md | 95 + docs/product/DELIVERY_PLAN.md | 28 +- docs/product/FEATURE_CATALOG.md | 98 +- docs/product/TRACEABILITY_MATRIX.md | 4 +- package.json | 2 +- .../src/campaigns/autonomous-prospecting.ts | 139 + .../campaigns/campaign-autopilot-dashboard.ts | 55 + .../campaigns/campaign-content-generator.ts | 56 + .../src/campaigns/campaign-engagement.ts | 163 + .../src/campaigns/channel-assessment.ts | 51 + .../campaigns/conversation-draft-improver.ts | 22 + .../src/campaigns/inbound-reply-agent.ts | 60 + .../src/campaigns/outbound-channel-gateway.ts | 39 + .../src/crm/company-prospect-source.ts | 21 + .../src/crm/prospect-discovery-policy.ts | 69 + .../src/crm/prospect-enrichment-ports.ts | 30 + .../src/gtm/icp-prospectability-policy.ts | 217 - .../src/gtm/product-research-ports.ts | 75 +- .../src/gtm/research-orchestrator.ts | 535 +- .../src/gtm/v3-report-projection.ts | 196 + .../src/gtm/v3-stage-input-projector.ts | 135 + .../openapi/product-research-v1.json | 23 +- packages/contracts/src/product-research-v3.ts | 369 + packages/contracts/src/product-research.ts | 107 +- .../campaigns/campaign-automation-health.ts | 32 + .../campaigns/campaign-autopilot-policy.ts | 322 + .../domain/src/campaigns/campaign-sequence.ts | 81 + .../domain/src/campaigns/prospecting-plan.ts | 99 + .../src/campaigns/sequence-validation.ts | 38 + packages/domain/src/crm/prospect-channels.ts | 42 + packages/domain/src/gtm/product-research.ts | 155 +- packages/domain/src/pipeline/opportunity.ts | 41 + .../migrations/0012_numerous_microbe.sql | 12 + .../migrations/0013_third_lilith.sql | 2 + .../migrations/0014_big_doctor_faustus.sql | 21 + .../migrations/0015_slippery_selene.sql | 20 + .../migrations/0016_medical_saracen.sql | 1 + .../0017_v3_budget_partial_recovery.sql | 42 + .../migrations/0018_superb_clea.sql | 1 + .../migrations/0019_cloudy_gressill.sql | 2 + .../migrations/0020_cultured_kylun.sql | 210 + .../migrations/0021_campaign-backfill.sql | 156 + .../0022_ranked-icp-campaign-backfill.sql | 111 + .../0023_serious_doctor_strange.sql | 144 + .../0024_close-poisoned-discovery-runs.sql | 18 + .../migrations/0025_lovely_silvermane.sql | 1 + .../0026_autonomous_campaign_sourcing.sql | 3 + .../0027_autonomous_campaign_scoring.sql | 8 + .../0028_autonomous_campaign_execution.sql | 78 + .../0029_autonomous_inbound_replies.sql | 107 + .../0030_campaign_autopilot_policy.sql | 22 + .../0031_daily_prospecting_and_commands.sql | 51 + .../migrations/0032_flashy_rick_jones.sql | 37 + .../migrations/0033_third_black_widow.sql | 21 + .../0034_opportunity-history-backfill.sql | 13 + .../0035_calcom_agent_scheduling.sql | 9 + .../migrations/0036_meeting_proposals.sql | 32 + .../migrations/0037_linkedin_inbox_unread.sql | 1 + .../migrations/meta/0012_snapshot.json | 4612 +++++++++ .../migrations/meta/0013_snapshot.json | 4624 +++++++++ .../migrations/meta/0014_snapshot.json | 4802 ++++++++++ .../migrations/meta/0015_snapshot.json | 4979 ++++++++++ .../migrations/meta/0016_snapshot.json | 4995 ++++++++++ .../migrations/meta/0018_snapshot.json | 5002 ++++++++++ .../migrations/meta/0019_snapshot.json | 5014 ++++++++++ .../migrations/meta/0020_snapshot.json | 5414 +++++++++++ .../migrations/meta/0021_snapshot.json | 5414 +++++++++++ .../migrations/meta/0022_snapshot.json | 5414 +++++++++++ .../migrations/meta/0023_snapshot.json | 5863 ++++++++++++ .../migrations/meta/0024_snapshot.json | 5863 ++++++++++++ .../migrations/meta/0025_snapshot.json | 5885 ++++++++++++ .../migrations/meta/0026_snapshot.json | 5899 ++++++++++++ .../migrations/meta/0027_snapshot.json | 5950 ++++++++++++ .../migrations/meta/0028_snapshot.json | 6617 +++++++++++++ .../migrations/meta/0029_snapshot.json | 7535 +++++++++++++++ .../migrations/meta/0032_snapshot.json | 8189 ++++++++++++++++ .../migrations/meta/0033_snapshot.json | 8304 +++++++++++++++++ .../migrations/meta/_journal.json | 184 +- .../infrastructure/src/ai/crawler-client.ts | 12 +- .../src/ai/external-query-guard.ts | 27 + .../ai/langchain-research-agent-executor.ts | 485 +- ...postgres-research-tool-request-registry.ts | 133 + .../infrastructure/src/ai/research-tools.ts | 151 +- .../src/ai/v3-objective-ranker.ts | 115 + .../src/ai/v3-sourcing-validator.ts | 153 + .../src/calendar/calcom-client.ts | 370 + .../src/calendar/calcom-webhook.ts | 191 + .../src/calendar/calendar-credential.ts | 47 + .../src/calendar/meeting-proposal-manager.ts | 567 ++ .../calendar/postgres-calendar-integration.ts | 1099 +++ .../campaigns/automated-reply-send-runner.ts | 223 + .../campaigns/campaign-automation-runner.ts | 455 + .../campaigns/campaign-composition-runner.ts | 589 ++ .../campaigns/campaign-health-reconciler.ts | 73 + .../campaigns/campaign-sourcing-reconciler.ts | 310 + .../campaigns/channel-assessment-runner.ts | 91 + .../campaigns/channel-observation-source.ts | 320 + .../src/campaigns/channel-strategy-planner.ts | 122 + .../campaigns/conversation-command-runner.ts | 503 + .../campaigns/daily-prospecting-scheduler.ts | 237 + .../src/campaigns/inbound-reply-runner.ts | 760 ++ .../langchain-campaign-content-generator.ts | 87 + .../langchain-conversation-draft-improver.ts | 210 + .../langchain-inbound-reply-agent.ts | 95 + .../src/campaigns/outreach-dispatch-runner.ts | 724 ++ .../postgres-campaign-autopilot-dashboard.ts | 196 + ...stgres-campaign-conversation-repository.ts | 610 ++ .../campaigns/postgres-campaign-repository.ts | 247 + ...ostgres-conversation-command-repository.ts | 77 + .../postgres-prospecting-plan-repository.ts | 496 + .../prospect-assessment-reconciler.ts | 56 + .../campaigns/unipile-channel-readiness.ts | 17 + .../campaigns/unipile-chat-synchronizer.ts | 576 ++ .../unipile-outbound-channel-gateway.ts | 171 + .../src/campaigns/unipile-webhook-ingestor.ts | 105 + .../crm/crawler-company-prospect-source.ts | 258 + .../src/crm/crawler-prospect-enricher.ts | 374 + .../src/crm/postgres-discovery-repository.ts | 294 +- .../crm/postgres-prospect-view-repository.ts | 448 + .../src/crm/prospect-discovery-runner.ts | 307 + .../src/crm/unipile-prospect-source.ts | 445 +- .../infrastructure/src/database/schema.ts | 797 ++ .../postgres-product-research-repository.ts | 361 +- .../src/gtm/research-stage-projection.ts | 131 +- .../src/jobs/postgres-job-queue.ts | 26 +- .../src/pipeline/opportunity-stage-writer.ts | 80 + .../postgres-opportunity-repository.ts | 192 + .../src/testing/in-memory-research-backend.ts | 171 +- .../src/http/calendar-connection-handler.ts | 131 + .../src/http/calendar-webhook-handler.ts | 59 + .../interface/src/http/campaign-handler.ts | 403 + packages/interface/src/http/crm-handler.ts | 37 + .../interface/src/http/discovery-handler.ts | 284 +- packages/interface/src/http/http-schemas.ts | 8 + .../interface/src/http/opportunity-handler.ts | 95 + .../src/http/product-research-handler.ts | 13 +- .../interface/src/http/sequence-handler.ts | 17 +- .../src/http/unipile-webhook-handler.ts | 49 + .../src/http/workspace-ai-settings-handler.ts | 2 +- scripts/evaluate-ignitionrag-icp-run.ts | 24 +- scripts/run-integration-tests.ts | 79 + scripts/smoke-icp-v3-live.ts | 222 + scripts/start-development.ts | 65 +- tests/fixtures/research-agent-fixtures.ts | 245 + ...onversation-draft-improvement-http.test.ts | 88 + tests/http/opportunity-http.test.ts | 74 + tests/http/product-research-http.test.ts | 60 + tests/http/workspace-ai-settings-http.test.ts | 28 +- tests/integration/calendar-booking.test.ts | 215 + tests/integration/calendar-setter.test.ts | 172 + tests/integration/crm-foundation.test.ts | 11 + .../meeting-proposal-manager.test.ts | 288 + tests/integration/postgres-foundation.test.ts | 274 +- tests/integration/prospect-discovery.test.ts | 175 +- tests/integration/review-publication.test.ts | 2 + tests/integration/v3-auto-publication.test.ts | 1094 +++ tests/unit/calcom-client.test.ts | 149 + tests/unit/calendar-webhook.test.ts | 77 + tests/unit/campaign-automation-health.test.ts | 43 + .../unit/campaign-autopilot-dashboard.test.ts | 44 + tests/unit/campaign-autopilot-policy.test.ts | 80 + tests/unit/campaign-engagement-view.test.ts | 52 + tests/unit/campaign-engagement.test.ts | 36 + tests/unit/campaign-prospect-score.test.ts | 20 + tests/unit/campaign-sequence.test.ts | 70 + tests/unit/channel-assessment-policy.test.ts | 57 + tests/unit/channel-observation-source.test.ts | 136 + tests/unit/crawler-client.test.ts | 28 + .../crawler-company-prospect-source.test.ts | 101 + tests/unit/crawler-prospect-enricher.test.ts | 141 + .../unit/daily-prospecting-scheduler.test.ts | 19 + tests/unit/development-launcher.test.ts | 12 + tests/unit/discovery-filters.test.ts | 1 + tests/unit/icp-prospectability-policy.test.ts | 100 - .../inbound-webhook-normalization.test.ts | 59 + tests/unit/inbox-filters.test.ts | 54 + tests/unit/integration-test-runner.test.ts | 26 + tests/unit/opportunity-pipeline.test.ts | 30 + tests/unit/product-research-domain.test.ts | 134 + tests/unit/product-research-openapi.test.ts | 24 + tests/unit/prospect-navigation.test.ts | 31 + tests/unit/report-discovery-link.test.ts | 17 + tests/unit/research-agent-contracts.test.ts | 86 +- tests/unit/research-agent-provider.test.ts | 238 +- tests/unit/research-ai-tools.test.ts | 129 +- tests/unit/research-orchestrator.test.ts | 225 +- tests/unit/research-worker.test.ts | 39 + tests/unit/unipile-chat-sync.test.ts | 114 + .../unipile-outbound-channel-gateway.test.ts | 103 + tests/unit/unipile-prospect-source.test.ts | 197 + tests/unit/v3-objective-ranker.test.ts | 105 + tests/unit/v3-report-projection.test.ts | 40 + tests/unit/v3-sourcing-validator.test.ts | 93 + tests/unit/v3-stage-input-projector.test.ts | 68 + 260 files changed, 140580 insertions(+), 1811 deletions(-) delete mode 100644 apps/crawler/src/crawler_service/__pycache__/__init__.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/__pycache__/config.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/__pycache__/main.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/api/__pycache__/__init__.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/api/__pycache__/routes.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/api/__pycache__/schemas.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/__init__.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/crawler.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/discovery.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/domain_limiter.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/job_manager.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/request_safety.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/search.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/sse_emitter.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/core/__pycache__/url_safety.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/models/__pycache__/__init__.cpython-312.pyc delete mode 100644 apps/crawler/src/crawler_service/models/__pycache__/events.cpython-312.pyc delete mode 100644 apps/crawler/tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 apps/crawler/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc delete mode 100644 apps/crawler/tests/__pycache__/test_outbound_search.cpython-312-pytest-9.0.2.pyc delete mode 100644 apps/crawler/tests/__pycache__/test_security.cpython-312-pytest-9.0.2.pyc delete mode 100644 apps/crawler/tests/__pycache__/test_url_safety.cpython-312-pytest-9.0.2.pyc create mode 100644 apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/campaigns/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/campaigns/campaign-auto-refresh.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/campaigns/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/campaigns/plans/[planId]/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/inbox/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/pipeline/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/prospects/discover/discovery-launch-form.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/prospects/discover/run-auto-refresh.tsx delete mode 100644 apps/web/app/w/[workspaceSlug]/research/[runId]/report/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/research/[runId]/report/report-links.ts create mode 100644 apps/web/app/w/[workspaceSlug]/settings/calendar/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/settings/calendar/page.tsx create mode 100644 apps/web/components/conversation-composer.tsx create mode 100644 apps/web/components/prospect-activity-drawer.tsx create mode 100644 apps/web/lib/campaign-engagement.ts create mode 100644 apps/web/lib/inbox-filters.ts create mode 100644 apps/web/lib/prospect-navigation.ts create mode 100644 docs/architecture/2026-08-01-icp-v3-evidence-led-design.md create mode 100644 packages/application/src/campaigns/autonomous-prospecting.ts create mode 100644 packages/application/src/campaigns/campaign-autopilot-dashboard.ts create mode 100644 packages/application/src/campaigns/campaign-content-generator.ts create mode 100644 packages/application/src/campaigns/campaign-engagement.ts create mode 100644 packages/application/src/campaigns/channel-assessment.ts create mode 100644 packages/application/src/campaigns/conversation-draft-improver.ts create mode 100644 packages/application/src/campaigns/inbound-reply-agent.ts create mode 100644 packages/application/src/campaigns/outbound-channel-gateway.ts create mode 100644 packages/application/src/crm/company-prospect-source.ts create mode 100644 packages/application/src/crm/prospect-discovery-policy.ts create mode 100644 packages/application/src/crm/prospect-enrichment-ports.ts create mode 100644 packages/application/src/gtm/v3-report-projection.ts create mode 100644 packages/application/src/gtm/v3-stage-input-projector.ts create mode 100644 packages/contracts/src/product-research-v3.ts create mode 100644 packages/domain/src/campaigns/campaign-automation-health.ts create mode 100644 packages/domain/src/campaigns/campaign-autopilot-policy.ts create mode 100644 packages/domain/src/campaigns/campaign-sequence.ts create mode 100644 packages/domain/src/campaigns/prospecting-plan.ts create mode 100644 packages/domain/src/crm/prospect-channels.ts create mode 100644 packages/domain/src/pipeline/opportunity.ts create mode 100644 packages/infrastructure/migrations/0012_numerous_microbe.sql create mode 100644 packages/infrastructure/migrations/0013_third_lilith.sql create mode 100644 packages/infrastructure/migrations/0014_big_doctor_faustus.sql create mode 100644 packages/infrastructure/migrations/0015_slippery_selene.sql create mode 100644 packages/infrastructure/migrations/0016_medical_saracen.sql create mode 100644 packages/infrastructure/migrations/0017_v3_budget_partial_recovery.sql create mode 100644 packages/infrastructure/migrations/0018_superb_clea.sql create mode 100644 packages/infrastructure/migrations/0019_cloudy_gressill.sql create mode 100644 packages/infrastructure/migrations/0020_cultured_kylun.sql create mode 100644 packages/infrastructure/migrations/0021_campaign-backfill.sql create mode 100644 packages/infrastructure/migrations/0022_ranked-icp-campaign-backfill.sql create mode 100644 packages/infrastructure/migrations/0023_serious_doctor_strange.sql create mode 100644 packages/infrastructure/migrations/0024_close-poisoned-discovery-runs.sql create mode 100644 packages/infrastructure/migrations/0025_lovely_silvermane.sql create mode 100644 packages/infrastructure/migrations/0026_autonomous_campaign_sourcing.sql create mode 100644 packages/infrastructure/migrations/0027_autonomous_campaign_scoring.sql create mode 100644 packages/infrastructure/migrations/0028_autonomous_campaign_execution.sql create mode 100644 packages/infrastructure/migrations/0029_autonomous_inbound_replies.sql create mode 100644 packages/infrastructure/migrations/0030_campaign_autopilot_policy.sql create mode 100644 packages/infrastructure/migrations/0031_daily_prospecting_and_commands.sql create mode 100644 packages/infrastructure/migrations/0032_flashy_rick_jones.sql create mode 100644 packages/infrastructure/migrations/0033_third_black_widow.sql create mode 100644 packages/infrastructure/migrations/0034_opportunity-history-backfill.sql create mode 100644 packages/infrastructure/migrations/0035_calcom_agent_scheduling.sql create mode 100644 packages/infrastructure/migrations/0036_meeting_proposals.sql create mode 100644 packages/infrastructure/migrations/0037_linkedin_inbox_unread.sql create mode 100644 packages/infrastructure/migrations/meta/0012_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0013_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0014_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0015_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0016_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0018_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0019_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0020_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0021_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0022_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0023_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0024_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0025_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0026_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0027_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0028_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0029_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0032_snapshot.json create mode 100644 packages/infrastructure/migrations/meta/0033_snapshot.json create mode 100644 packages/infrastructure/src/ai/external-query-guard.ts create mode 100644 packages/infrastructure/src/ai/postgres-research-tool-request-registry.ts create mode 100644 packages/infrastructure/src/ai/v3-objective-ranker.ts create mode 100644 packages/infrastructure/src/ai/v3-sourcing-validator.ts create mode 100644 packages/infrastructure/src/calendar/calcom-client.ts create mode 100644 packages/infrastructure/src/calendar/calcom-webhook.ts create mode 100644 packages/infrastructure/src/calendar/calendar-credential.ts create mode 100644 packages/infrastructure/src/calendar/meeting-proposal-manager.ts create mode 100644 packages/infrastructure/src/calendar/postgres-calendar-integration.ts create mode 100644 packages/infrastructure/src/campaigns/automated-reply-send-runner.ts create mode 100644 packages/infrastructure/src/campaigns/campaign-automation-runner.ts create mode 100644 packages/infrastructure/src/campaigns/campaign-composition-runner.ts create mode 100644 packages/infrastructure/src/campaigns/campaign-health-reconciler.ts create mode 100644 packages/infrastructure/src/campaigns/campaign-sourcing-reconciler.ts create mode 100644 packages/infrastructure/src/campaigns/channel-assessment-runner.ts create mode 100644 packages/infrastructure/src/campaigns/channel-observation-source.ts create mode 100644 packages/infrastructure/src/campaigns/channel-strategy-planner.ts create mode 100644 packages/infrastructure/src/campaigns/conversation-command-runner.ts create mode 100644 packages/infrastructure/src/campaigns/daily-prospecting-scheduler.ts create mode 100644 packages/infrastructure/src/campaigns/inbound-reply-runner.ts create mode 100644 packages/infrastructure/src/campaigns/langchain-campaign-content-generator.ts create mode 100644 packages/infrastructure/src/campaigns/langchain-conversation-draft-improver.ts create mode 100644 packages/infrastructure/src/campaigns/langchain-inbound-reply-agent.ts create mode 100644 packages/infrastructure/src/campaigns/outreach-dispatch-runner.ts create mode 100644 packages/infrastructure/src/campaigns/postgres-campaign-autopilot-dashboard.ts create mode 100644 packages/infrastructure/src/campaigns/postgres-campaign-conversation-repository.ts create mode 100644 packages/infrastructure/src/campaigns/postgres-campaign-repository.ts create mode 100644 packages/infrastructure/src/campaigns/postgres-conversation-command-repository.ts create mode 100644 packages/infrastructure/src/campaigns/postgres-prospecting-plan-repository.ts create mode 100644 packages/infrastructure/src/campaigns/prospect-assessment-reconciler.ts create mode 100644 packages/infrastructure/src/campaigns/unipile-channel-readiness.ts create mode 100644 packages/infrastructure/src/campaigns/unipile-chat-synchronizer.ts create mode 100644 packages/infrastructure/src/campaigns/unipile-outbound-channel-gateway.ts create mode 100644 packages/infrastructure/src/campaigns/unipile-webhook-ingestor.ts create mode 100644 packages/infrastructure/src/crm/crawler-company-prospect-source.ts create mode 100644 packages/infrastructure/src/crm/crawler-prospect-enricher.ts create mode 100644 packages/infrastructure/src/crm/postgres-prospect-view-repository.ts create mode 100644 packages/infrastructure/src/crm/prospect-discovery-runner.ts create mode 100644 packages/infrastructure/src/pipeline/opportunity-stage-writer.ts create mode 100644 packages/infrastructure/src/pipeline/postgres-opportunity-repository.ts create mode 100644 packages/interface/src/http/calendar-connection-handler.ts create mode 100644 packages/interface/src/http/calendar-webhook-handler.ts create mode 100644 packages/interface/src/http/campaign-handler.ts create mode 100644 packages/interface/src/http/http-schemas.ts create mode 100644 packages/interface/src/http/opportunity-handler.ts create mode 100644 packages/interface/src/http/unipile-webhook-handler.ts create mode 100644 scripts/run-integration-tests.ts create mode 100644 scripts/smoke-icp-v3-live.ts create mode 100644 tests/http/conversation-draft-improvement-http.test.ts create mode 100644 tests/http/opportunity-http.test.ts create mode 100644 tests/integration/calendar-booking.test.ts create mode 100644 tests/integration/calendar-setter.test.ts create mode 100644 tests/integration/meeting-proposal-manager.test.ts create mode 100644 tests/integration/v3-auto-publication.test.ts create mode 100644 tests/unit/calcom-client.test.ts create mode 100644 tests/unit/calendar-webhook.test.ts create mode 100644 tests/unit/campaign-automation-health.test.ts create mode 100644 tests/unit/campaign-autopilot-dashboard.test.ts create mode 100644 tests/unit/campaign-autopilot-policy.test.ts create mode 100644 tests/unit/campaign-engagement-view.test.ts create mode 100644 tests/unit/campaign-engagement.test.ts create mode 100644 tests/unit/campaign-prospect-score.test.ts create mode 100644 tests/unit/campaign-sequence.test.ts create mode 100644 tests/unit/channel-assessment-policy.test.ts create mode 100644 tests/unit/channel-observation-source.test.ts create mode 100644 tests/unit/crawler-company-prospect-source.test.ts create mode 100644 tests/unit/crawler-prospect-enricher.test.ts create mode 100644 tests/unit/daily-prospecting-scheduler.test.ts create mode 100644 tests/unit/development-launcher.test.ts create mode 100644 tests/unit/inbound-webhook-normalization.test.ts create mode 100644 tests/unit/inbox-filters.test.ts create mode 100644 tests/unit/integration-test-runner.test.ts create mode 100644 tests/unit/opportunity-pipeline.test.ts create mode 100644 tests/unit/prospect-navigation.test.ts create mode 100644 tests/unit/report-discovery-link.test.ts create mode 100644 tests/unit/unipile-chat-sync.test.ts create mode 100644 tests/unit/unipile-outbound-channel-gateway.test.ts create mode 100644 tests/unit/v3-objective-ranker.test.ts create mode 100644 tests/unit/v3-report-projection.test.ts create mode 100644 tests/unit/v3-sourcing-validator.test.ts create mode 100644 tests/unit/v3-stage-input-projector.test.ts 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..98d797d 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,16 @@ 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"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -74,9 +85,18 @@ const crm = createCrmHttpHandler({ }); const unipileDsn = process.env.UNIPILE_DSN ?? ""; const unipileApiKey = process.env.UNIPILE_API_KEY ?? ""; +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, + jobQueue: queue, prospectSource: () => { if (!unipileDsn || !unipileApiKey) { return { @@ -94,13 +114,48 @@ 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 } + : {}), }); }, + 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 +163,14 @@ 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/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 +179,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 2762cc64f5b1a7df57a4e09bb8933c80be71bdb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 184 zcmX@j%ge<81Xns!vOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!3fB)UPAw|dFHX$K z%*{#6%uP+sNYzhCEz{3T&&w>y%+J%!FD*&RFU?EQPb?@X)=w@x4WXcbkZ-w07!iME%$jK;fTdz_+A#i7VE81X!P}cK_|dQp)39T9+>LeOX1tRy6EP!hd}`QL-$ZeP zqSw)4GkK7#rh2t|%xgNWE+@iaUOAvbI9@9#HyJOBHZ5D6?7Nf$!S@`CZk?*-)ws;u zB>{aJdCQsMXb|nbV+kwDqkqm7zlbTt)G%Y9I{qAVn+c`Lj@t~wO zRgKonqDJc)ePAwXbV;Kbb4ly0YP4aNGKCCO885!wR59>qb|oH zguC2*Ci4neZQO*Y4PxzrWL2NtD}C1TKr8c}bVY-8J^M>KaJ|b!ZF{ROvn(QT7j6M2 zEl}FK9A$KwMO;T(Y8 zu&yXLjVrG39_K?=n{|leo!W}e+O$c!ZnOxyP6zaF!p)MCdZoIugV`3i!}G@{atbau z9ZX`Iy5vBP>y%hH9SQ|H^{!g)Lv_O`+zFi9;|W9NRn!jQyEfbRu+=7AO2j$&xVkLU7<;5cF_xnkcbMIE)i{IkS6$*p%m{{EvoPj*!!zJT zL8E@%arJVQi9m2Ui81j!CiJ}szA7q%zaW%qWvpCa27-GI?bR z$}A{TRAv#3Og7f(pcW7~Uzfls zS#4T_%TjZqe!Vj6%Np_hpcdVUTEMO07&ckIwQGZ_xBLU1QhAh7|3Lwo<~!pJy7Omt zEu7AW^OZ1Pd^dt}sl!KS6UOxX;r&-DeN+gia)VJWoXH%9eu5T{gFadhOUse}a<~vhUJ92gCn$GB`=}BYiicku!J=Zg zP#Gkygew~=-P1nW2ul!2r-Mikf9@Fd(F!ChmIuzIunf}|k3oZ)URhI>-1{A^@~gwW zdR$HM4FGc5Kd^}>91C`K@S)`D6?Q#y~RHU2>WgXss+vx ku~_Vl@zLMLt#_%bv9(uUzcb+HANWvaazE^Rlhz^r1F^+nLjV8( 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 e76ba39b9d7de1a019f8c17c0a99d6358f34a88a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3338 zcmaJ@U5r!56`r|2{3x)GxN7}I)UIfewWn#5=ZDeJ`lfH$gCWb5W0m3svv@iWZ?=9s8o@F%9dPL zDhlRvilx>gl}Ny=R0zPJ?>zPUhBMDKdwn+r;Q#yDX z_ePZ=s*pI1T$V`UBjt9eQptj4gd|Cdq-pj}=C%@Q=V-1r*7qYg8l$P&PBH4!+MYfy zI5Hq&zmArWAx*>o;ueIip!ZO%EVTMm=(hQjaEis&qiy9`j&ztdDxu^ zG3ry0PWDHF1L4aIn^SG`4YZ{Dkg$N8diF>5YbE8oFmXwszTJ znm0o=gX!}YWm?@dnB!75XBxEhZ|?c*81{3zyI>oJV|(-+uddtrG-V);h8}^|QxHgtPJb3L7T&4#AiM001F9&zUFlAk1Wj#^HmPHk_oJaz8quTLL8 zR|TY2PaVIYnXbm@Z<{8gL<2;*CZXDTgMa*pf$ex2fNtUk&<;_p>4rfYo~t!&y$R5_ zJzfjooJXN9OzdzP)Yj?_p}RE`Aa4_x<%ME7hHmlTnqylFVRb~RQ>VunbC@5ooM|XiKMM6|L-qO5pomS!Pa2GZ z`>LnAvy4OBPZ<`~?PjBDIZnfm7`kmx3u-!`79~B8F>z#L5`*h%of-G!@)FXt=jY46 zf}M8Du5OuiONV@n8Cr%-E1T1{$(zm|IL2V*=J$-kx_>^So%Q@lTVdO36Ee(cPn zWUi<2D3)2u+{kn#IsHq#Dy#9t>LA)M+RnY&%Dwu>ciY>Kw6-5v-hOmBckJrve=6~A z1gS%9rO;9e50w!xQ`4PHu^lVkj}=!FD4B03HnkF)mJ?cA(OQbuO``bdvq#BnJGr@) z-24<_HT`ug)s=xh@5Ye2`MLWhWPa_~;8X$M%c;jE(Y=lM*o1O#MB)6)1uox@r-qe# zKUX^HgWQ zIs1k-PcH%7T^JcNP$)sSf3EH=jhFV0DZc8No<(_Cx&|{F{K)+19GodI!T$Sr_X!{G z1l6=|S2qru5`;48g=T;IAm=&Lv;cU7o%n1EN{kuEm`~V%lJe1 zuk43U?-t;fCy{@;UlZjI%6Sy_q5PYHUk$Kl%xX}=4F^iOgjPTgG2U`K8s@3IJ2$iJun(Jq_Qz3wF*8KSzmV_I7G zAn5IDe42Wvd%X+RZ|ijmix>d!0rq;GC!-Tm?WYJ0402q9q3Xf|$MaR!1NizG(>AOo zq1E1l#FxQ;a43GX3C|pd*}lx0HY@QE{AuZ{gkEY+^EXTgZ2m^s+dJFXBwP$liymgj zLC7cEeei_>$M|b>@H>?G24x*JE_(+j#MMp&c)_;>FJoEyuTA zjX-CShP%=T-u!v=DFU)03&E2^D4OXcUg-=T>7;U<^zdq2jY?PL?goUFHp;e8wxeXh zLE&fZ{H|7hS35u6%8&nf>%;tk6;;Va{+>T@{X{1@v^05R^5)^LisA!HsT-+JGM)T^ zCtRVMKm|Zx{?k{2PdA5fZWSf4)Ge-s`1HU-RODHz9~V9-TpwA+xhGN-M|hqax}1Z2 zl+^Xb2lB`_O7>f2;OZM6pZefbC$rT1^V|DQO3yBkGH@!8XTkb}z+ Ma}RHS{iVeJ0L<{9 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 b55127e6d5993b9c6cec26f9b43049261bd33b34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 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 b;k`NGvqkZ};MN@S*^boT`wg^@(Bb_LIsn_T 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 b32f0473c4e7c25d317b4b70332b4f2e97fbe990..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 189 zcmX@j%ge<81Xns!vOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!iqQ`(PAw|dFHX$K z%*{#6%uP+sNYzhCEz{3T&&w>y%+J%!FD*&RFU?EQPb?@X)=w@eXwRNmsS$<0qG%}KQ@Vg*{p2*kx8#z$sGM#ds$APWG& C1T(Mz 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 ea379805c558883fff42d22d743072b59c00102f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22884 zcmeHvZE#apmf(AOPv4Ry%d+K9EPr5IHvYhT*0Irr(kCkvT|?wy+5s(s+|&i%gk-1F`|=iGDkq1kMp;MsW2*I(X7QNO`}^r#ca zvWBLpNyL=(fI=1!MovQTKpn&~wbsL8c7f(10f}=ne)30wIV|2^j(24V`d%g2ADaexG1p8FqWH zQ&7@y_;5!D6v9#(!E|(JWYFjLxsQhif`U~F4S2Yhd_yM(1*_LHI5ZgWdV+y5Kh`gp zyhFjD-y4E5o{*5^C1V=l{BGXU=XWOvg}DYp{=ty@xQ9Ouy)%kq_8o-&Cjy83{75k5 z6=zI@nnYmvTYyYbehL_oLM8+TRQXw$PzI(>?PI@3^=f=-zt*Sm>wH?jzE9)RL5hKd z^?oBvvH`*-pApja@Mjv+lBqXCta;Sr$`H(nS?C~BI_5gS!4&Wz&J*(U%ACVII7fJd zBs*|Loxs3UcoUpPI?MpXzvUm1ZineGE^IWfpQtRd_Xu0hO~Z>69fjBPB6Pq z1bAR4A5JtcXx!f(@sIcgotq<5#S2=upW}u&UNE}(kcSJoLxBOmU~=P!;ubj)=9=aG z!9LOmk4-wA4_>JM2CzS0&jT9`1U*m)XkG8~pQsNU8w`X3LxZ(LBO$_k^`7BjzCOW5 z^*ra5a5vO&BH;Dcdxtmx;s;qbJPO@&2cQzSdn{+gl-5b{MbHVp9l*;}JS*o6JE2>2 zluVbs_FX_tiHU}^#Ai z`*4~5f`{Wm$Qk5WWjv3dx{v8oNqwNHP_a~#kM*fTPASx1Dq+g|n&yqfk}yu|snA*} zB}`w_N;PhzUWJXAjEfb=HLeO($~lu-PCwf7H22MrOU_UAS4+6^mfBin>}lrmCHS17 z=IT{StW)Ziu>N`pQ{EE)pQm|s`rh}iS8_|3kCFQOE$GEKGtPz?DBUQfEAJB&pBH9V z>0=tcdubP}hV;b0OJ7_6CZ%Qx3(I{W+_puESKeV2^p2J0S>{o-#G|2RDR2K)2~*xl z{L`YuN|;24higqwP43V?4JU9FtI+rf`G8lQrzg}t75pA&c*AU!cf;4p#eGHN|VcmpW?Bl0zEq0|&lpH$J*^RV{QSR&^X^E`Eu-a<|M z5dL`hd!~#EHA(&G?~*X(T~2Y$t5WoUl25`OmxES(tJr5kckDAwy+VZ!O7%he$H%ov z`;oj8jO&tk>5!CPc|QR!=~m3%3JR_|_HQUHNmy9-(5U2@z)|^B{} z*&oUs*h>aiO_63&oj+$z(k{bTPRA*~cO>L@hK~D19nA@{c^&9r45}(>q8$mk0ne#j z{^8JZ!N7Y4{JV(eB#Og5yRR~W?zrFM3;KDGZ%_ESqeHx3FpPk-=>}b8Fm#m`G@_Do zl@$!b5XZNJ&J+?@pC{xIGzp1V=Nkdl0VG+$92yFFg1Zty*E)ekDVZw+`f)HEN)*3w zVG%Tg{!nnp%avm^3L}CE1#(fi_WQURjM78*-9FNPBlbTbXmd_X)PT6`AM}Cz9^yC@ z`8X8%g)B*&b}IwnP{!nN0~DB`e~>Fd%mn(K*B^BIPry(G4d{%h2r$D#!yJk<0^{-d zK-wqbwi^d8Xo(^j7!uTc(C;4>tf(=GdMNZV=o#f4*bWLloKb9IXka+#hfFXxf-2w> zGRPpkiD;;wga`Z~F5ne1+DVUseqRUb8d7E{QqXst@&YlC(z-rRAV_L8kQ&HT2x@N- zrqHG5P_hy-j*7asTbx5do6sCMG68BrR}R+(K_hB80__*{q$c+Oe~d?RI;DrJz>Ewx zGB}pGQrCbe6wml0Alm&m>bEabpBEHgYQEU~`Zl6IEEbke_gs1I@^e?db@^M-!p2x( zbEL2tlk%1{lqKt2@N6)aQ5ngo{LE1>dFsNc$?%16Jg?|d?#0|oYc8&d7dkKPy|_14 zSQja*i?1oYbo}D+*qX-3n#M2nYIDXiqqP{mrnGv)5&?EfZ$4){YmDjgBf9(_w@?&@>bU%dKaG!|Pq^d2NzcXgvIcMLwv=(Zb?EJwhy`N9p`%enD zer=?3TY(Vw8mQdD$b4u2sO_nk>8Ux>Q!u0~$K<*T z>teRrh^=;}FKXKqGi{nPZHn7+#CTHnsT+HvwvL#oBVy`U%8|>IM{MO$TSd%N5iwOP z?NI?M++V7Ip5I?8r?Pj@_mPn8@T$Q$7!LfOcWi&Kw3DXHJLum+vU&4w8Hneeg$0Q$a~#TG zUfg@6#|+4;@VC4dmgrQYMOaoZK|rIEm218O>`JQFslQNx`bDJ^wfkj5hSU@Yv9hvG z#0=Rmq2%btRW{`T6;4qHQu;wtqo_LL_ClV1lY}YnRm4%Nl0(8mSrP_i(kO_MxD&#S zjXWz#q%k(gB_6ZpF{stYAY7%_Xt7IqBw;SBy2eV1$Ex&|4IN4)E5`7MQ9o(Fpg-y) z#aHAXBEq52s~Ad>lw6b*QXZe?80FLc1ZL_1r!!rEF+hDyFU^rxBAJ4T8u`6GGZ|k-Sc_x%XIh(*#;2HfV5Dgb zJmN@w7HORr*FnBCrXDHZxE|(OHeVScnt3(?pyE$x5_dP3l;CsP(x|Q-0c7lfT&q7+7{V9E& zVMbi*lMUMwCs``=j8rG|-w@V?4$EPg|H4LC!|eG~*bMJ%cr#%gmzT81==#%`65dEu zB#n5G_T~5-KURfMU?Gq4)rX#!<75Lw=mjZGs_9zp8wN3MJQK#AW?xG?c9|bn-9e8? zEh%rLM)C-yQ6$9~rMPQ((jJ|V&b z!QXZ;!2O9d3NUv0#%;a=aZgfCfWtPa_hc2%g86&Q{Vkj&m0XpYX(Y^aEvM{TD@Knd zMjt(H`;KiC_G^4AMlNEIDjcgKRspBSiATmkzY{GcPS2pv=|2T0%ifSPaV%cf{h)d$ zSbkmt{{lE)uN@va8Vq=y-#FCetU7%7&~E22Hxzn#|nEX4W*mJHFJ z0)`VYf*70xqj!Yk@W>rZUyxaw2nuSF%pHX~zQAt-%vj8z=Y*i?8-g}CL4)Qmc&j~p z02~lBg29X42<`!JM-c2Mhd3X!2nH{A=r~wNjt>QWg4ut{8yrEmfsp4IFXSYGZWyJX z8}tOh95nb6FPN}<UiG`@m65TkzFTeqfu*mPH;RDuL+v?;Bp)D|T4WJnhjAooy&;`Iz0Xr?Q zFuuMo@I>hg9{_S*?otuuNzfx5h~4k1Az?ZS1wuhM9G6`sSahM_PGJLJc*ODRz@+6N zRzfthiEb3%0K23y!3BH4Hv$6*e~PxyRaT_P%UDT9B9|MCb;rPXX%c%aPDX+kb_ICv z5Ew1}z6Ta!E)$7|{tB)}4tGP*5QnluvVrlWhZ}@UqJhpbG7O#cyMgD9!3^`FffB9c z+)f0mjZ{!*xa8Ycm=D-Kpw;| z4I0Gh$p{P*PIV`yTs;6>;}Mu+U~EJMnA?s3jCL?2H!$}|5V#$E3yArX!^39|RYtIO z4hI7v4o^k7Yy_y<37H}pkj+KAqL0I^n8O{D+X293CuYbLU0l#6CO|O2SQDm6?m5UW z==%cr0l_z{jEZ|1Qbf*T0t3fL#|1s|7T&6P&w5zWzp=qn5AycQWwwN`R0Z< z*UshaJ+minvYzWY+Z8hvM@+@j+67aUZ0@Ov*lK3>%pQ)~nq#KsIa71oWLsi`w7&Us zlOtxTjF>9pR{Nyxf^O1y!MNxwo8EP0|Kn8M1Y3UBtF-h0*evNN!y`Cx7zz zh2xWh7Y3ool9tM;dY9a+S@wJu0nw{~=8nkJ}Dz`-{ zw=EW}jTLQ*6m428am7knA|);N%-Z~nGf&^MQ@Ir}M|H#j2FsYE{$oddytv|0_+ogL zx$*Q=I9j|Xmbd4;=4*z^ZGdzzzuEIATYu1s6fj*d#jIr!YuQ}I=2?E$f1~k6U##^| zr1j9;_QP}KJqy-vVBdomg0Y2V$t*J}hwW=hm1$cd zKtp9ZmJC#0aV)nsl3P2UyKy$_%)YoKcdC8fQoK;N^UdaH-TqkB{<*6CpV_C!X1;`ZE_y)t62oMEH(+Ic&6s{@Du-Zs1A zU=}}h_EgMLF>k4u*?hg_KeRxKlTa~MTpuZ}pVi$cT`1nZtY-6Ve_yG6QQkOV4z~E!pS7-w#i#XV~kyt6(tR z8mZbk@7x+Msk~BnxiD6;K2oxNw))1=PfFU(?~50dU)p|g`%KQPEn2Yg{4T*!uvl3) z!_V%DRyI$W;$V?3sEriV&ICU$084nurLK!zGldIEoxdU zteVM%0|$7&-hHni!=78Y&s%aj_w5d6Fd0IPPwYK_+Extf;0@xEF9Ud>xI(bn#$?Lf?QAYwYO z^a4X!>(K;l-gJ_Wdwf0&1joq{DQCm~Y)bxp|>32(7sOum3XVJE7dA5|kKp$>mf6&GN`c^i5cpLjx zjvDd8_UxWU>YerU;a2vY#mGf6qww zXxaBnZIuwXwSn$&vbQ#}h&Rzt?XBiAz~3*Vd&=1Ni`q00_@IpLsbD`S$4p?NhD>0h zrXX(4Hqt!}Y;*%w65XPLN}}7a zs%R?>F$*+-EDhaU&=TCB=_%4KSm~ZD(?X^e@mxLTgw6o0Akc-~TWBD7qyE`qW?@^K z{n>ox6AS&UP4kIWjd(Uv>yvynB!5zjRK(M@&{&LQCg7t_&>TIgQk>_MGCahC{*cbrjABa4!{tK@3B55n32I1VStgdL)$31V zgV;qK0Shj;aG|t9o_-W0Ql791`k&BvKzGjvE4PY8i*UM8q|t0SmtvtlL-&El_V;v; zUwRiM>Clocg*5cB<4jVmEYzxoTA7D)<4lZdT-vdQc9ip-fj*+nh%%M)XySYxBz)1U zdT7wu7YKsn&DWvJ=@r}VfkqQe@=^M6=9K{v|=4h#~)$(tZaq+ z5j{!M)I=dmjZ&zqU8T`|TgCYW4e$4W>#vIuRCNskJxnBU9@*0Ad|5p3)wh5nfdAAm z*E&|0A|oY>jCC!b(RqS=Yn_}Q^k9m|gY@4L$!RX9E7I6G^UM!9JV>0*oNkXfsvzb* z53&{Ys|DK)pN1Ws8kiE0VnOp!qenX@=)lo|KN$$YTye9IE-)bL z3mW2f?^`ir+<%1RC!H%J&Q&!OOw3hA*Dnvu9zJv8-08EYV-{z`;*45KW0snTrDmo* zW@-54fyA_J*i84hOvJYXxCyje@b)l6J)#~(6rpVxEC(Q1C0!XthI%K@{UxSdCux$h zBBg=*#o(w*EvgXMH9?z55-j{nfnlO>5At9o;JF)+%;VSM{1c~hhbY$n1q8KEz z9Si*pfcxOFkeN4C_FB;y4eZk<+qvy$x6e7(M~v&^S-Bv|S=UT?7c8X^z+~&Wrn5~k zWAVJPczQQV>9aktO}itTcF&jZ7PG)vIk+*z91RPOhBJE>Et%&=&yG$xrprFIRD9|v z1X(P1JxHWdP37~(^2My2XigU#F<1SfD%RLJ-`EMkg)ERp3zsx(f#Hk=G!K&{iW381 z!Po^&eP+qx@Vtkpf8FV~m+wP5TyMdvEwBiy;D{mJa^YjZabMMse4p}2$64b{nBj`U zOh2l-DUVM+LP>X+DSZWpVM%wXALMo<%t1s+b@p*X1J1oY`@8Ch&7u22K?_NE;lEq2;UUB(^c^+tYpEP)B>);SNE4j2jw(l@#280>ds=5K#@x>Lh@K)3YV-Za8Of@QX1V4}E|BHPEWy1CaMvG1FDAEOaHEZB zB#5}l4VMi(!9-=jWdH!QI_b@|lZWeQ$j+4s@v%B5#jCuw!_Oz{#p4yv1P@Z$T{ zhGi07;+QhEeL8E2g4c|F8DC3J(^`nz03J6Gw_(FiHN(H=-Fh>pV|;L7r(qaZm^!KK3n}S*TvYJc{b+(kHXR` z#V-0XUCI7L1DB0huw(EmK1%$&2no>I@h}PCj!Z%V8izB)KY|D~3K8Hs3Yt<+NG^gy zeNMDL1^PsbD_@7AK+a()*NP8;@Ezjd;3EKu%|`rw1cyX1xuFE@79gt=X;%VB zf_7!o?KAeMxo$P;IjiZP&{gbDHPy5iO34qd?D#An0`TzFC?Dfj`!&dQaP^6WOHNvl z)2Ln@T!_-c1t|^0iWj8xevQunf5yaxC==v0jv8HNE((RmHjvTtPLD#OV1N%ffo5Qw z=ABi8Lr%0dIs3qBIdl?ih^{(?INoHYMI8T@`vFW+D{n=?*$L$a(uj{D!LPU@CD%so zJ;@96$~#fy-jggL!%ZiZ=-TPhD*YmtorwLD7&AqiXc@E(GrJaEO3oECOIDt-;`E#$ zUoUdu46lb;^HqIYCRuKdzKU zAP6VM`Z0JvK|2OXn38!zz7zxFuSwH}3jD_sd&0wI9__6YQuwRk;hH5XJzOhXB4AkH zUHm7v4VO;|FDLj6I7(i>9{pve6E+i9Qc%Gn&k71YVn%^RX;L9}qg0df2CpNdbm|OO zQWV}x<9qY?ytm>?N(x1wmUNuctn^93pq2#nWa`UwU8NS3KT{s?KuW%nlF}P;B_&;d z_N7zvxNc{x3iC!Lp1OoF&} z0{(j7Tzs9>n{*yPa8@o}Oi4EmJk?8i9+!_+P#@}6&iKj2lpRvF&^}&FX_vx^KL%V( zQ8;Rsl&-v=fTNNxrle4C)zt`3tWus87gHXp2QH>C4_!?8l3YwlN88`WBS*Kr#@Jr|L zlX9))wjz##j40fo3z;a(V6EcmEfHE#J|d^FTpMC2Oo}%_^!~vSFjv6wFNbr?;Y5;C zVeVfe)`bA4nLCdFiO%gsfaKwxMu6M0C{T4GwjV(^0=zcG9YpX9f*lA@c;yZw=mC(p zqrstP5IG?g=zw?t-h?rzAtaq(>LG|qD|tlC6?K`h{8i*LI9Xi}YYhMQuz85W_lheS zpJmvTTNiOS#b~z` zi@Hn3i^j#OnweeK_g~!~t=hU+o~Z1n-SP5DIPJ{Nfg>##-nxtHV)+{*`5WWTlIga~ z>cus1dUVBg+4cG$WP~%$f=w`(f*cqO6d?f1DOV|?`Aso<)119&m5UVfaFL>xD&Ful z*l)h2IM0Z+sH=DJ9!`T%SfU~R4EI7N`se{1#cDi#N`+jyQ;#=s> zI`;jhGQdA5pgSAb4+>etONyZ74_e_O1pC2L8o)o)(h&clfrd&ygvJ0`3Ctq#IhszF z_QOKDv)J@ukrwfCJ?5_?sSN}+wz1u{%gwot;Ed%^+ z310rVTdG04N)37L)-v6AN=8E`?l!JD;9x$o(g(71A7yF~cQ6pIxMr--UQyq975BEF~tiUxX@C|0`kJzT0@rM}EZiDUr_+t3 zeBfhG^}Cev5~fIvGVS!>XKsMpC`6?^{j#*KB!CaSI^A6n@S#r|pYD!`&z$UGe$JTW zLvIWl)0W7PMh-sotK{&Rq%S7nLvI*2hGd-+zA1fJ8`>m|M4?330AG_OAAVuTXANt? zk9&)pN86u9o0GIBsYl?)of)7*vc{%pd!g-0O;T>ZA zys+vjiZX7>gH%W!pWG{D%myj$T9%|w%j<0t$ATuvOo%^V@D|{ziQ@zwB(VK4JgQX_ zJ(7k9TIM|{M^D@(@cRjmY9sNeZUU_l1$FX(hUObL`|RTC6q5C#uvzLg`JOYLU^kLE zYgIa;ES~bDYdK|qLY;}M*rLuvys6FKF%xfUc&B(%XOG#5G1>{XXvLeluA4*a9M^~7 z7yvjP4I(~(U=YDDf^Q?>5by{>2u2W`KyVTPZV1Gyk{d-VjNm&6aF^k}i{K1`2>`D0 zbl$Ywzru775Jcx!?gC;+8}0`PUPFKzFZV+Pe~#cs2qqCsA;?7VrwCp_a1jAHYxp;a zy^i3=2yzhM{wpeV{}wUaq7`3MQBAvqak9SUvWVvGXEBzjgwu#!MnJTypCG0*|1t(o zA^0hRD+r!P&ZgKng|!Hsk=&z0h= znZ`fUs3HED*8lIHne({={6_yT^v(qR`q2)Mx$*q9Z|Q!<&oo_cz1kYB*|t#Ga?3N9 zcW|+&HjUR~wvl)}=0EK9_?dLI{xN+Xza&179jat+#wYP(w4)zmHMlX}2iIb2-aml{ zBm6ujcrbQ1fd^w(7J4uO3LcDIIq1QNcwt-iejWApdb%r5^L8VH_$EhJDf256-Bqah zm6=66(+KguDx|xL*uIRzT|*h-wRG1u_T9QR4Fqm(q`O+# zo0~Ax&1TGWbL&nD0=KqS@2A=K%XhjUaNA7phfDSunCrGp1-WkLVBXt#G{!)yrOfSO zf|qK#Hfe8H(OosB+b%8Q4SLM8jik0ByhCHlcXayhP0XFlHap(IUrTpaY3^325wF2f z-)&Gs^4(2H4RN8JfhB7dZ;|tmlROyc2;PoB4bcts-TuS@!MSJCOkh!vGRToL45`O~lB)tTb7IidID zXIVp|qC;8+R+_#8Jp6(uUSD%V~@SC7K{6Z1@*eldZBoZRS5Md+>Nnl5i zjV%?Wb_#9VLB5Xs*bpzuG!D$2hhPrDuMzA(uz&!khFi%Og04mM8fxXffK>c|cogj5 zq5@5SN>%-a%J^GK|F@L!*HraCPzR&b!T(Ct{F>VNcU0@Ise^w&n;8%S~3BcKDC7SZ0JV&FGfl9*CxM;wtqz-Bn9Q-@J#|FAOIvt@%SNhQ+-gRT#IomJE@t-2#7n9fbhZG z9VHP48;`9fR7O)tc4|`n$8_QuQMEhaOlLasOlKM^sU=T49YT}}_@Ydli94&A3St{>Awu8-3RW{gP~#tanJqvOVeY0Q)`kC_veF-yWaW>xr1 z+?J>st5Rq~ygFeYvn#YQ?nu;()g)@iYL$Ca+?jBVxfI$Q-;r>SxhbL}!-TV3Ae{AG zo!YxGkC|v6&c)eyFIUCaanS2uGx7DjE0; zdo}9U$;KHW9!o`&vI&Ukupsh&TDBh+XOod{@Z(2?@VPiI$W=##)H#tCj;4|m(Mj1+ zVD~6)2Skr7{x+&JMh4<=(;@CTPw9_jXV$4Kip|&A_b%t^-u)lI_~8%?>3eqe(tJ zsOYq;&eQ}u4bv&IsU#a_&++3T%nRFzN=IINb~l?$Y0CBoMrE@og#`>Q(=c=R-B5Drhu``NBnu=%Wk{%0 zn9;!~~XscO)!1Sza3WcH|CKO70 zw=J@OR)QlJzXi!Fq)HVC{lBP1iG*m|7%zRe@G};P*SNp1_j$M zZ2`%Uu+6El=~8ntD~kyvSgXyt^&N=vTH%T#<0M@{IoXHi75YxOxD zrEdKl-BlJ&~lEMGh`TO&6aCu)LBY6lrcbAi^eJEXJCXvpmMv|u5vw%%9Q3B z=8&$abtYrjtl`Qd66{|x3)H0x8Cs;0moxM+a+$eoi0CItM0eUtNNKDj)}i&OoPz;{ zr)#U^s_s%fP3DX^HkVm2Jk+7k`vID z3TQ56r{bDE?N~hdEBJE_qUA=I!M`A^ouex9+o0gvL3Myqkc@g2JNPeB4oLCHU5e{{}1YU&-gMkPQoQ*Dt(y^~E>7Id6H;(6;pa z^_Sj$X{})(*Ki=)Z~(nuUBN)iRhO+7tvOSD)>OZE?5)vjqsxa^eSv$X;HP!%`5kq6 zSKR|oQ_j<$_4IET>3Yk2^@feOT9*Sk=l=W7{Wrhyz}B*~@1CtYU+Z4zexv)H)+H|2 zzBk*xcdcdL&9=V{ydPL=8TrIpe}&0Iht9ly=AFp$t{=x%dViG6H}AUcd)s%t>FuVK znziQst507!y0|0nsb6Z#wG3q8>lwJYK9)q z=*f<{oU0@2>R2|eI9K@lu7mmZ?gB$<+z$!V+%OT(&V}UFex{u`?$5tK3 zHVjyhxSCixy5{c8IXYJzodrK}dKS80uQUhuT1I}$5Z6;MYa4rr%e%1u>i#zlEY)Y7 z-Seir-I=pDW$jH%-c`GQ)#NYqL)HIS-w8b^m`KeK^*N}j#{YSt2l$_Z4ESc9`+p~X z7j*vW5!;D6>Q*&1@`T}*U5|9_QQrw#|91fsrh6Mlo-}>vM9zl+YGlyxVXy({+g9oX zG2FKG0ey#9q1hea=;%APE~K9{0{mo=q>iTK&@xo41fI&`VJAWZ zkV1nAS7{FDB8*}n1OT5>IqVdbIVws0E)&DOz_ttLF2lsYac{rWeM?3Mu*HzkodLB8 zuCl`dCth@3rJfX@q(~$&w6fbScuoaCralMUWKrInQ7VC{@;oW-kKHB^Zey*F^Q00i zf;lq+uJJw7yT$dQ^#Fhq6YG4uh44e%|5bt~XFw|e4J)>a^*k<#Gfxst zQ^&|@4&1vrW3E`!QU(xnmW)NDE_tP(CK1MG(d5iop>MrfNjcA0wQ)h)y&0_4S1#44 zvlNsLLD_)DDd&~u806?VYZ@wThLV>iXfg{cYL-#}$v-M-Oi7mnXjv0L}}q~CDLB4FKtB7BV?JS&XG-k#g=AEfJKWUuwj5I$w>euh(ZFR3eo_a zVGtuibXo#mEX~Gw*_2GF48K9PL@@RfNOcAZNJ9C+iIq!f~-1YVp<#W}gE7+T;$ zQg~97-CCpp0#XoY2*)AD12GKQhK~%PV+TwjJ3wgUOh`zLrz9~XotOMN)&3zq7VyoJ zsi2=$crqhJB?wfZX_RRQU`)xTCc(HeGxp7$2qi?h7Dh46N2E{_S%6ht%`*WV)eNQmo)c#iHT4qIm^XbLRTGMc@ZupAfw|e5;1Lfe~ zUG)otuMg%OZgBZ^O*wB**4uN}JFw!MKasb47mwbxH{A;y`RV?(z{yek?q3nsTu;pp0YY)_T#USNdj5Fc=E&I^vbKhttu*n}BH6OlmBJXQ^>(I4B%kGuhHQ&IMW3tEhpaY=U%CWVM{foA|uPNv2$@+ShRrlCCBcHR26HOKa4*}j!$e!A;U^9TOb{%7v9Cm&XkmLn9Pv*vyC znZhZGID(G~4KP!W@^!i(VR%r(EZISLHqzY~8B z3Vzi-;-s!p!%g(-3{Z=Q*%6w2@FX?dV)$Uc9_fRg;copcCpEmwaLYv_?X?2`Rx35! zN#AO7AU!}q+gnex0{t^DHA2xptD})_ME=iq9tptBhgFT3c2{HiR12iH8)zuL-9jO~ zivrQNI~BUyFg$3y-AfG**lzb3k=}2HI(KOEh(mv;N(Zg(*eRqPdY~gklmNpau+uBX zH{XGk`+e&B%;u2P#Zr*s2h2q#wz-|s7()0IgeRz3orH3ftK0^BJ;X;Vp)8>uq*cII zP`iY%G&8rgC^{ zo|=Helnc1Mu2AzB(E@a3FFLE^Xb4-CFxor=5*U!6f4nx%9A^MLxV5$sYD4H`yKJmI zYx0}Zdyb;dV51Z43(q~nsz#F?=UH`I$gvouA*BM0cmV{GR;8BPa-Rc z>0}fO#Islcrw`L`lbG&T(g8@rXQN1`&O(}wVkw+Hr1gQxG>{Qd1TrRK)6~R7+Mr~8 zSV=;ud`g1!d?F610Ckx_SV|?fXahT`Xk!|PP1*pQI0v&G1Mc=KhEuui($-F_^j?-L zWJ+&!QD;F-XTk*3^6NDx4F^|(Y=S_Dh-YlTk3zAmM_2z6P+L?eoB_^%UwMCpa=IWX zDzEyP_rJPwYX0oymoL7Yv$I({yJm06*}Jp$?&YI7`=0l|`etUzIio&rSD) zxkNOnlJOLrDWwfTm1PL3_eRVCY^9lC1dfWJssg%@V+w{N3XX%a@N_hoHU>km5TziL zKmou_2f2iA+X=|37smBw!|sp`*<@6L8u5pXuyxw(D%@{jQa-J*Kms zti5MBj5_OjB;aa(9!{cAcZjm5cnHB+u~*7rR5wye{0%LkFp0?&Ch7rII63P#sK^gv z1G2GrcqiM%Gtp_po_tcoFdvK^83>ID;-*wX+ZB5zN@qDWxxTGo8cKmNb$!4ct$?IZ``C7Ir65fV z_X684&17+Uj;<%C$gJMaq`gDxX+Ep$E7%Dkm0(AnKlw}`?K`NjzWLJN>8`=fZ@zT! zbhrOtP~i;bt`TF5ZqVT0H7&^{~D;fo)4gocnA_$Hjdf_^HuX|{gXG_ey+P+^+DB|V|1SR z*y;eCEF8FcVDUR|4Cb62S!c(Z)4$xd+`sA!uG)eSyi?S5gNbec9uqo2+Vi8M{u(^o zOyV(iNVbG<6FL(|+7^Oy+HkyBVhq74cLdma7?iAst)Wbhr&6#tj7NdPf-u-ZVk}H7$zQ{7YQ6zN=7$tm@6Ib(B@; z>SDTYv$0bYNBK(^e!;=1wF29oMPl`$&8N5p!S&dP-rD2Sj<(Jn->ufcEq+mJjK zmBjxG=pZgcXPEb>Qv45MW?$yBA!erA9Kpl`oJ+KF_@Ymr)=XFV;HC60iQ>u2|KijI ztSsvH_x@J^Af37raLxFQYNX=#O_prnmA`mw{tti(_o}(#0fc%8BwPY%AHh$=$yZMt zyrjA@U%Mk;>%*@L_LhG5!iKoo@XGOmgVcH#nyxmj)wJZSEvt0Ps$coj0C4->_)Al#5`5EzI@#{)`KK#4!7zR9G3=z(xleIroSn*u4m z;>nBlFN6SKhJfPD!lL?4xwqF)&ur9Fq^8FU1wH^hQU2*8&8i*|hatCCth)8jLTZ4f za!rAUKX8bWCKv6HLB`$@ejt#{@MYT*O528u;qVrh}@z85Dj9w_uit z_!CG9dWxbxCQtl^RR4mQe?hFjB3=JNo?Ro){xjM8OVa%-^6W3k;4jJX-x_9gl~ 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 38a54832b012e7c5b73f83196dc288aaafc9924d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2594 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 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 f086af1216d2b396b2c35c0bfdda945fdb7be060..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7816 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 G>VE)x$*U&- 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 90f0532350dd70fb1ba836a4c9c283a97ebf3c3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3470 zcmbVOT})iZ6`t99_n)5y76)5RIv5*!ZCHx`?Yee>h187&6tJxzrOV~sS-5cb-tEj? z@X|%1PSiN6C9A3wsFX@X>I0S2s*(E8$2xhb)E6+8uy0%`QdFsV+ZHOh?n}?i?t<+^ zedtJc&bjk<&YU^tJLfO8wGjj@{BvXG25?`9pK9}0m~9uBWn`dnWMIQ*;c+afZ`>!T zGOhqsEH&#N_hYd}wF24Tco29$4H*F%&ZtK49d$fX`H#}jemK+cEd&vQxe(0NJhwIs z|Fs2QBBK2ZBQ!cXGC*?7ajEW_j!iNSBknXMI@9JY%94>tFNHBmGStyV!E76JKVLkEQQquOw7RPFiy-&L~O6DG>B#xz;j}9 zVmmIGvYbiHvS5LeceP22lB1U6RN0pE;jAar#uULObA!=A7nIGBVKDeZKo#htPqtIcju}Y~FHBUE^*?ByaN^ z)y)jtDv=~()1X(5(K%|vl{A}Zd5~#~#V%Z17=dJ%lO+ZmE==>B;Xp^%iHHGUD6>Ip zo4WH3n?%dWpGexK(jcVm`KW9 zg9(MHa8+*~xT`atu6S3v^2FS0NaymCmZ_&J9!_}zGhqrYY(Sp*@%b;^z=9F!UNbH1Ho^=Y4%A;pVdU<*#on_n;v$|AR(YuzzB$ZrLCgkTnu6a&U(v^57S0S;XJ;gCR{zSh z2zcm*s$(-XRc?=dq}&raLR(Z^aaq|6^&M1HQs`dgzv_gGYEcz(i)sqJ)ed|7pusDZ zMQEm_%I(pje|K-77??S*H&f*pzSE9T0jsE}j-oc??uNx7#1xF)gt>WiQ+X53W0>8$ zOCGIw zv~cJvftI`|lLWt%++B47HgO^F@960gTHk;6#vS|-287YU)vvnw6*hj5*8;lp(|mRkIq5IQlGgz37z4JOKZop%=X}JUlWqHk2Og8}>p| znn~sE;LvFKn3`+0sXIpH&@g0<1!{wpy|9_9z=Rhub5+QIVV*9-QX(j^!wX2HGvEmT z=RCi(ix=L77*7?f=QDF0Qa@NrHfb^@VoP?f0mDCsZV8nFia-9OVgE|oFJ8SJD1}g{ z?%lJ&xAHZ%Gx)WBayZ2RC8|S5K|Q|8RZl^y?d^ zUw;_;-fjO*2sIqo5Vqaie|9r=cInFFXdJeE5!-)TE%}si%aeu!TMdUd8V;`-n+?Zr z_m(0k)_Ax0v*P2XS4s`AyA($?&FgCX>CU%N&Axvk?2kVNxeCz#?6jcTrn^J$4SjIq z*VcOU5U+|v5-)c#500-i!{_ee zAE0Xm)82-P?#IBt?^{-W>i0|BT*3tfQ1$;mLn$hTFOP3RZo;VOD=M=`p(I3S(VcGi z8e~kW3m^3Gf~}Kz2AK#-9u2B9sOm0c^0t0q;>AP*`B5fFk!od?mzPx~~%2}vB zItX_}DafGKf-0M?nv*4-aAH}45m5_@Q*(~Z%{eHS#MIc} zKq4TMF%x*|1vR0p>3DSzn`yZ*EjD`fATe#)>8cPJ4GFazS*l#(&zKAXE5NDl*oMRe zA)*XA@fUBJmU37($n1lUFG45b1#)XBwiRrD5NrqUe-drnineYR6la6D_3WE`Gl7t#z7uF;?k*UiT3|-7RM&6>A*jX486$ICygK)+8AHE1yw_6ad z{|v=GL$!ZJt$#zuOMw9X0bWr`NYGVvThdbFLEOA@vV?%Fo?jdN@Y3&78*S&d#eeDh TxE`Na?JFT5YpESUqzeB9ga?Zh 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/models/__pycache__/__init__.cpython-312.pyc b/apps/crawler/src/crawler_service/models/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 84edf05ae54a98affd5cfcad5bb3332484a6ad2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 191 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 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 eed990fd5eef3c4cdc78c69f8c2418c2aaf03c98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 170 zcmX@j%ge<81Xns!vOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3)G4PAw|dFHX$K z%*{#6%uP+sNYzhCEz{3T&&w>y%+J%!FD*&RFU?EQPb?@X)=w@>?}b5 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 d5767b4b3084cea1ea82c41cdea9e1ba753a9083..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8068 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 +}) { + 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" + ? "sourcing en cours" + : campaign.discoveryStatus === "completed" + ? "sourcing 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} + +
+
+
+

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.contactId ? ( + + {prospect.fullName} + + ) : ( + {prospect.fullName} + )} + {[prospect.headline, prospect.companyName].filter(Boolean).join(" · ") || "Fonction à confirmer"} +
    + {prospect.eligible ? `${prospect.score ?? 0}/100` : prospect.state === "excluded" ? "exclu" : "candidat"} +
    +
    + {prospect.linkedinUrl ? : null} + {prospect.channels.email.value ? : null} + {prospect.channels.whatsapp.value ? : 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 "recherche terminée"; + 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"; +} 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..7347321 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}
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 - -
- -
- - - - - -