From c09e4a0392a7277e22b5aaa4a907a3209fd934dd Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Tue, 4 Aug 2026 21:45:57 +0200 Subject: [PATCH 001/169] 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 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 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 - -
- -
- - - - - -
+
État :
diff --git a/design/noosphere/screen-prospects.html b/design/noosphere/screen-prospects.html new file mode 100644 index 0000000..4cc72ab --- /dev/null +++ b/design/noosphere/screen-prospects.html @@ -0,0 +1,49 @@ + +Prospects — Noosphere
IgnitionAI / ProspectsSL

Prospects

Une seule identité, quelle que soit la manière dont Noosphere l'a découverte.

+

428 prospects

Mis à jour il y a 2 min
ProspectOrigineICPCanauxAvis IAProchaine action
Claire MartinDirectrice juridique · Groupe AltaviaMixte92 / 100
in@
Commentaire explicite + besoin confirméRépondre
Lucas PerrinAssocié · Perrin & Métral AvocatsOutbound88 / 100
in@wa
Signal recrutement et équipe distribuéeRelance J+3
Anaïs de La Rochefoucauld-MontbelResponsable conformité réglementaire internationale · Kerdonis ConseilInbound76 / 100
in
A enregistré 3 contenus ; identité fiableObserver
+
État :
diff --git a/design/noosphere/screen-today.html b/design/noosphere/screen-today.html new file mode 100644 index 0000000..9211e58 --- /dev/null +++ b/design/noosphere/screen-today.html @@ -0,0 +1,143 @@ + + + + + + + Aujourd'hui — Noosphere + + + +
+ +
+
IgnitionAI / Aujourd'huiSL
+
+

Aujourd'hui

Le système travaille. Voici ce qui mérite réellement ton attention.

Configuration
+ + +
+
Inbound actif

Post LinkedIn demain à 08:40

12 SIGNAUX PARTAGÉS
Outbound actif

5 campagnes · prochaine passe 06:00

+
Contenus cette semaine4
Prospects activés61
Conversations chaudes8
Appels réservés3
+

À traiter

2 actions
Réponse avec demande de prix

Claire Martin · LinkedIn · issue du post “RAG juridique sécurisé”

Compte email à reconnecter

2 campagnes isolées ; LinkedIn et Inbound continuent normalement.

Prochains résultats

Système sain
Publication LinkedIn

Demain, 08:40 · “Pourquoi les copilotes RAG échouent en production”

18 h
Appel MGPP Avocats

Demain, 14:30 · attribué Inbound + Outbound

32 h
+
+ + + + + + +
État :
+
+
+
+ + + + diff --git a/docs/architecture/NOOSPHERE_EXPERIENCE_ARCHITECTURE.md b/docs/architecture/NOOSPHERE_EXPERIENCE_ARCHITECTURE.md new file mode 100644 index 0000000..c3fbd61 --- /dev/null +++ b/docs/architecture/NOOSPHERE_EXPERIENCE_ARCHITECTURE.md @@ -0,0 +1,322 @@ +# Noosphere — architecture produit et expérience Inbound ↔ Outbound + +> Statut : architecture et maquettes statiques à valider. Aucun code de +> production ni comportement provider n'est livré par ce package. + +## 1. Phrase produit + +Noosphere transforme une offre et un ICP en demande créée, prospects activés, +conversations qualifiées et rendez-vous attribués, sans demander à +l'utilisateur de piloter chaque étape. + +La preuve produit ultime reste : + +> « J'ai donné mon offre à Noosphere ; les contenus et campagnes tournent, et +> je vois les conversations et les appels qu'ils ont générés. » + +## 2. Diagnostic AS-IS + +Le socle Outbound possède déjà les objets et surfaces nécessaires : offre, ICP, +campagnes, prospects, threads multicanaux, calendrier, pipeline, policies et +jobs durables. La navigation actuelle simplifiée demeure cependant centrée sur +la prospection. Ajouter une seconde sidebar Inbound reproduirait le problème +historique : deux produits côte à côte et un utilisateur chargé de reconstruire +leur relation. + +La cible conserve le monolithe modulaire, les ports provider, PostgreSQL, les +workers et les règles de sécurité. Elle change le modèle mental et ajoute les +contextes Content Inbound et Attribution sans dupliquer CRM ou Conversations. + +## 3. Modèle mental : le Noosphere Axis + +```mermaid +flowchart LR + I[Inbound\nCréer la demande] --> S[Symbiose\nTransformer les signaux] + S --> O[Outbound\nActiver la demande] + O --> C[Conversations] + I --> C + C --> A[Appels attribués] + A --> L[Apprentissage] + L --> I + L --> O +``` + +Le contrôle à trois positions est une **lentille de lecture** : + +| Lens | Question répondue | Objets dominants | Action primaire | +|---|---|---|---| +| Inbound | Que publions-nous et quelle demande créons-nous ? | stratégie, idées, assets, publications, interactions | créer une idée | +| Symbiose | Quels contenus produisent des signaux exploitables ? | signaux, identités, attribution, handoffs | ouvrir le signal prioritaire | +| Outbound | Quels ICP et campagnes activent le marché ? | ICP, entreprises, prospects, campagnes, séquences | lancer un ICP | + +Changer de lens n'a aucun effet métier. Les actions de pause, cadence, +publication et envoi sont explicites et séparées. + +## 4. Information architecture + +### Navigation principale partagée + +1. **Aujourd'hui** — santé des deux moteurs et attention requise ; +2. **Activité** — surface pilotée par le Noosphere Axis ; +3. **Prospects** — identités et qualification, quelle que soit leur origine ; +4. **Conversations** — LinkedIn, email et WhatsApp, campagne ou hors campagne ; +5. **Appels** — rendez-vous et attribution au contenu/campagne. + +Configuration reste dans le menu workspace/utilisateur et regroupe offre, +ICP, comptes, autonomie, agenda et connaissance. Desktop et mobile utilisent +les cinq mêmes destinations, dans le même ordre. + +### Routes cibles + +| Route | Surface | Compatibilité | +|---|---|---| +| `/w/:workspace` | Aujourd'hui | remplace le cockpit centré Outbound | +| `/w/:workspace/activity?lens=…` | Activité | nouvelle surface canonique | +| `/w/:workspace/prospects` | Prospects | conservée | +| `/w/:workspace/inbox` | Conversations | conservée | +| `/w/:workspace/appointments` | Appels | conservée | +| `/w/:workspace/settings` | Configuration | conservée | +| `/campaigns` | redirection vers `activity?lens=outbound` | filtres historiques conservés | +| `/content/*` | redirection vers `activity?lens=inbound` | nouvelles routes contextuelles | +| `/pipeline` | vue avancée depuis Appels | non primaire | + +## 5. Parcours critiques + +### 5.1 Premier résultat + +```mermaid +flowchart TD + A[Configuration de l'offre] --> B[Lancer un ICP] + B --> C[ICP et campagnes Outbound actifs] + B --> D[Stratégie Inbound proposée] + D --> E[Idées et contenus planifiés] + C --> F[Conversations] + E --> G[Interactions et signaux] + G --> H[Prospects attribués] + H --> F + F --> I[Setter qualifie] + I --> J[Appel réservé et attribué] +``` + +### 5.2 Consultation quotidienne + +```mermaid +flowchart LR + A[Aujourd'hui] --> B{Exception ?} + B -->|Non| C[Voir la prochaine publication, campagne et appel] + B -->|Oui| D[Ouvrir la ressource concernée] + C --> E[Activité] + E --> F[Inbound] + E --> G[Symbiose] + E --> H[Outbound] +``` + +### 5.3 Engagement vers revenu + +```mermaid +sequenceDiagram + participant P as Provider social + participant E as Engagement ingestion + participant A as Attribution + participant C as CRM + participant D as Decision engine + participant M as Conversations + participant K as Calendar + P->>E: commentaire, réaction ou mention + E->>A: interaction normalisée et idempotente + A->>C: identité résolue ou incertaine + A->>D: signal prouvé + D-->>M: aucune action ou action autorisée + M->>K: réservation après qualification + K-->>A: appel confirmé et source attribuée +``` + +## 6. Inventaire des écrans + +| Écran | Route | But | États obligatoires | P | +|---|---|---|---|---| +| Aujourd'hui | `/w/:workspace` | comprendre la santé en moins de 10 secondes | vide, loading, erreur, succès, stale | P0 | +| Activité Inbound | `/activity?lens=inbound` | gérer stratégie, idées et publications | vide, loading, erreur, succès | P0 | +| Activité Symbiose | `/activity?lens=symbiosis` | convertir signaux en conversations attribuées | vide, loading, erreur, succès | P0 | +| Activité Outbound | `/activity?lens=outbound` | lancer un ICP et suivre les campagnes | vide, loading, erreur, succès | P0 | +| Prospects | `/prospects` | voir origine, preuve et prochaine action | vide, loading, erreur, succès | P0 | +| Conversations | `/inbox` | lire/répondre sur tous les comptes | vide, loading, erreur, succès, reconnect | P0 | +| Appels | `/appointments` | prendre les appels et comprendre leur source | vide, loading, erreur, succès | P0 | +| Configuration | `/settings` | corriger le prochain prérequis | vide, loading, erreur, succès | P0 | + +Les détails contenu, campagne, prospect et attribution utilisent une route ou +un drawer sérialisé dans l'URL. Ils ne deviennent pas des destinations +principales supplémentaires. + +## 7. Contrats de composants + +### `NoosphereAxis` + +- Entrées : `lens`, compteurs par moteur, santé, URL courante. +- Sortie : navigation GET vers la même ressource avec un autre `lens`. +- Interdit : mutation, pause, changement de policy ou de budget. +- Accessibilité : `role=tablist`, trois boutons, labels visibles, flèches + clavier, `aria-selected`, focus contrasté. + +### `EngineStatusBar` + +- affiche Inbound et Outbound indépendamment : actif, ralenti, suspendu, + dégradé ; +- donne la prochaine action et sa date ; +- ne fusionne jamais deux erreurs provider en une erreur globale. + +### `AttributionJourney` + +- montre source → interaction → identité → conversation → appel ; +- distingue preuve, inférence et attribution inconnue ; +- ouvre chaque preuve résoluble ; +- n'invente jamais un lien causal à partir de la seule proximité temporelle. + +### `AttentionList` + +- contient seulement les éléments nécessitant réellement une intervention ; +- trie par risque puis ancienneté ; +- chaque ligne possède une action de récupération unique. + +## 8. Modèle de domaine cible + +### Contextes + +| Contexte | Responsabilité | Réutilisé depuis Outbound | +|---|---|---| +| Strategy | offre, ICP, claims et voix | oui | +| Content Inbound | idées, briefs, assets et publications | nouveau | +| Engagement | interactions provider normalisées | nouveau | +| Attribution | relations prouvées entre touchpoints et outcomes | nouveau | +| Campaigns | activation Outbound | oui | +| CRM | entreprises, contacts, signaux et suppression | oui | +| Conversations | threads, messages et Setter | oui | +| Pipeline | appels et opportunités | oui | +| Operations | jobs, santé, audit et attention | oui | + +```mermaid +erDiagram + WORKSPACE ||--o{ GROWTH_STRATEGY : owns + GROWTH_STRATEGY ||--o{ CONTENT_IDEA : guides + CONTENT_IDEA ||--o{ CONTENT_ASSET : becomes + CONTENT_ASSET ||--o{ PUBLICATION : schedules + CHANNEL_ACCOUNT ||--o{ PUBLICATION : sends + PUBLICATION ||--o{ SOCIAL_INTERACTION : receives + SOCIAL_INTERACTION }o--o| CONTACT : resolves_to + CONTACT ||--o{ PROSPECT_SIGNAL : accumulates + CONTACT ||--o{ CONVERSATION : participates + CAMPAIGN ||--o{ CONVERSATION : may_source + CONVERSATION ||--o| APPOINTMENT : produces + ATTRIBUTION_EDGE }o--|| PUBLICATION : from + ATTRIBUTION_EDGE }o--o| SOCIAL_INTERACTION : through + ATTRIBUTION_EDGE }o--o| CONTACT : identifies + ATTRIBUTION_EDGE }o--o| CONVERSATION : influences + ATTRIBUTION_EDGE }o--o| APPOINTMENT : converts +``` + +### Invariants + +1. Une lens n'est pas persistée comme policy métier. +2. Une publication capture stratégie, contenu, compte et policy versionnés. +3. Une interaction provider est idempotente par compte et référence externe. +4. Une identité incertaine ne fusionne jamais automatiquement deux contacts. +5. Une réaction seule ne déclenche jamais un message. +6. Une attribution causale doit contenir une preuve ou rester `unknown`. +7. Toute réponse humaine arrête les actions Setter concurrentes. +8. Un appel est réservé une seule fois et conserve toutes ses sources. + +## 9. Projections et API + +| Méthode | Endpoint | Usage | Mutation métier | +|---|---|---|---| +| GET | `/api/v1/workspace/growth-overview?lens=` | Aujourd'hui et santé | non | +| GET | `/api/v1/activity?lens=&cursor=` | feed Inbound/Symbiose/Outbound | non | +| GET | `/api/v1/content/calendar` | calendrier et statuts | non | +| POST | `/api/v1/content/ideas` | idée manuelle | oui, idempotency key | +| POST | `/api/v1/content/assets/:id/schedule` | planifier | oui, policy gate | +| GET | `/api/v1/attribution/journeys` | conversions et preuves | non | +| GET | `/api/v1/prospects?origin=` | CRM partagé | non | +| GET | `/api/v1/conversations?origin=` | inbox partagée | non | +| GET | `/api/v1/appointments?origin=` | appels attribués | non | + +Les projections sont workspace-scoped côté serveur. `lens` est un enum de +présentation et n'est accepté par aucun endpoint de commande. + +## 10. Product Truth Contracts + +### PTC-1 — Changer de lens sans toucher au moteur + +- Départ : un crawl Outbound et une publication Inbound sont en cours. +- Action : ouvrir successivement Inbound, Symbiose puis Outbound. +- Résultat observable : les données visibles changent ; les deux operation IDs, + leurs leases et prochaines actions restent identiques. +- Échecs : job annulé, relancé, dupliqué, disparu ou compteur réinitialisé. +- Interdits : mocks de jobs, modification SQL, état local fabriqué. +- E2E futur : scénario navigateur + base réelle + workers actifs. + +### PTC-2 — LinkedIn Content Inbound vers un signal exploitable + +- Départ : offre, ICP et compte LinkedIn sains. +- Action : une publication est planifiée puis reçoit une interaction réelle. +- Résultat observable : publication provider résoluble, interaction unique, + identité qualifiée ou incertaine, signal CRM et attribution affichée. +- Première continuation : aucune action, conversation assistée ou activation + Outbound explicitement justifiée par la policy. +- Échecs : faux post, commentaire manquant, doublon, attribution inventée, + DM déclenché par un simple like. + +### PTC-3 — Offre vers appel attribué + +- Départ : workspace prêt et agenda connecté. +- Action : lancer un ICP. +- Résultat observable : moteurs actifs, conversations visibles et rendez-vous + confirmé avec source Inbound, Outbound, mixte ou inconnue. +- Échecs : intervention cachée, job perdu en navigation, rendez-vous dupliqué, + source obligatoire inventée. + +## 11. Migration par tranches après validation visuelle + +1. introduire `NoosphereAxis` et les routes de lens sans comportement métier ; +2. construire les projections Aujourd'hui et Activité depuis les données + Outbound existantes ; +3. migrer campagnes vers la lens Outbound ; +4. ajouter stratégie, idées, assets et publication LinkedIn ; +5. ajouter Engagement et Attribution ; +6. enrichir Prospects, Conversations et Appels avec `origin` ; +7. seulement alors redécouper et publier les issues. + +## 12. Guardian UX et architecture + +- aucune nouvelle destination primaire sans retirer ou fusionner une autre ; +- mobile et desktop partagent cinq destinations et le même ordre ; le libellé + compact `Messages` représente `Conversations` sur les écrans étroits ; +- chaque écran P0 possède empty/loading/error/success ; +- le bloc `:root` des maquettes est byte-identical ; +- toute action d'exécution contient un verbe explicite et une conséquence ; +- le Noosphere Axis ne peut importer ni appeler une commande application ; +- un KPI sans décision associée est supprimé ; +- aucune preuve produit n'est revendiquée avant exécution des PTC ; +- le backlog gelé ne peut être publié avant validation de la galerie HTML. + +## 13. Système visuel et accessibilité + +- direction V1 : interface claire, navigation bleu nuit et accent lime ; +- mode sombre complet : volontairement hors V1 pour ne pas doubler la surface + de validation avant d'avoir stabilisé le produit ; +- ratio `text-primary` sur blanc : `17.83:1` ; +- ratio `text-secondary` sur blanc : `7.89:1` ; +- ratio `text-muted` sur blanc : `5.41:1` ; +- ratio `accent-fg` sur accent : `12.69:1` ; +- ratio blanc sur navigation : `18.75:1` ; +- aucun état n'est indiqué par la couleur seule : label, icône ou texte visible + accompagne succès, attention et erreur ; +- le focus clavier utilise un contour lime de trois pixels ; +- la réduction de mouvement doit désactiver les animations décoratives ; +- la largeur mobile de référence est `390px`, sans défilement horizontal de la + page. Les pipelines internes peuvent défiler dans leur propre conteneur. + +## 14. Artefacts de revue + +La galerie est située dans [`design/noosphere/`](../../design/noosphere/). +Les fichiers sont des contrats statiques, pas des composants réutilisables ni +du code de production. diff --git a/docs/architecture/NOOSPHERE_PRODUCT_ARCHITECTURE.md b/docs/architecture/NOOSPHERE_PRODUCT_ARCHITECTURE.md index 08696a2..8700fa3 100644 --- a/docs/architecture/NOOSPHERE_PRODUCT_ARCHITECTURE.md +++ b/docs/architecture/NOOSPHERE_PRODUCT_ARCHITECTURE.md @@ -1,7 +1,10 @@ # Noosphere — architecture produit Outbound + Content Inbound Date de décision : 2026-08-20 -Statut : cible d'architecture et backlog prêts à être découpés en issues +Statut : remplacé pour l'expérience et la navigation par +[`NOOSPHERE_EXPERIENCE_ARCHITECTURE.md`](./NOOSPHERE_EXPERIENCE_ARCHITECTURE.md). +Ce document reste la référence détaillée pour les agrégats Content Inbound, +les ports provider et les contraintes par canal. Baseline analysée : `b8efbf8424ebc1c5c6f86f48a0a68d70d63a6652` ## 1. Décision produit diff --git a/docs/architecture/adr/ADR-011-noosphere-axis-navigation-lens.md b/docs/architecture/adr/ADR-011-noosphere-axis-navigation-lens.md new file mode 100644 index 0000000..b7ffa97 --- /dev/null +++ b/docs/architecture/adr/ADR-011-noosphere-axis-navigation-lens.md @@ -0,0 +1,61 @@ +# ADR-011 — Le Noosphere Axis est une lentille, pas une commande d'exécution + +- Statut : accepté +- Date : 2026-08-20 +- Décideur : Salim Laimeche +- Validation : expérience et galerie approuvées le 2026-08-20 + +## Contexte + +Noosphere réunit deux moteurs qui peuvent et doivent tourner simultanément : + +- Content Inbound crée et capte la demande par les contenus et interactions ; +- Outbound active une demande ciblée par l'ICP, le sourcing et les campagnes. + +Un slider linéaire peut suggérer à tort que déplacer le curseur vers Inbound +éteint Outbound, ou qu'il modifie immédiatement un budget et des envois. Cette +ambiguïté serait dangereuse dans un produit autonome. + +## Décision + +Le `NoosphereAxis` comporte trois positions : `inbound`, `symbiosis` et +`outbound`. Il filtre les projections et change la surface visible. Il ne crée, +ne suspend, ne relance et ne reconfigure aucun job. + +- `inbound` montre stratégie, idées, contenus, calendrier et engagement ; +- `symbiosis` montre signaux, attribution, passages vers le CRM et appels ; +- `outbound` montre ICP, sourcing, campagnes, séquences et qualification. + +Les deux moteurs continuent selon leurs propres policies. Une modification de +cadence, budget, compte ou autonomie utilise une commande explicite dans +Configuration ou dans le détail de la campagne. + +## Conséquences + +- la position est sérialisée dans `?lens=inbound|symbiosis|outbound` ; +- le contrôle est un groupe de trois boutons accessible au clavier, stylé comme + un axe, et non un `input[type=range]` imprécis ; +- aucune route `PATCH` ou commande métier ne correspond au changement de lens ; +- chaque élément transversal porte son moteur source et son attribution ; +- les écrans gardent Prospects, Conversations et Appels communs aux moteurs. + +## Alternatives rejetées + +### Slider de répartition 0–100 + +Rejeté en V1 : il expose un faux niveau de contrôle, car un pourcentage ne se +traduit pas directement en contenus, quotas LinkedIn, emails, coût ou appels. + +### Deux applications séparées + +Rejeté : cela dupliquerait prospects, conversations, configuration, agenda et +attribution, précisément là où Noosphere doit produire sa valeur. + +### Un unique dashboard sans perspective + +Rejeté : mélanger tous les objets recréerait la surcharge cognitive actuelle. + +## Preuve future + +Un test E2E doit démontrer que changer trois fois de lens pendant un job actif +ne modifie ni le statut, ni la lease, ni la prochaine action de ce job. diff --git a/docs/product/NOOSPHERE_BACKLOG.md b/docs/product/NOOSPHERE_BACKLOG.md index f7db602..07caa5f 100644 --- a/docs/product/NOOSPHERE_BACKLOG.md +++ b/docs/product/NOOSPHERE_BACKLOG.md @@ -1,843 +1,442 @@ -# Noosphere — backlog Outbound + Content Inbound +# Noosphere — backlog produit validé Date : 2026-08-20 -Baseline : `b8efbf8424ebc1c5c6f86f48a0a68d70d63a6652` -Architecture : [`NOOSPHERE_PRODUCT_ARCHITECTURE.md`](../architecture/NOOSPHERE_PRODUCT_ARCHITECTURE.md) - -## Règles du backlog - -- Chaque ticket est une tranche verticale démontrable : données, cas d'usage, - API, UI, audit et tests lorsque ces couches sont nécessaires. -- Les tickets sont publiables dans l'ordre de dépendance indiqué. -- `P0` livre LinkedIn Content Inbound et le nouveau produit Noosphere. -- `P1` étend le moteur à X et à la vidéo verticale. -- `P2` branche YouTube Shorts, TikTok Shorts et l'optimisation cross-channel. -- Un canal n'est jamais déclaré livré avant son canary réel. -- Les issues Outbound existantes #15, #16 et #17 restent dans le backlog ; elles - ne sont ni dupliquées ni masquées par le renommage. - -## Vue d'ensemble - -| ID | Priorité | Wave | Titre | Bloqué par | -|---|---|---|---|---| -| NOO-001 | P0 | 0 | Adopter l'identité Noosphere sans casser les URLs | — | -| NOO-002 | P0 | 0 | Afficher Outbound et Inbound dans le cockpit Aujourd'hui | NOO-001 | -| PUB-001 | P0 | 0 | Exposer les capacités sociales réelles de chaque compte | NOO-001 | -| INB-001 | P0 | 0 | Publier une stratégie éditoriale liée à l'offre et à l'ICP | NOO-001 | -| INB-002 | P0 | 1 | Capturer et rechercher des idées sourcées | INB-001 | -| INB-003 | P0 | 1 | Transformer une idée en brief éditorial prouvé | INB-002 | -| INB-004 | P0 | 1 | Générer, critiquer et versionner un contenu canonique | INB-003 | -| PUB-002 | P0 | 1 | Planifier une publication durable et idempotente | PUB-001, INB-004 | -| ENG-001 | P0 | 1 | Normaliser les interactions sociales | PUB-001 | -| ATT-001 | P0 | 1 | Attribuer contenu, engagement, conversation et call | ENG-001 | -| LI-001 | P0 | 1 | Qualifier les comptes LinkedIn Unipile pour la publication | PUB-001 | -| LI-002 | P0 | 1 | Produire un post LinkedIn non générique | INB-004, LI-001 | -| LI-003 | P0 | 1 | Publier et reprendre un post LinkedIn texte | PUB-002, LI-002 | -| LI-004 | P0 | 1 | Publier images, documents et carrousels LinkedIn | LI-003, MED-001 | -| LI-005 | P0 | 1 | Synchroniser posts et métriques LinkedIn | LI-003 | -| LI-006 | P0 | 2 | Centraliser commentaires et réponses LinkedIn | ENG-001, LI-005 | -| LI-007 | P0 | 2 | Convertir un engagement LinkedIn en signal CRM | ATT-001, LI-006 | -| LI-008 | P0 | 2 | Exécuter la cadence LinkedIn en autopilote borné | LI-003, LI-006 | -| LI-009 | P0 | 2 | Prouver le parcours LinkedIn par un canary réel | LI-007, LI-008 | -| MED-001 | P1 | 1 | Gérer une bibliothèque média et un brand kit | INB-001 | -| MED-002 | P1 | 3 | Dériver un contenu sans copier entre canaux | INB-004, MED-001 | -| MED-003 | P1 | 3 | Rendre une vidéo verticale 9:16 reproductible | MED-002 | -| X-001 | P1 | 3 | Connecter X et afficher scopes, coûts et capacités | PUB-001 | -| X-002 | P1 | 3 | Publier posts, threads et médias sur X | X-001, PUB-002, MED-001 | -| X-003 | P1 | 3 | Synchroniser mentions, réponses, métriques et attribution X | X-002, ENG-001, ATT-001 | -| YT-001 | P2 | 4 | Connecter une chaîne YouTube et uploader en privé | PUB-001, MED-003 | -| YT-002 | P2 | 4 | Publier un Short avec traitement et scheduling durables | YT-001, PUB-002 | -| YT-003 | P2 | 4 | Synchroniser commentaires, rétention et attribution YouTube | YT-002, ENG-001, ATT-001 | -| TT-001 | P2 | 4 | Connecter TikTok et livrer un upload brouillon conforme | PUB-001, MED-003 | -| TT-002 | P2 | 4 | Publier un Short TikTok avec suivi du statut | TT-001, PUB-002 | -| TT-003 | P2 | 4 | Synchroniser les métriques TikTok accessibles et l'attribution | TT-002, ATT-001 | -| CC-001 | P1 | 3 | Unifier le calendrier éditorial cross-channel | PUB-002, LI-003, X-002 | -| CC-002 | P2 | 5 | Piloter la performance du contenu jusqu'au revenu | ATT-001, LI-007, X-003, YT-003, TT-003 | -| CC-003 | P1 | 2 | Relier automatiquement signaux Inbound et moteur Outbound | LI-007 | -| OPS-001 | P0 | 2 | Réconcilier publications, retries et actions concurrentes | LI-003, PUB-002 | -| AI-160 | P2 | 5 | Évaluer continuellement la qualité éditoriale | LI-005, CC-002 | - -## Wave 0 — identité et fondations - -### NOO-001 — Adopter l'identité Noosphere sans casser les URLs - -## What to build - -Faire de Noosphere le nom produit visible dans le shell, les métadonnées, la -documentation et les notifications, tout en conservant les routes et variables -techniques historiques par compatibilité. Produire l'inventaire explicite des -éléments à migrer avant un éventuel renommage du dépôt GitHub. - -## Acceptance criteria - -- [ ] Le nom et le logo texte Noosphere sont cohérents sur login, onboarding, - shell, emails système, erreurs et manifests. -- [ ] Les URLs `/w/:workspace/*` existantes continuent de fonctionner. -- [ ] Aucun nom de variable, table ou job n'est renommé sans migration et - compatibilité documentée. -- [ ] Les tests de navigation et snapshots ne contiennent plus un mélange de - marques visible par l'utilisateur. -- [ ] L'inventaire du renommage de dépôt couvre CI, compose, images, webhooks, - domaines, liens GitHub et runbooks. -## Blocked by +Architecture validée : +[`NOOSPHERE_EXPERIENCE_ARCHITECTURE.md`](../architecture/NOOSPHERE_EXPERIENCE_ARCHITECTURE.md) + +Contrat visuel : [`design/noosphere/`](../../design/noosphere/) + +Décision structurante : +[`ADR-011`](../architecture/adr/ADR-011-noosphere-axis-navigation-lens.md) + +> **Statut : prêt à implémenter.** Le Noosphere Axis, les huit écrans P0 et le +> parcours Offre/ICP → moteurs actifs → conversations → appels sont validés. +> Les tickets ci-dessous remplacent l'ancien backlog pré-maquettes. Ils ne +> doivent être publiés sur GitHub que dans l'ordre des lots. + +## 1. Résultat produit attendu + +Noosphere doit rendre vrai ce parcours sans intervention quotidienne : + +1. l'utilisateur décrit son offre et lance un ICP ; +2. Outbound crée et exécute les campagnes éligibles ; +3. Inbound propose, rédige, planifie et publie les contenus LinkedIn ; +4. Symbiose transforme les engagements prouvés en signaux exploitables ; +5. les conversations LinkedIn, email et WhatsApp restent visibles et + répondables ; +6. le Setter qualifie selon une policy déterministe ; +7. l'utilisateur récolte les appels et comprend leur origine. + +Changer la position du Noosphere Axis ne modifie jamais un job, une cadence, +une policy ou un compte. + +## 2. Definition of Done commune + +Chaque ticket P0 doit : + +- livrer une tranche verticale démontrable, pas seulement une table ou un + composant ; +- préserver l'isolation workspace, le RBAC, l'audit et l'idempotence ; +- exposer les états `loading`, `empty`, `error` et `success` ; +- conserver les filtres, drawers et retours dans l'URL ; +- fonctionner à 390 px et 1440 px selon la galerie validée ; +- ne jamais perdre un job lorsqu'une page est quittée ; +- distinguer intention, tentative, acceptation provider, livraison et réponse ; +- vérifier suppression, quota, compte, fenêtre et policy juste avant tout effet + externe ; +- inclure tests de contrat, intégration PostgreSQL et parcours navigateur ; +- rattacher sa preuve produit à un Product Truth Contract ; +- rester en provider simulé tant qu'un canary réel borné n'est pas autorisé. + +## 3. Travail existant à conserver + +État GitHub vérifié le 2026-08-20 : + +| Issue | Statut kanban | Rôle dans la cible | +|---|---|---| +| [#15 PERF-001](https://github.com/IgnitionAI/noosphere/issues/15) | In progress | benchmark VPS ; rebaseline après ajout d'Inbound | +| [#16 BUG crawler](https://github.com/IgnitionAI/noosphere/issues/16) | Todo | progression fiable des jobs de recherche | +| [#17 AI-150](https://github.com/IgnitionAI/noosphere/issues/17) | In progress | prochaine décision durable partagée par Outbound et Setter | + +Ces issues ne sont pas dupliquées. La clôture de #17 est un prérequis au lot 1 ; +#16 doit être corrigée avant le premier PTC de recherche ; #15 reçoit une +seconde campagne de mesure après `LNK-102`. + +## 4. Vue d'ensemble + +| ID | P | Lot | Titre | Taille | Dépendances | +|---|---:|---:|---|---:|---| +| NOO-101 | P0 | 0 | Installer le Noosphere Axis et les routes compatibles | M | — | +| NOO-102 | P0 | 0 | Projeter la santé des deux moteurs | L | NOO-101 | +| NOO-103 | P0 | 0 | Livrer le shell et la page Aujourd'hui | M | NOO-102 | +| OPS-101 | P0 | 0 | Unifier les exceptions réellement actionnables | M | NOO-102 | +| OUT-101 | P0 | 1 | Migrer les campagnes vers Activité Outbound | L | NOO-101, #17 | +| CRM-101 | P0 | 1 | Unifier les prospects et leur origine | M | NOO-101 | +| CON-101 | P0 | 1 | Stabiliser l'inbox multicanale canonique | L | NOO-101, #17 | +| CALL-101 | P0 | 1 | Livrer la surface Appels orientée résultat | M | NOO-101 | +| CFG-101 | P0 | 1 | Transformer Settings en Configuration guidée | M | NOO-101 | +| STR-101 | P0 | 2 | Dériver une stratégie Inbound de l'offre et de l'ICP | L | CFG-101 | +| IDE-101 | P0 | 2 | Rechercher des idées sourcées et dédupliquées | L | STR-101, #16 | +| CNT-101 | P0 | 2 | Générer et critiquer un contenu non générique | XL | IDE-101 | +| PUB-101 | P0 | 2 | Planifier une publication durable | XL | CNT-101, CFG-101 | +| LNK-101 | P0 | 2 | Publier un post LinkedIn texte via capacité observée | XL | PUB-101 | +| LNK-102 | P0 | 2 | Synchroniser calendrier, posts et métriques LinkedIn | L | LNK-101 | +| ENG-101 | P0 | 3 | Ingérer les engagements LinkedIn sans doublon | L | LNK-101 | +| ATT-101 | P0 | 3 | Résoudre identité et attribution avec preuves | XL | ENG-101, CRM-101 | +| SYM-101 | P0 | 3 | Livrer Activité Symbiose et ses parcours attribués | L | ATT-101, NOO-103 | +| CRM-102 | P0 | 3 | Prioriser un prospect grâce aux signaux Inbound | L | ATT-101, OUT-101 | +| CON-102 | P0 | 3 | Unifier commentaires sociaux et conversations | L | ENG-101, CON-101 | +| REV-101 | P0 | 3 | Attribuer les appels au contenu et aux campagnes | M | ATT-101, CALL-101 | +| AUT-101 | P0 | 4 | Exécuter la boucle éditoriale LinkedIn quotidienne | XL | LNK-102, OPS-101 | +| AUT-102 | P0 | 4 | Apprendre des réponses sans modifier la policy seul | L | ATT-101, AUT-101 | +| OPS-102 | P0 | 4 | Réconcilier les effets provider inconnus | L | PUB-101, OPS-101 | +| PTC-101 | P0 | 4 | Prouver le parcours LinkedIn réel de bout en bout | M | AUT-101, CON-102, REV-101, OPS-102 | +| MED-201 | P1 | 5 | Ajouter médias et brand kit partagés | L | PUB-101 | +| X-201 | P1 | 5 | Étendre publication et engagement à X | XL | PUB-101, ENG-101 | +| VID-201 | P1 | 5 | Produire un rendu vidéo vertical reproductible | XL | MED-201, CNT-101 | +| YT-201 | P2 | 6 | Publier et mesurer YouTube Shorts | XL | VID-201, PUB-101 | +| TTK-201 | P2 | 6 | Publier et mesurer TikTok Shorts | XL | VID-201, PUB-101 | +| ANA-201 | P1 | 6 | Mesurer contenu et prospection jusqu'au revenu | L | ATT-101, REV-101 | -None - can start immediately. +## 5. Lot 0 — installer le produit Noosphere -### NOO-002 — Afficher Outbound et Inbound dans le cockpit Aujourd'hui +### NOO-101 — Installer le Noosphere Axis et les routes compatibles -## What to build +**Build.** Introduire `Inbound | Symbiose | Outbound` comme paramètre de lecture +dans le shell et sur `/activity`, puis rediriger les routes historiques sans +perdre leurs filtres. -Étendre le cockpit opérationnel afin qu'il présente l'activité des deux moteurs -sans obliger l'utilisateur à ouvrir leurs consoles : campagnes, publications, -prochains rendez-vous, jobs en cours et exceptions actionnables. +**Preuve visible.** Les trois positions changent la projection affichée ; les +operation IDs et prochaines actions des workers restent identiques. -## Acceptance criteria +**Acceptation.** Le composant est accessible au clavier, encode `lens` dans +l'URL, n'importe aucune commande métier et ne déclenche aucun `POST`, `PUT`, +`PATCH` ou `DELETE`. -- [ ] Les compteurs distinguent Outbound, Content Inbound et Conversations. -- [ ] Une publication planifiée ou bloquée expose sa prochaine action. -- [ ] Un workspace sans contenu voit une action unique vers la stratégie. -- [ ] Les erreurs d'un canal ne masquent pas l'activité des autres. -- [ ] La projection est workspace-scoped, paginée pour les exceptions et datée - avec `asOf`. +### NOO-102 — Projeter la santé des deux moteurs -## Blocked by +**Build.** Créer une projection workspace-scoped donnant santé Inbound, +Outbound, prochaines publications/campagnes, conversations, appels, jobs et +`asOf`. -- NOO-001. +**Preuve visible.** Une erreur LinkedIn Inbound n'efface ni les campagnes email +ni les rendez-vous ; chaque moteur conserve son état propre. -### PUB-001 — Exposer les capacités sociales réelles de chaque compte +**Acceptation.** Agrégations SQL déterministes, pagination des exceptions, +permissions testées et aucune table de read-model persistante avant mesure. -## What to build +### NOO-103 — Livrer le shell et la page Aujourd'hui -Étendre les comptes connectés avec un contrat `SocialAccountCapabilities` lu -auprès de l'adaptateur. L'UI de configuration affiche les opérations réellement -disponibles, les scopes, la santé, les quotas et la dernière vérification. +**Build.** Implémenter les cinq destinations validées et la page Aujourd'hui à +partir de `screen-today.html`. -## Acceptance criteria +**Preuve visible.** En moins de dix secondes, l'utilisateur voit si les deux +moteurs travaillent, ce qui arrive ensuite et ce qui demande réellement son +attention. -- [ ] Les capacités de publication, lecture, commentaire et métriques sont - persistées avec provider, compte, source et date. -- [ ] Une capacité inconnue est désactivée, jamais considérée vraie par défaut. -- [ ] Un probe rejoué met à jour le même état sans dupliquer les alertes. -- [ ] Les tokens et identifiants secrets ne sont jamais renvoyés au navigateur. -- [ ] Un compte dégradé bloque seulement les jobs qui le ciblent. +**Acceptation.** Même ordre desktop/mobile ; `Messages` est le libellé mobile +compact de Conversations ; état stale visible ; aucune métrique décorative. -## Blocked by +### OPS-101 — Unifier les exceptions réellement actionnables -- NOO-001. +**Build.** Projeter les comptes dégradés, résultats partiels, retries épuisés, +policies bloquantes et rendez-vous non réconciliés dans une liste unique. -### INB-001 — Publier une stratégie éditoriale liée à l'offre et à l'ICP +**Preuve visible.** Chaque exception ouvre la ressource concernée et propose une +seule récupération ; “Rien à traiter” signifie réellement que l'automatisation +peut continuer seule. -## What to build +**Acceptation.** Tri risque/ancienneté, explication normalisée, correlation ID, +redaction des secrets et aucune duplication par relivraison webhook. -Créer une surface Inbound / Stratégie qui projette une offre publiée et un ICP -dans une stratégie éditoriale : audience, niveau de conscience, piliers, voix, -formats, cadence, CTA, sujets interdits et policy par canal. La publication -produit une version immuable. +## 6. Lot 1 — faire entrer Outbound dans le nouveau modèle mental -## Acceptance criteria +### OUT-101 — Migrer les campagnes vers Activité Outbound -- [ ] Une stratégie ne peut être publiée sans offre, audience/ICP, au moins un - pilier, une voix et un objectif mesurable. -- [ ] Les claims autorisés et sujets interdits sont hérités avec leur version. -- [ ] Modifier le brouillon ne change aucune publication existante. -- [ ] L'UI compare le brouillon à la version active et explique les prérequis. -- [ ] Les mutations sont workspace-scoped, RBAC, idempotentes et auditées. +**Build.** Faire d'Activité Outbound la vue canonique des ICP et campagnes +existantes : sourcing, enrichissement, scoring, rédaction, envoi, relance, +qualification et réservation. -## Blocked by +**Preuve visible.** Lancer un ICP crée les campagnes utiles et leur progression +reste visible après navigation, reload ou redémarrage worker. -- NOO-001. +**Acceptation.** Pas de campagne vide créée pour remplir l'interface ; actions +pause/reprise/recherche idempotentes ; détails historiques accessibles par URL. -## Wave 1 — studio et LinkedIn tracer bullet +### CRM-101 — Unifier les prospects et leur origine -### INB-002 — Capturer et rechercher des idées sourcées +**Build.** Adapter la liste et la fiche Prospect 360 aux filtres ICP, campagne, +hors campagne, LinkedIn, email, WhatsApp, signal, statut et période. -## What to build +**Preuve visible.** Un prospect affiche origine Inbound, Outbound ou mixte, +preuves, score, canaux disponibles, avis IA et prochaine décision durable. -Fournir une inbox d'idées alimentée manuellement ou par un job de recherche -borné utilisant connaissances internes, actualités publiques, questions de -prospects et contenus déjà performants. Chaque idée conserve angle, audience, -source, fraîcheur et statut. +**Acceptation.** Aucun canal n'est inféré ; email probable non vérifié jamais +envoyé ; filtres dans l'URL ; retour vers la vue source sans perte de contexte. -## Acceptance criteria +### CON-101 — Stabiliser l'inbox multicanale canonique -- [ ] Une idée manuelle peut être enregistrée en moins de deux interactions. -- [ ] Une idée issue de recherche cite au moins une source résoluble. -- [ ] Les doublons sémantiques sont regroupés sans perdre leurs sources. -- [ ] Les idées expirées ou déjà traitées sont distinguées. -- [ ] La recherche est durable, reprenable, budgétée et ne publie rien. +**Build.** Rendre `/inbox` canonique pour tous les threads LinkedIn, email et +WhatsApp, campagne ou hors campagne. -## Blocked by +**Preuve visible.** L'utilisateur peut lire, répondre manuellement, améliorer +un brouillon ou déléguer au Setter depuis le même écran. -- INB-001. +**Acceptation.** Améliorer ne signifie jamais envoyer ; hors campagne ne lance +aucune automation implicite ; réponse humaine annule l'action Setter concurrente. -### INB-003 — Transformer une idée en brief éditorial prouvé +### CALL-101 — Livrer la surface Appels orientée résultat -## What to build +**Build.** Réunir rendez-vous, opportunité, contact, entreprise, prochaine +action, statut calendrier et source connue. -Produire depuis une idée un brief structuré contenant audience, problème, -insight, angle, preuve, promesse, format, CTA, contre-arguments et risques. Le -brief reste éditable puis devient un snapshot à la génération. +**Preuve visible.** L'utilisateur voit ses prochains appels et peut remonter à +la conversation qui les a produits. -## Acceptance criteria +**Acceptation.** Un booking correspond à un rendez-vous durable ; fuseaux +explicites ; annulation, déplacement et no-show ne dupliquent rien. -- [ ] Chaque fait du brief référence une preuve autorisée ou le label opinion. -- [ ] Un brief sans angle distinctif ou sans rapport avec l'offre est rejeté. -- [ ] Le brief affiche les contenus récents trop proches. -- [ ] La demande est idempotente et conserve le modèle, prompt et sources. -- [ ] Une erreur IA laisse l'idée et les sources intactes et relançables. +### CFG-101 — Transformer Settings en Configuration guidée -## Blocked by +**Build.** Implémenter la checklist validée : offre, ICP, comptes, autonomie, +agenda et connaissance optionnelle. -- INB-002. +**Preuve visible.** Chaque étape explique son état, l'action suivante et son +impact. Quitter puis reprendre ne perd rien. -### INB-004 — Générer, critiquer et versionner un contenu canonique +**Acceptation.** Une étape n'est prête que si le prérequis serveur est vrai ; +agenda et connaissance peuvent rester optionnels ; comptes déjà connectés sont +reconnus sans refaire l'onboarding. -## What to build +## 7. Lot 2 — tracer bullet Inbound LinkedIn -Créer un pipeline `writer → evidence auditor → independent critic` qui produit -un asset canonique versionné. L'éditeur montre les preuves, les changements, -les motifs de rejet et permet une amélioration IA sans publication implicite. +### STR-101 — Dériver une stratégie Inbound de l'offre et de l'ICP -## Acceptance criteria +**Build.** Produire une stratégie versionnée contenant audience, piliers, +niveau de conscience, voix, formats, cadence, CTA, claims autorisés et sujets +interdits. -- [ ] Le writer reçoit offre, ICP, voix, brief, historique récent et objectif. -- [ ] Le critic rejette hooks génériques, répétitions, faux chiffres, CTA hors - sujet et phrases interchangeables. -- [ ] Toute réécriture crée une version et conserve la précédente. -- [ ] Le contenu publié est toujours relié au modèle, prompt, policy et preuves. -- [ ] Les sorties structurées invalides ne deviennent jamais `ready`. +**Preuve visible.** Une offre publiée et un ICP actif suffisent à proposer une +stratégie exploitable et lisible dans Activité Inbound. -## Blocked by +**Acceptation.** Modifier le brouillon n'altère aucun contenu planifié ; les +sources et versions consommées sont conservées ; sortie structurée rejetée si +incomplète. -- INB-003. +### IDE-101 — Rechercher des idées sourcées et dédupliquées -### PUB-002 — Planifier une publication durable et idempotente +**Build.** Alimenter un radar quotidien depuis preuves produit, questions de +prospects, objections réelles et sources publiques autorisées. -## What to build +**Preuve visible.** Chaque idée indique angle, ICP, fraîcheur, sources et raison +de priorité ; une recherche interrompue reprend depuis son curseur. -Créer l'agrégat Publication, le calendrier, le job lease/retry et la policy de -dernière seconde. Une publication peut être planifiée, déplacée, annulée ou -publiée immédiatement sans double envoi. +**Acceptation.** Zéro idée inventée sans provenance ; doublons regroupés ; +budget et durée bornés par run ; la recherche ne publie rien. -## Acceptance criteria +### CNT-101 — Générer et critiquer un contenu non générique -- [ ] Une publication capture asset, stratégie, compte et policy immuables. -- [ ] Deux demandes avec la même clé logique produisent une publication. -- [ ] Déplacer ou annuler conserve l'historique et invalide le job précédent. -- [ ] Le gate final revérifie compte, capacité, quota, fenêtre, source et - contenu dupliqué sous verrou. -- [ ] Un résultat provider inconnu n'est jamais automatiquement rejoué. +**Build.** Pipeline `brief → writer → evidence auditor → critic indépendant → +version prête`, avec l'offre complète, l'ICP, les preuves, les conversations et +l'historique éditorial. -## Blocked by +**Preuve visible.** La preview explique les preuves et rejette hooks génériques, +faux chiffres, répétitions, ton interchangeable et CTA non relié à l'offre. -- PUB-001. -- INB-004. +**Acceptation.** Chaque réécriture crée une version ; aucun fait non sourcé +n'entre comme fait ; améliorer ne planifie ni ne publie. -### ENG-001 — Normaliser les interactions sociales +### PUB-101 — Planifier une publication durable -## What to build +**Build.** Créer Publication, calendrier, snapshot de contenu, lease, retry, +annulation, déplacement et gate de dernière seconde. -Créer une ingestion commune des commentaires, réponses, réactions et mentions. -Chaque événement conserve la référence provider, l'auteur résolu si possible, -la provenance et une clé d'idempotence. +**Preuve visible.** Une publication survit au reload et aux redémarrages ; elle +est publiée au plus une fois ou reste explicitement à réconcilier. -## Acceptance criteria +**Acceptation.** Clé d'idempotence, policy et compte versionnés ; résultat +provider inconnu jamais rejoué automatiquement ; historique immuable. -- [ ] Un webhook ou polling relivré produit une seule interaction. -- [ ] Les interactions envoyées par le compte sont distinguées des entrantes. -- [ ] L'identité incertaine n'est pas fusionnée automatiquement avec un contact. -- [ ] Suppression provider et modification restent traçables sans réapparition. -- [ ] L'UI distingue inconnu, prospect, client et membre du workspace. +### LNK-101 — Publier un post LinkedIn texte via capacité observée -## Blocked by +**Build.** Sonder les capacités du compte Unipile, générer la variante LinkedIn +et publier un post texte par le port provider existant. -- PUB-001. +**Preuve visible.** Un compte sain publie une fois, conserve l'ID provider et +expose le lien ; un compte dégradé bloque uniquement ce job. -### ATT-001 — Attribuer contenu, engagement, conversation et call +**Acceptation.** Aucun secret navigateur ; payloads 422/429/5xx classifiés ; +tests contractuels sur fixtures expurgées ; canary réel reporté à PTC-101. -## What to build +### LNK-102 — Synchroniser calendrier, posts et métriques LinkedIn -Créer des touches d'attribution déterministes reliant publication, -interaction, contact, conversation, campagne, rendez-vous et opportunité. La -vue explique le modèle retenu et n'affirme pas une causalité non prouvée. +**Build.** Rattraper posts internes/externes, statuts et snapshots des métriques +disponibles puis les afficher dans Activité Inbound. -## Acceptance criteria +**Preuve visible.** Calendrier, publication provider et métriques convergent +après un redémarrage sans créer de contenu fantôme. -- [ ] Chaque touch conserve source, date, identité et règle d'attribution. -- [ ] Les UTM et références provider sont consommées sans duplication. -- [ ] Les modèles first-touch et last-touch sont reproductibles en SQL. -- [ ] Une identité non résolue reste visible sans être attribuée à tort. -- [ ] L'utilisateur peut remonter du call au contenu source. +**Acceptation.** Métriques cumulatives non additionnées en deltas ; fraîcheur +visible ; post externe marqué comme tel ; curseur durable. -## Blocked by +## 8. Lot 3 — rendre Symbiose réellement utile -- ENG-001. +### ENG-101 — Ingérer les engagements LinkedIn sans doublon -### LI-001 — Qualifier les comptes LinkedIn Unipile pour la publication +**Build.** Normaliser commentaires, réponses, réactions et mentions avec clé +provider, auteur, date, publication et provenance. -## What to build +**Preuve visible.** Un événement relivré apparaît une fois ; les interactions +du propriétaire du compte sont distinguées des entrantes. -Ajouter l'adaptateur de capacité LinkedIn sur les comptes Unipile existants et -un écran de vérification en lecture seule : publier, lister ses posts, lire et -répondre aux commentaires, lire les réactions et publier pour une page. +**Acceptation.** Suppression/modification réconciliées ; identité inconnue +conservée ; une réaction seule ne déclenche jamais un message. -## Acceptance criteria +### ATT-101 — Résoudre identité et attribution avec preuves -- [ ] Le probe ne réalise aucune publication. -- [ ] Chaque compte expose personnel/page, permissions et capacités observées. -- [ ] Une session LinkedIn en checkpoint ou expirée produit une action de - reconnexion explicite. -- [ ] Les limites v1/v2 Unipile restent dans l'adaptateur. -- [ ] Un test contractuel couvre les payloads réels expurgés. +**Build.** Relier publication, interaction, contact, conversation, campagne et +appel par des edges d'attribution explicables. -## Blocked by +**Preuve visible.** La vue peut remonter du call au touchpoint source ou afficher +`unknown` sans inventer de causalité. -- PUB-001. +**Acceptation.** Confiance et règle visibles ; aucune fusion faible ; first et +last touch reproductibles ; preuve ouvrable pour chaque edge affirmé. -### LI-002 — Produire un post LinkedIn non générique +### SYM-101 — Livrer Activité Symbiose et ses parcours attribués -## What to build +**Build.** Implémenter la file des signaux prioritaires et le parcours +source → interaction → identité → conversation → appel. -Ajouter la variante LinkedIn du pipeline éditorial avec hooks, structure, -longueur, lisibilité, mentions, lien externe et CTA conformes au compte. Une -preview fidèle permet de comparer les versions et preuves. +**Preuve visible.** L'utilisateur comprend ce que les contenus ont réellement +produit et quelle suite est prévue. -## Acceptance criteria +**Acceptation.** Aucun KPI d'engagement décoratif ; distinction preuve, +inférence et inconnu ; ouvrir un signal ne déclenche aucune activation. -- [ ] Le texte respecte les contraintes LinkedIn lues par l'adaptateur. -- [ ] Le critic compare les derniers posts et rejette les formulations répétées. -- [ ] Les mentions sont résolues avant que la variante devienne `ready`. -- [ ] Une amélioration IA ne planifie et ne publie rien. -- [ ] Les fixtures de qualité contiennent des cas français longs et réalistes. +### CRM-102 — Prioriser un prospect grâce aux signaux Inbound -## Blocked by +**Build.** Ajouter les signaux sociaux prouvés au scoring et à la prochaine +décision Outbound, sous ICP, suppression et policy. -- INB-004. -- LI-001. +**Preuve visible.** La fiche prospect explique pourquoi le signal change ou ne +change pas sa priorité. -### LI-003 — Publier et reprendre un post LinkedIn texte +**Acceptation.** Signal expiré exclu ; like seul sans action ; conversation +sociale ouverte empêche un DM contradictoire ; décision idempotente. -## What to build +### CON-102 — Unifier commentaires sociaux et conversations -Livrer le premier chemin réel complet : sélectionner un compte sain, planifier -un post texte, le publier via Unipile, conserver l'identifiant provider, puis -réconcilier son état après redémarrage. +**Build.** Rendre les commentaires/réponses LinkedIn consultables et répondables +dans Conversations avec contexte du post et de l'attribution. -## Acceptance criteria +**Preuve visible.** Réponse manuelle, amélioration IA et Setter utilisent le +même thread et le même contexte prouvé. -- [ ] Un succès provider produit exactement une publication `published`. -- [ ] Une réponse 422/429/5xx est classée avec retry seulement si sûre. -- [ ] Un crash après acceptation provider ne crée pas un second post. -- [ ] Le post est visible dans le calendrier avec lien LinkedIn. -- [ ] Le test d'intégration utilise un faux provider ; le canary réel reste LI-009. +**Acceptation.** Action humaine prioritaire ; opt-out immédiat ; prix, +juridique, sécurité et négociation deviennent des exceptions ciblées. -## Blocked by +### REV-101 — Attribuer les appels au contenu et aux campagnes -- PUB-002. -- LI-002. +**Build.** Enrichir Appels avec les sources Inbound, Outbound, mixtes ou +inconnues et le chemin de conversion. -### LI-004 — Publier images, documents et carrousels LinkedIn +**Preuve visible.** Un rendez-vous confirmé affiche la conversation, le contenu +ou la campagne qui l'a influencé, sans forcer une attribution. -## What to build +**Acceptation.** Booking unique ; modèle d'attribution affiché ; parcours +recalculable ; aucune source absente transformée en zéro. -Étendre la publication LinkedIn aux images multiples, document/carrousel et -vidéo supportée en validant type, taille, résolution, droits et preview avant -upload. +## 9. Lot 4 — autonomie et preuve produit -## Acceptance criteria +### AUT-101 — Exécuter la boucle éditoriale LinkedIn quotidienne -- [ ] Les limites provider sont lues et vérifiées avant création du job. -- [ ] Les pièces proviennent exclusivement de la bibliothèque média du workspace. -- [ ] Un upload partiel ne crée pas de publication fantôme. -- [ ] Le hash du rendu relie le fichier MinIO à la publication provider. -- [ ] Les erreurs de média sont actionnables et n'altèrent pas la version texte. +**Build.** À l'heure configurée : chercher des idées, générer les briefs, +rédiger, critiquer, planifier et publier selon stratégie, cadence et budget. -## Blocked by +**Preuve visible.** Le chemin normal ne demande aucune validation humaine ; les +exceptions suspendent seulement l'asset concerné. -- LI-003. -- MED-001. +**Acceptation.** Pause/reprise immédiate et auditée ; collisions évitées ; jobs +reprenables ; budget atteint restitue un résultat partiel sans perte. -### LI-005 — Synchroniser posts et métriques LinkedIn +### AUT-102 — Apprendre des réponses sans modifier la policy seul -## What to build +**Build.** Produire des recommandations de piliers, angles et ciblage à partir +des réponses et appels attribués. -Rattraper les posts du compte, rapprocher ceux publiés hors Noosphere et -collecter les métriques disponibles sous forme de snapshots datés. +**Preuve visible.** Noosphere explique l'apprentissage proposé et peut l'utiliser +au prochain run dans les bornes déjà autorisées. -## Acceptance criteria +**Acceptation.** Aucune hausse de quota, nouveau claim, nouveau canal ou +élargissement d'ICP sans configuration explicite ; recommandations versionnées. -- [ ] Un post publié hors produit est marqué `external`, sans asset inventé. -- [ ] Les métriques cumulatives ne sont jamais additionnées comme des deltas. -- [ ] La suppression ou indisponibilité du post est réconciliée. -- [ ] Le curseur de sync survit aux redémarrages. -- [ ] La fraîcheur et le périmètre des métriques sont visibles. +### OPS-102 — Réconcilier les effets provider inconnus -## Blocked by +**Build.** Étendre la reprise aux publications, commentaires et réponses dont +le provider a peut-être accepté l'effet avant timeout ou crash. -- LI-003. +**Preuve visible.** L'opérateur voit `unknown`, la recherche provider et la +décision finale ; aucun bouton ne rejoue aveuglément l'action. -## Wave 2 — engagement, conversion et autonomie LinkedIn +**Acceptation.** Deux workers ne publient jamais le même snapshot ; correlation +complète ; payloads expurgés ; reprise testée après kill du worker. -### LI-006 — Centraliser commentaires et réponses LinkedIn +### PTC-101 — Prouver le parcours LinkedIn réel de bout en bout -## What to build +**Build.** Exécuter sur un compte canary borné : stratégie, idée sourcée, +contenu, publication réelle, interaction réelle, réponse, signal CRM, +conversation et rendez-vous attribué ou scénario contrôlé jusqu'à la réservation. -Afficher les commentaires LinkedIn dans une inbox d'engagement, avec contexte -du post, auteur, intention, suggestion IA et réponse manuelle ou Setter. Une -action humaine annule toute réponse IA concurrente. +**Preuve visible.** Chaque étape possède un ID provider ou une preuve durable et +le rapport distingue clairement le réel du simulé. -## Acceptance criteria +**Acceptation.** Autorisation explicite du compte et du contenu canary ; zéro +duplication après redémarrage ; URLs résolubles ; verdict L0-L5 ; aucune +revendication “prêt” avant succès. -- [ ] Les commentaires et réponses paginés sont synchronisés sans doublon. -- [ ] Le brouillon IA cite le post, l'échange et les claims autorisés. -- [ ] Le bouton améliorer ne publie jamais. -- [ ] Une réponse manuelle ou automatique utilise une clé idempotente. -- [ ] Opt-out, juridique, sécurité, prix et conflit deviennent des exceptions. +## 10. Lots ultérieurs -## Blocked by +### MED-201 — Médias et brand kit -- ENG-001. -- LI-005. +Bibliothèque workspace, droits, hashes, previews et variantes LinkedIn image ou +document. Aucun média généré sans provenance ni manifest. -### LI-007 — Convertir un engagement LinkedIn en signal CRM +### X-201 — X -## What to build +Capacités réelles, publication, threads, mentions, réponses, métriques et +attribution. Canary propre au canal obligatoire. -Transformer un commentaire, une réaction ou une mention en signal d'intention -daté. Résoudre le profil avec confiance, rattacher ou créer un contact sans -fusion faible, puis expliquer si ce contact correspond à un ICP. +### VID-201 — Rendu vertical -## Acceptance criteria +Script, storyboard, narration, sous-titres et MP4 9:16 reproductibles derrière +un port `MediaRenderer`. -- [ ] Commentaire, réaction et mention ont des poids configurables distincts. -- [ ] Une réaction seule ne déclenche ni invitation ni DM. -- [ ] Le contact conserve l'URL/source et la date de l'interaction. -- [ ] Les suppressions et exclusions ICP bloquent toute activation Outbound. -- [ ] Le score explique contenu, interaction et critères ICP satisfaits. +### YT-201 — YouTube Shorts -## Blocked by +OAuth, upload resumable privé, processing, publication, commentaires, +rétention et attribution. Canary privé puis public borné. -- ATT-001. -- LI-006. +### TTK-201 — TikTok Shorts -### LI-008 — Exécuter la cadence LinkedIn en autopilote borné +OAuth/audit, brouillon lorsque Direct Post est indisponible, publication quand +autorisée, processing et métriques réellement accessibles. -## What to build +### ANA-201 — Performance jusqu'au revenu -Exécuter chaque jour le radar d'idées, les briefs, la rédaction et le calendrier -selon la stratégie active. Le chemin normal ne demande pas d'approbation, mais -la policy crée des exceptions ciblées et respecte un budget quotidien. +Projection SQL déterministe de la production au call et au revenu ; définitions, +fenêtres, dénominateurs, coûts et capacités de chaque canal explicités. -## Acceptance criteria +## 11. Ordre de publication GitHub -- [ ] Activer l'autopilote exige stratégie publiée, compte sain et cadence. -- [ ] Le scheduler évite les collisions et contenus trop proches. -- [ ] Le budget limite le travail quotidien sans perdre les idées restantes. -- [ ] Une exception suspend seulement la publication concernée. -- [ ] Pause/reprise est immédiate, idempotente et auditée. +1. Publier `NOO-101`, `NOO-102`, `NOO-103`, `OPS-101`. +2. Publier le lot 1 lorsque `NOO-101` est en revue et #17 fermé. +3. Publier le lot 2 lorsque la page Aujourd'hui est démontrable. +4. Publier le lot 3 seulement après un post LinkedIn simulé réconcilié. +5. Publier le lot 4 seulement après ingestion d'une interaction simulée. +6. Les tickets P1/P2 restent dans ce backlog jusqu'au succès de `PTC-101`. -## Blocked by - -- LI-003. -- LI-006. - -### LI-009 — Prouver le parcours LinkedIn par un canary réel - -## What to build - -Exécuter et documenter PTC-IN-LI-001 sur un compte et une publication canary -explicitement autorisés : post réel, commentaire réel, réponse, sync après -redémarrage, signal CRM et attribution. - -## Acceptance criteria - -- [ ] Le destinataire, le compte et le texte canary sont explicitement autorisés. -- [ ] Le post n'est créé qu'une fois et son URL est enregistrée. -- [ ] Un commentaire réel est synchronisé puis répondu une seule fois. -- [ ] Le redémarrage ne duplique ni interaction, ni signal, ni réponse. -- [ ] Le rapport distingue les preuves réelles des contrôles locaux. - -## Blocked by - -- LI-007. -- LI-008. - -### CC-003 — Relier automatiquement signaux Inbound et moteur Outbound - -## What to build - -Permettre à une campagne Outbound de consommer les signaux issus du contenu -comme critère de priorité. La transition reste gouvernée par l'ICP, les -suppressions et la policy de campagne. - -## Acceptance criteria - -- [ ] La campagne peut filtrer et prioriser par type de contenu/interaction. -- [ ] Chaque décision cite l'interaction source. -- [ ] Aucun DM n'est déclenché uniquement par une réaction. -- [ ] Une réponse sociale ouverte empêche un message direct contradictoire. -- [ ] Les analytics distinguent organique, outbound et parcours combiné. - -## Blocked by - -- LI-007. - -### OPS-001 — Réconcilier publications, retries et actions concurrentes - -## What to build - -Étendre la console opérateur et les workers aux publications : lease expiré, -résultat provider inconnu, post absent, interaction concurrente et relance -manuelle sûre. - -## Acceptance criteria - -- [ ] Les jobs de publication sont visibles par correlation ID. -- [ ] Un état provider inconnu exige réconciliation avant retry. -- [ ] Deux workers ne publient jamais le même snapshot. -- [ ] Une modification humaine du post suspend l'action IA concurrente. -- [ ] Les payloads opérateur sont expurgés de tokens et PII inutile. - -## Blocked by - -- LI-003. -- PUB-002. - -## Wave 3 — médias partagés et X - -### MED-001 — Gérer une bibliothèque média et un brand kit - -## What to build - -Créer une bibliothèque workspace pour logos, couleurs, polices, images, -documents, vidéos, droits et templates. Les fichiers sont stockés dans MinIO et -exposés par liens signés courts. - -## Acceptance criteria - -- [ ] Type, taille, hash, dimensions, durée, licence et source sont validés. -- [ ] Un fichier identique n'est stocké qu'une fois par workspace. -- [ ] Aucun bucket ou chemin interne n'est exposé au modèle. -- [ ] Le retrait d'un asset bloque les futures publications sans modifier les - snapshots déjà publiés. -- [ ] Upload, retrait et usage sont audités. - -## Blocked by - -- INB-001. - -### MED-002 — Dériver un contenu sans copier entre canaux - -## What to build - -Créer un cas d'usage de repurposing qui transforme un asset canonique en -variantes LinkedIn, X et short vidéo, chacune avec son angle, format et CTA. La -relation de dérivation reste visible. - -## Acceptance criteria - -- [ ] Chaque variante conserve l'asset et les preuves sources. -- [ ] Le critic rejette la copie littérale entre canaux. -- [ ] Les contraintes du canal sont validées avant `ready`. -- [ ] Corriger une variante ne modifie pas les autres. -- [ ] Les coûts IA sont attribués à la dérivation concernée. - -## Blocked by - -- INB-004. -- MED-001. - -### MED-003 — Rendre une vidéo verticale 9:16 reproductible - -## What to build - -Produire depuis un brief un script, un storyboard, une narration, des sous- -titres et un rendu MP4 9:16 via un port `MediaRenderer`. Le manifest de rendu -permet de reproduire exactement le fichier. - -## Acceptance criteria - -- [ ] Le rendu respecte durée, codec, résolution, audio et safe zones cibles. -- [ ] Script, voix, sous-titres, assets, renderer et versions sont manifestés. -- [ ] Le job est repris après crash sans perdre les étapes terminées. -- [ ] Les médias sans droits ou au contenu interdit sont bloqués. -- [ ] Une preview web et les sous-titres éditables précèdent toute publication. - -## Blocked by - -- MED-002. - -### X-001 — Connecter X et afficher scopes, coûts et capacités - -## What to build - -Ajouter OAuth utilisateur X, chiffrer les tokens et sonder les capacités -réelles du plan : posts, médias, lecture des mentions, métriques publiques et -owner. Afficher coûts et limites sans estimation silencieuse. - -## Acceptance criteria - -- [ ] La connexion est reprenable et révocable. -- [ ] Les scopes et capacités sont lus puis affichés. -- [ ] Le coût/quota absent est marqué inconnu et bloque l'autopilote. -- [ ] La rotation ou révocation de token cible uniquement ce compte. -- [ ] Un test de lecture réel ne publie aucun post. - -## Blocked by - -- PUB-001. - -### X-002 — Publier posts, threads et médias sur X - -## What to build - -Générer puis publier un post, un thread ordonné ou un média X via la publication -durable commune. Chaque élément d'un thread est idempotent et reprend au bon -point après erreur. - -## Acceptance criteria - -- [ ] La variante respecte longueur, médias et ordre du thread. -- [ ] Un thread partiel est réconcilié avant reprise. -- [ ] Les identifiants provider relient tous les éléments au même asset. -- [ ] Les quotas et coûts sont contrôlés avant chaque appel. -- [ ] Un canary réel borné prouve publication et suppression/réconciliation. - -## Blocked by - -- X-001. -- PUB-002. -- MED-001. - -### X-003 — Synchroniser mentions, réponses, métriques et attribution X - -## What to build - -Ingestions des mentions/réponses et snapshots de métriques X, réponse assistée, -signal CRM et attribution au contenu puis au rendez-vous. - -## Acceptance criteria - -- [ ] Les métriques publiques et owner sont distinguées avec leur fenêtre. -- [ ] Les mentions et réponses sont dédupliquées et visibles dans l'inbox. -- [ ] Une réponse humaine suspend le Setter concurrent. -- [ ] Les interactions créent des signaux avec provenance et confiance. -- [ ] Le canary couvre post, reply, sync, signal et reprise après redémarrage. - -## Blocked by - -- X-002. -- ENG-001. -- ATT-001. - -### CC-001 — Unifier le calendrier éditorial cross-channel - -## What to build - -Afficher dans un calendrier unique toutes les variantes, comptes, canaux, -états, dépendances et conflits. Déplacer une publication respecte sa policy et -ne déplace pas implicitement ses variantes sœurs. - -## Acceptance criteria - -- [ ] Filtres canal, compte, stratégie, campagne et statut dans l'URL. -- [ ] Fuseau workspace et heure provider sont explicites. -- [ ] Conflits de cadence, duplication et compte dégradé sont visibles. -- [ ] Drag/drop appelle une mutation idempotente et auditable. -- [ ] Mobile propose une liste chronologique utilisable. - -## Blocked by - -- PUB-002. -- LI-003. -- X-002. - -## Wave 4 — YouTube Shorts et TikTok Shorts - -### YT-001 — Connecter une chaîne YouTube et uploader en privé - -## What to build - -Ajouter OAuth YouTube, capacités de chaîne, quota et un upload resumable privé -ou non répertorié. Le test valide le format sans publier publiquement. - -## Acceptance criteria - -- [ ] Les scopes YouTube Data et Analytics sont minimaux et visibles. -- [ ] L'upload reprend après interruption et conserve le même video ID. -- [ ] Le statut de traitement est suivi jusqu'à succès ou échec. -- [ ] Le quota réel est enregistré par appel. -- [ ] Le canary privé peut être nettoyé sans supprimer un autre contenu. - -## Blocked by - -- PUB-001. -- MED-003. - -### YT-002 — Publier un Short avec traitement et scheduling durables - -## What to build - -Produire titre, description, tags, miniature, audience et confidentialité, -uploader le rendu puis publier selon le calendrier. L'état YouTube processing -fait partie de la machine d'état. - -## Acceptance criteria - -- [ ] Un rendu non conforme n'est pas uploadé. -- [ ] Metadata et fichier sont un snapshot immuable. -- [ ] Un succès d'upload sans succès de processing reste `processing`. -- [ ] Le polling respecte quota, backoff et reprise après redémarrage. -- [ ] Un canary public borné prouve une seule publication. - -## Blocked by - -- YT-001. -- PUB-002. - -### YT-003 — Synchroniser commentaires, rétention et attribution YouTube - -## What to build - -Collecter commentaires et Analytics owner : vues, engaged views, durée moyenne, -rétention, likes, partages et abonnés gagnés. Relier interactions et appels. - -## Acceptance criteria - -- [ ] Les métriques et dimensions conservent leur définition et période. -- [ ] Les commentaires sont visibles et répondables selon capacité. -- [ ] Les seuils de confidentialité API sont représentés comme données absentes. -- [ ] Les snapshots sont recalculables sans double comptage. -- [ ] Un canary couvre commentaire réel, sync et attribution. - -## Blocked by - -- YT-002. -- ENG-001. -- ATT-001. - -### TT-001 — Connecter TikTok et livrer un upload brouillon conforme - -## What to build - -Passer le parcours OAuth et d'audit TikTok, sonder les capacités du compte et -uploader un rendu comme brouillon via Content Posting API. L'accès manquant est -un état produit explicite, pas une simulation. - -## Acceptance criteria - -- [ ] Scopes, audit status et limites sont affichés. -- [ ] Le mode draft fonctionne sans Direct Post. -- [ ] FILE_UPLOAD et PULL_FROM_URL sont encapsulés derrière le même port. -- [ ] L'upload est idempotent et son statut durable. -- [ ] Aucun contenu n'est rendu public pendant le canary brouillon. - -## Blocked by - -- PUB-001. -- MED-003. - -### TT-002 — Publier un Short TikTok avec suivi du statut - -## What to build - -Activer Direct Post lorsqu'il est approuvé, avec caption, privacy, interaction -settings et suivi du processing provider. Sinon l'UI conserve le workflow -brouillon sans fausse capacité. - -## Acceptance criteria - -- [ ] Direct Post n'est visible que si le compte et l'app sont autorisés. -- [ ] Les paramètres de visibilité et interactions sont confirmés par la policy. -- [ ] Le statut provider est réconcilié après timeout et redémarrage. -- [ ] Un résultat inconnu n'est jamais renvoyé automatiquement. -- [ ] Un canary réel crée une publication unique et traçable. - -## Blocked by - -- TT-001. -- PUB-002. - -### TT-003 — Synchroniser les métriques TikTok accessibles et l'attribution - -## What to build - -Collecter uniquement les métriques et interactions réellement accessibles au -compte/app, conserver leur fraîcheur, et attribuer les conversions observables. - -## Acceptance criteria - -- [ ] La matrice de capacité distingue indisponible, refusé et erreur temporaire. -- [ ] Les métriques absentes ne valent jamais zéro. -- [ ] Les snapshots sont idempotents et datés. -- [ ] Les liens/UTM et identités résolubles créent des touches d'attribution. -- [ ] Le rapport de canary décrit précisément ce que l'API ne permet pas. - -## Blocked by - -- TT-002. -- ATT-001. - -## Wave 5 — pilotage et apprentissage - -### CC-002 — Piloter la performance du contenu jusqu'au revenu - -## What to build - -Créer un dashboard cross-channel qui distingue production, distribution, -engagement, conversations, rendez-vous et revenu. Les dénominateurs, fenêtres -et coûts sont explicites. - -## Acceptance criteria - -- [ ] Filtres canal, compte, pilier, format, ICP, période et offre. -- [ ] Impressions, vues, engagement, conversations et calls restent distincts. -- [ ] Les métriques incompatibles entre plateformes ne sont pas additionnées. -- [ ] Coût IA/provider par asset, publication, conversation et call. -- [ ] Export exact de la vue filtrée, workspace-scoped et audité. - -## Blocked by - -- ATT-001. -- LI-007. -- X-003. -- YT-003. -- TT-003. - -### AI-160 — Évaluer continuellement la qualité éditoriale - -## What to build - -Étendre l'évaluation IA avec un dataset éditorial : fidélité aux preuves, -spécificité, voix, répétition, qualité du hook, clarté du CTA, conformité canal, -coût et latence. Le shadow mode ne publie rien. - -## Acceptance criteria - -- [ ] Les cas n'utilisent aucune PII réelle inutile. -- [ ] Les règles déterministes évaluent claims, longueurs et duplication. -- [ ] Le modèle évalué ne se note pas lui-même. -- [ ] Le shadow mode ne crée ni Publication ni appel provider. -- [ ] Une promotion de prompt est versionnée, auditée et sans rétroactivité. - -## Blocked by - -- LI-005. -- CC-002. - -## Ordre de création recommandé dans GitHub - -1. NOO-001, PUB-001, INB-001. -2. NOO-002, INB-002, LI-001, MED-001. -3. INB-003, INB-004, ENG-001. -4. PUB-002, ATT-001, LI-002. -5. LI-003, LI-005, OPS-001. -6. LI-004, LI-006, LI-007. -7. LI-008, CC-003, LI-009. -8. MED-002, MED-003, X-001, X-002, X-003, CC-001. -9. YT-001, YT-002, YT-003, TT-001, TT-002, TT-003. -10. CC-002, AI-160. - -Le graphe est volontairement acyclique. LI-009 est le release gate de -LinkedIn ; X-003, YT-003 et TT-003 portent leurs canaries de canal. +Le kanban doit avoir au plus deux tickets produit `In progress` simultanément, +hors bug ou benchmark. Une tranche ne passe à `Done` que lorsque sa preuve +visible et ses tests sont attachés à l'issue. From c023963f6633306ef493ea08d2a3bd31925e1142 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 21:21:43 +0200 Subject: [PATCH 127/169] feat(noosphere): add axis and today cockpit --- .github/workflows/check.yml | 15 +- .gitignore | 2 + apps/api/src/index.ts | 1 + apps/web/app/layout.tsx | 6 +- apps/web/app/login/page.tsx | 6 +- .../w/[workspaceSlug]/activity/loading.tsx | 10 + .../app/w/[workspaceSlug]/activity/page.tsx | 124 ++++++++++ apps/web/app/w/[workspaceSlug]/loading.tsx | 12 +- apps/web/app/w/[workspaceSlug]/page.tsx | 162 ++++++++++++- apps/web/components/app-shell.tsx | 66 +++--- apps/web/components/noosphere-axis.tsx | 47 ++++ apps/web/lib/api.ts | 59 ++++- bun.lock | 14 +- package.json | 3 + .../src/workspaces/operational-views.ts | 56 +++++ .../openapi/product-research-v1.json | 22 +- .../workspaces/postgres-operational-views.ts | 215 ++++++++++++++++-- .../src/http/operational-view-handler.ts | 22 +- playwright.config.ts | 26 +++ scripts/start-e2e-server.ts | 18 ++ tests/e2e/noosphere-axis.spec.ts | 50 ++++ tests/http/operational-view-http.test.ts | 23 +- .../noosphere-operational-views.test.ts | 68 ++++++ tests/unit/product-research-openapi.test.ts | 1 + 24 files changed, 950 insertions(+), 78 deletions(-) create mode 100644 apps/web/app/w/[workspaceSlug]/activity/loading.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/activity/page.tsx create mode 100644 apps/web/components/noosphere-axis.tsx create mode 100644 playwright.config.ts create mode 100644 scripts/start-e2e-server.ts create mode 100644 tests/e2e/noosphere-axis.spec.ts create mode 100644 tests/integration/noosphere-operational-views.test.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 416da3e..65ef822 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,9 +27,9 @@ env: OPENAI_SYNTHESIS_MODEL: ci-test-synthesis-model OPENAI_EMBEDDING_MODEL: text-embedding-3-small AI_PROVIDER: openai - BETTER_AUTH_URL: http://127.0.0.1:3000 + BETTER_AUTH_URL: http://localhost:3000 BETTER_AUTH_SECRET: ci-only-better-auth-secret-012345678901234567890123 - BETTER_AUTH_TRUSTED_ORIGINS: http://127.0.0.1:3000 + BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:3000,http://127.0.0.1:3000 BETTER_AUTH_ALLOW_SIGN_UP: "false" OUTBOUND_API_URL: http://127.0.0.1:3001 S3_ENDPOINT: http://127.0.0.1:9000 @@ -41,6 +41,11 @@ env: CRAWLER_SERVICE_URL: http://127.0.0.1:8000 SEARCH_FALLBACK_ENABLED: "true" PORT: "3001" + BOOTSTRAP_OWNER_EMAIL: noosphere-ci@example.test + BOOTSTRAP_OWNER_NAME: Noosphere CI + BOOTSTRAP_OWNER_PASSWORD: noosphere-ci-password + BOOTSTRAP_WORKSPACE_SLUG: ignition-ai + BOOTSTRAP_WORKSPACE_NAME: IgnitionAI jobs: check: @@ -112,3 +117,9 @@ jobs: - name: Run PostgreSQL integration tests run: bun run test:integration + + - name: Install browser for Noosphere P0 + run: bunx playwright install --with-deps chromium + + - name: Run Noosphere browser journeys + run: bun run test:e2e diff --git a/.gitignore b/.gitignore index df70e03..58ebb6b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules/ .next/ dist/ coverage/ +playwright-report/ +test-results/ __pycache__/ .pytest_cache/ .venv/ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 495e2c8..acaae5c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -286,6 +286,7 @@ const server = Bun.serve({ if (isWorkspaceDataRoute(pathname, request.method)) return workspaceData(request); if ( pathname === "/api/v1/workspace/operational-summary" + || pathname === "/api/v1/activity" || pathname === "/api/v1/workspace/setup-readiness" || pathname === "/api/v1/conversations" || (request.method === "GET" && /^\/api\/v1\/conversations\/[^/]+$/.test(pathname)) diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e157fba..4ad79d5 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,10 +4,10 @@ import "./globals.css"; export const metadata: Metadata = { title: { - default: "Ignition Outbound", - template: "%s · Ignition Outbound", + default: "Noosphere", + template: "%s · Noosphere", }, - description: "Prospection B2B multi-workspace, sourcée et supervisée.", + description: "Créer la demande, capter les prospects et récolter les appels.", }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index bf10f46..6df879a 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -14,16 +14,16 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
- IO + N
Espace privé IgnitionAI

- Se connecter à Ignition Outbound + Se connecter à Noosphere

- Retrouvez vos études ICP, prospects et campagnes dans un espace isolé. + Retrouvez vos moteurs Inbound et Outbound dans un espace isolé.

diff --git a/apps/web/app/w/[workspaceSlug]/activity/loading.tsx b/apps/web/app/w/[workspaceSlug]/activity/loading.tsx new file mode 100644 index 0000000..7d54e5c --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/activity/loading.tsx @@ -0,0 +1,10 @@ +export default function ActivityLoading() { + return ( +

+
+
+
+
+
+ ); +} diff --git a/apps/web/app/w/[workspaceSlug]/activity/page.tsx b/apps/web/app/w/[workspaceSlug]/activity/page.tsx new file mode 100644 index 0000000..844809c --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/activity/page.tsx @@ -0,0 +1,124 @@ +import { AlertTriangle, ArrowRight, CheckCircle2, Clock3, Radio, Settings2, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { NoosphereAxis } from "@/components/noosphere-axis"; +import { getActivity, type ActivityWorkspacePage, type NoosphereLens } from "@/lib/api"; + +export const metadata = { title: "Activité — Noosphere" }; +export const dynamic = "force-dynamic"; + +type Query = { lens?: string; cursor?: string }; + +export default async function ActivityPage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string }>; + searchParams: Promise; +}) { + const { workspaceSlug } = await params; + const query = await searchParams; + const lens = validLens(query.lens); + try { + const activity = await getActivity(workspaceSlug, lens, query.cursor); + return ( + <> +
+
+
Projection en lecture seule
+

{titleFor(lens)}

+

{descriptionFor(lens)}

+
+ {lens === "outbound" ? Lancer un ICP : Configuration} +
+ + + +
+
{statusLabel(activity)}

{activity.headline}

+ Mis à jour {formatTime(activity.asOf)} +
+ +
+ {activity.counters.map((counter) =>

{counter.label}

{counter.value}
)} +
+ + + + ); + } catch { + return ; + } +} + +function ActivityContent({ activity, workspaceSlug }: { activity: ActivityWorkspacePage; workspaceSlug: string }) { + if (activity.state === "not_configured") { + return ( +
+ +

{activity.lens === "inbound" ? "Inbound LinkedIn sera le prochain moteur activé" : "La Symbiose attend une première preuve Inbound"}

+

{activity.lens === "inbound" ? "La stratégie, les idées sourcées, le pipeline éditorial et la publication durable seront installés sans perturber Outbound." : "Elle reliera ensuite publications, interactions, conversations et appels sans modifier l’origine campagne/hors campagne."}

+ Vérifier les prérequis +
+ ); + } + return ( +
+

Flux opérationnel

Quitter cette page ne change aucun job, lease ou prochaine action.

{activity.items.length}
+ {activity.items.length ?
{activity.items.map((item) => ( + + + {item.title}{item.detail} + {formatDate(item.occurredAt)} + + ))}
:

Aucune activité dans cette projection

Le moteur est sain et attend sa prochaine échéance.

} + {activity.pagination.nextCursor ?
Afficher la suite
: null} +
+ ); +} + +function ActivityError({ lens, workspaceSlug }: { lens: NoosphereLens; workspaceSlug: string }) { + return

Impossible de charger cette activité

Les opérations en cours continuent. La lens ne pilote aucune mutation.

Réessayer
; +} + +function StatusIcon({ state }: { state: ActivityWorkspacePage["state"] }) { + if (state === "attention") return ; + if (state === "active") return ; + return ; +} + +function statusClass(state: ActivityWorkspacePage["state"]): string { + if (state === "attention") return "flex flex-col gap-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between"; + if (state === "not_configured") return "flex flex-col gap-3 rounded-xl border border-line bg-white p-4 text-navy sm:flex-row sm:items-center sm:justify-between"; + return "flex flex-col gap-3 rounded-xl bg-navy p-4 text-white sm:flex-row sm:items-center sm:justify-between"; +} + +function statusLabel(activity: ActivityWorkspacePage): string { + if (activity.state === "not_configured") return "Moteur non configuré"; + if (activity.state === "attention") return "Une exception est localisée"; + if (activity.state === "active") return "Moteur actif"; + return "Moteur en veille"; +} + +function validLens(value?: string): NoosphereLens { + return value === "inbound" || value === "outbound" ? value : "symbiosis"; +} + +function titleFor(lens: NoosphereLens): string { + if (lens === "inbound") return "Créer la demande"; + if (lens === "outbound") return "Capter la demande"; + return "Relier contenu et revenu"; +} + +function descriptionFor(lens: NoosphereLens): string { + if (lens === "inbound") return "De l’idée sourcée à la conversation, sans contenu générique."; + if (lens === "outbound") return "Des ICP aux appels, avec chaque campagne et chaque job observables."; + return "Des signaux sociaux prouvés aux conversations et appels attribués."; +} + +function workspaceHref(workspaceSlug: string, href: string): string { + if (href.startsWith("/w/")) return href; + return `/w/${workspaceSlug}${href.startsWith("/") ? href : `/${href}`}`; +} + +function formatTime(value: string): string { return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris" }).format(new Date(value)); } +function formatDate(value: string): string { return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris" }).format(new Date(value)); } diff --git a/apps/web/app/w/[workspaceSlug]/loading.tsx b/apps/web/app/w/[workspaceSlug]/loading.tsx index 48fc682..48c60b6 100644 --- a/apps/web/app/w/[workspaceSlug]/loading.tsx +++ b/apps/web/app/w/[workspaceSlug]/loading.tsx @@ -1,11 +1,11 @@ export default function WorkspaceLoading() { return ( -
-
-
-
-
-
+
+
+
+
+
+
{[0, 1, 2, 3].map((key) =>
)}
); } diff --git a/apps/web/app/w/[workspaceSlug]/page.tsx b/apps/web/app/w/[workspaceSlug]/page.tsx index 7aaf1f1..d9c0af3 100644 --- a/apps/web/app/w/[workspaceSlug]/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/page.tsx @@ -1,6 +1,162 @@ -import { redirect } from "next/navigation"; +import { + AlertTriangle, + ArrowRight, + CalendarCheck2, + CheckCircle2, + Clock3, + MessageSquareText, + Settings2, + Target, + Users, +} from "lucide-react"; +import Link from "next/link"; +import { NoosphereAxis } from "@/components/noosphere-axis"; +import { getOperationalSummary, type AttentionItem, type EngineOperationalState, type NextOutcome } from "@/lib/api"; -export default async function WorkspaceHomePage({ params }: { params: Promise<{ workspaceSlug: string }> }) { +export const metadata = { title: "Aujourd’hui — Noosphere" }; +export const dynamic = "force-dynamic"; + +export default async function TodayPage({ + params, + searchParams, +}: { + params: Promise<{ workspaceSlug: string }>; + searchParams: Promise<{ attentionCursor?: string }>; +}) { const { workspaceSlug } = await params; - redirect(`/w/${workspaceSlug}/campaigns`); + const query = await searchParams; + try { + const summary = await getOperationalSummary(workspaceSlug, { + ...(query.attentionCursor ? { attentionCursor: query.attentionCursor } : {}), + attentionLimit: 8, + }); + const stale = Date.now() - Date.parse(summary.asOf) > 5 * 60_000; + return ( + <> +
+
+

Aujourd’hui

+

Le système travaille. Voici ce qui mérite réellement votre attention.

+
+ Configuration +
+ + + + {stale ? ( +
+ +

Dernier état connu. Les données n’ont pas été actualisées depuis plus de cinq minutes ; les jobs continuent en arrière-plan.

+
+ ) : null} + + + +
+ + + + +
+ +
+ + +
+ + ); + } catch { + return ; + } +} + +function EngineBar({ inbound, outbound, workspaceSlug }: { inbound: EngineOperationalState; outbound: EngineOperationalState; workspaceSlug: string }) { + return ( +
+ +
Symbiose en attente d’Inbound
+ +
+ ); +} + +function Engine({ engine, href, align = "left" }: { engine: EngineOperationalState; href: string; align?: "left" | "right" }) { + const dotClass = engine.status === "degraded" ? "bg-amber-400" : engine.status === "not_configured" ? "bg-slate-500" : "bg-signal"; + return ( + + + {engine.label}{engine.summary} + + ); +} + +function Metric({ icon: Icon, label, value, tone }: { icon: typeof Users; label: string; value: number; tone?: "signal" }) { + return
{label}
{value}
; +} + +function AttentionPanel({ attention, nextCursor, workspaceSlug }: { attention: readonly AttentionItem[]; nextCursor: string | null; workspaceSlug: string }) { + return ( +
+

À traiter

Uniquement les exceptions qui bloquent ou changent le résultat.

{attention.length ? `${attention.length} action${attention.length > 1 ? "s" : ""}` : "Rien à traiter"}
+ {attention.length ?
{attention.map((item) => )}
: ( +

L’automatisation peut continuer seule

Aucun compte, job ou échange ne demande d’intervention.

+ )} + {nextCursor ?
Voir les suivantes
: null} +
+ ); +} + +function AttentionRow({ item, workspaceSlug }: { item: AttentionItem; workspaceSlug: string }) { + const href = workspaceHref(workspaceSlug, item.action?.href ?? item.resourceHref ?? "/"); + return ( +
+ +

{item.message}

{ageLabel(item.ageSeconds)}{item.correlationId ? ` · Réf. ${item.correlationId.slice(0, 12)}` : ""}

+ {item.action?.label ?? "Ouvrir"} +
+ ); +} + +function OutcomesPanel({ outcomes, workspaceSlug }: { outcomes: readonly NextOutcome[]; workspaceSlug: string }) { + return ( +
+

Prochains résultats

Des événements durables, pas des promesses décoratives.

Système observé
+ {outcomes.length ?
{outcomes.slice(0, 5).map((outcome) => ( + + + {outcome.label}{outcome.detail} + {outcome.expectedAt ? formatRelative(outcome.expectedAt) : "À définir"} + + ))}
:

Prochain résultat en préparation

Configurez un compte ou activez une campagne pour obtenir une prochaine échéance réelle.

} +
+ ); +} + +function TodayError({ workspaceSlug }: { workspaceSlug: string }) { + return

Le cockpit n’a pas pu être actualisé

Les moteurs continuent en arrière-plan. Réessayez sans perdre les opérations en cours.

Réessayer
; +} + +function engineHref(workspaceSlug: string, lens: "inbound" | "outbound", engine: EngineOperationalState): string { + if (engine.status === "not_configured") return `/w/${workspaceSlug}/settings`; + return `/w/${workspaceSlug}/activity?lens=${lens}`; +} + +function workspaceHref(workspaceSlug: string, href: string): string { + if (href.startsWith("/w/")) return href; + return `/w/${workspaceSlug}${href.startsWith("/") ? href : `/${href}`}`; +} + +function ageLabel(seconds: number): string { + if (seconds < 60) return "À l’instant"; + if (seconds < 3_600) return `Il y a ${Math.max(1, Math.round(seconds / 60))} min`; + if (seconds < 86_400) return `Il y a ${Math.round(seconds / 3_600)} h`; + return `Il y a ${Math.round(seconds / 86_400)} j`; +} + +function formatRelative(value: string): string { + const delta = Date.parse(value) - Date.now(); + if (delta <= 0) return "Maintenant"; + const hours = Math.round(delta / 3_600_000); + if (hours < 24) return `Dans ${Math.max(1, hours)} h`; + return `Dans ${Math.round(hours / 24)} j`; } diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx index d4fd47a..2557140 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -1,16 +1,17 @@ "use client"; import { + Activity, ArrowRight, CalendarCheck2, ChevronDown, Inbox, - Megaphone, + Home, Menu, Plus, Settings, ShieldAlert, - SlidersHorizontal, + Users, X, type LucideIcon, } from "lucide-react"; @@ -37,29 +38,36 @@ export function AppShell({ const pathname = usePathname(); const [open, setOpen] = useState(false); const baseHref = `/w/${workspace.slug}`; - const prospectionHref = `${baseHref}/campaigns`; - const messagesHref = `${baseHref}/inbox`; - const appointmentsHref = `${baseHref}/appointments`; + const todayHref = baseHref; + const activityHref = `${baseHref}/activity?lens=symbiosis`; + const prospectsHref = `${baseHref}/prospects`; + const conversationsHref = `${baseHref}/inbox`; + const callsHref = `${baseHref}/appointments`; const settingsHref = `${baseHref}/settings`; - const prospectionActive = [ - prospectionHref, - `${baseHref}/prospects`, + const todayActive = pathname === baseHref || pathname === `${baseHref}/`; + const activityActive = [ + `${baseHref}/activity`, + `${baseHref}/campaigns`, `${baseHref}/strategy`, `${baseHref}/research`, - `${baseHref}/icps`, ].some((href) => pathname.startsWith(href)); - const appointmentsActive = pathname.startsWith(appointmentsHref) || pathname.startsWith(`${baseHref}/pipeline`); + const prospectsActive = pathname.startsWith(prospectsHref) || pathname.startsWith(`${baseHref}/icps`); + const callsActive = pathname.startsWith(callsHref) || pathname.startsWith(`${baseHref}/pipeline`); const settingsActive = pathname.startsWith(settingsHref) || pathname.startsWith(`${baseHref}/offers`) || pathname.startsWith(`${baseHref}/knowledge`) || pathname.startsWith(`${baseHref}/ai-studio`); - const currentSection = pathname.startsWith(messagesHref) - ? "Messages" - : appointmentsActive - ? "Rendez-vous" + const currentSection = pathname.startsWith(conversationsHref) + ? "Conversations" + : callsActive + ? "Appels" : settingsActive ? "Configuration" - : "Prospection"; + : prospectsActive + ? "Prospects" + : activityActive + ? "Activité" + : "Aujourd’hui"; const initials = session.user.name .split(/\s+/) .map((part) => part[0]) @@ -75,10 +83,10 @@ export function AppShell({ @@ -144,11 +153,12 @@ export function AppShell({ {children} -
diff --git a/apps/web/components/noosphere-axis.tsx b/apps/web/components/noosphere-axis.tsx new file mode 100644 index 0000000..22814df --- /dev/null +++ b/apps/web/components/noosphere-axis.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import type { NoosphereLens } from "@/lib/api"; + +const lenses: readonly { readonly value: NoosphereLens; readonly label: string }[] = [ + { value: "inbound", label: "Inbound" }, + { value: "symbiosis", label: "Symbiose" }, + { value: "outbound", label: "Outbound" }, +]; + +export function NoosphereAxis({ + workspaceSlug, + lens, + searchParams = {}, +}: { + readonly workspaceSlug: string; + readonly lens: NoosphereLens; + readonly searchParams?: Readonly>; +}) { + return ( + + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 574c290..4276d18 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -56,16 +56,60 @@ export type AttentionItem = { readonly resourceHref: string | null; readonly ageSeconds: number; readonly action: { readonly label: string; readonly href: string } | null; + readonly correlationId: string | null; readonly createdAt: string; }; +export type NoosphereLens = "inbound" | "symbiosis" | "outbound"; +export type EngineOperationalStatus = "not_configured" | "idle" | "running" | "degraded" | "paused"; +export interface EngineOperationalState { + readonly status: EngineOperationalStatus; + readonly label: string; + readonly summary: string; + readonly lastActivityAt: string | null; + readonly nextAction: { readonly label: string; readonly href: string } | null; +} + +export interface NextOutcome { + readonly id: string; + readonly type: "publication" | "research" | "conversation" | "call"; + readonly source: "inbound" | "outbound" | "mixed" | "unknown"; + readonly label: string; + readonly detail: string; + readonly expectedAt: string | null; + readonly href: string; +} + export interface WorkspaceOperationalSummary { readonly asOf: string; - readonly counts: { readonly activeCampaigns: number; readonly prospects: number; readonly openConversations: number; readonly openOpportunities: number; readonly attention: number }; + readonly counts: { readonly activeCampaigns: number; readonly prospects: number; readonly openConversations: number; readonly openOpportunities: number; readonly bookedCalls: number; readonly attention: number }; readonly attention: readonly AttentionItem[]; readonly jobs: { readonly active: number; readonly failed: number; readonly running: readonly { readonly id: string; readonly type: string; readonly status: string; readonly updatedAt: string }[] }; readonly nextAutomaticResearch: string | null; readonly accountHealth: { readonly connected: number; readonly degraded: number; readonly disconnected: number; readonly activeAlerts: number }; + readonly engines: { readonly inbound: EngineOperationalState; readonly outbound: EngineOperationalState }; + readonly nextOutcomes: readonly NextOutcome[]; + readonly attentionPagination: { readonly nextCursor: string | null }; +} + +export interface ActivityWorkspacePage { + readonly lens: NoosphereLens; + readonly asOf: string; + readonly state: "not_configured" | "idle" | "active" | "attention"; + readonly headline: string; + readonly counters: readonly { readonly key: string; readonly label: string; readonly value: number }[]; + readonly items: readonly { + readonly id: string; + readonly kind: "campaign" | "job" | "conversation" | "call" | "publication" | "signal"; + readonly source: "inbound" | "outbound" | "mixed" | "unknown"; + readonly status: "pending" | "running" | "completed" | "attention"; + readonly title: string; + readonly detail: string; + readonly occurredAt: string; + readonly href: string; + readonly correlationId: string | null; + }[]; + readonly pagination: { readonly nextCursor: string | null }; } export interface SetupReadinessItem { @@ -127,8 +171,17 @@ export interface WorkspaceConversationPage { }; } -export async function getOperationalSummary(workspaceSlug: string): Promise { - return crmFetch(workspaceSlug, "/api/v1/workspace/operational-summary"); +export async function getOperationalSummary(workspaceSlug: string, input: { attentionCursor?: string; attentionLimit?: number } = {}): Promise { + const params = new URLSearchParams(); + if (input.attentionCursor) params.set("attentionCursor", input.attentionCursor); + if (input.attentionLimit) params.set("attentionLimit", String(input.attentionLimit)); + return crmFetch(workspaceSlug, `/api/v1/workspace/operational-summary${params.size ? `?${params}` : ""}`); +} + +export async function getActivity(workspaceSlug: string, lens: NoosphereLens, cursor?: string): Promise { + const params = new URLSearchParams({ lens }); + if (cursor) params.set("cursor", cursor); + return crmFetch(workspaceSlug, `/api/v1/activity?${params}`); } export async function getSetupReadiness(workspaceSlug: string): Promise { diff --git a/bun.lock b/bun.lock index 274bc79..1d8ee48 100644 --- a/bun.lock +++ b/bun.lock @@ -22,10 +22,12 @@ "zod": "^4.4.3", }, "devDependencies": { + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.3.3", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "dotenv": "^17.0.0", "drizzle-kit": "^0.31.10", "postcss": "^8.5.23", "tailwindcss": "^4.3.3", @@ -256,6 +258,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@smithy/core": ["@smithy/core@3.30.0", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.14", "", { "dependencies": { "@smithy/core": "^3.30.0", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ=="], @@ -340,6 +344,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], @@ -356,7 +362,7 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], @@ -440,6 +446,10 @@ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], @@ -512,6 +522,8 @@ "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index cf21437..7693789 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "check:crawler": "cd apps/crawler && uv run --extra dev pytest tests -q", "build:web": "next build apps/web && bun scripts/prepare-web-standalone.ts", "test:integration": "bun scripts/run-integration-tests.ts", + "test:e2e": "bun run bootstrap:owner && playwright test", "db:generate": "drizzle-kit generate", "db:migrate": "bun packages/infrastructure/src/database/migrate.ts", "bootstrap:owner": "bun scripts/bootstrap-owner.ts", @@ -50,10 +51,12 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.3.3", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "dotenv": "^17.0.0", "drizzle-kit": "^0.31.10", "postcss": "^8.5.23", "tailwindcss": "^4.3.3", diff --git a/packages/application/src/workspaces/operational-views.ts b/packages/application/src/workspaces/operational-views.ts index 08af763..76c594a 100644 --- a/packages/application/src/workspaces/operational-views.ts +++ b/packages/application/src/workspaces/operational-views.ts @@ -1,5 +1,28 @@ export type AttentionSeverity = "info" | "warning" | "critical"; +export const noosphereLenses = ["inbound", "symbiosis", "outbound"] as const; +export type NoosphereLens = (typeof noosphereLenses)[number]; + +export type EngineOperationalStatus = "not_configured" | "idle" | "running" | "degraded" | "paused"; + +export interface EngineOperationalState { + readonly status: EngineOperationalStatus; + readonly label: string; + readonly summary: string; + readonly lastActivityAt: Date | null; + readonly nextAction: { readonly label: string; readonly href: string } | null; +} + +export interface NextOutcome { + readonly id: string; + readonly type: "publication" | "research" | "conversation" | "call"; + readonly source: "inbound" | "outbound" | "mixed" | "unknown"; + readonly label: string; + readonly detail: string; + readonly expectedAt: Date | null; + readonly href: string; +} + export interface AttentionItem { readonly id: string; readonly type: "account" | "job" | "campaign" | "decision" | "conversation"; @@ -9,6 +32,7 @@ export interface AttentionItem { readonly resourceHref: string | null; readonly ageSeconds: number; readonly action: { readonly label: string; readonly href: string } | null; + readonly correlationId: string | null; readonly createdAt: Date; } @@ -19,6 +43,7 @@ export interface WorkspaceOperationalSummary { readonly prospects: number; readonly openConversations: number; readonly openOpportunities: number; + readonly bookedCalls: number; readonly attention: number; }; readonly attention: readonly AttentionItem[]; @@ -34,6 +59,37 @@ export interface WorkspaceOperationalSummary { readonly disconnected: number; readonly activeAlerts: number; }; + /** Compatibility fields above remain available for one release. */ + readonly engines: { + readonly inbound: EngineOperationalState; + readonly outbound: EngineOperationalState; + }; + readonly nextOutcomes: readonly NextOutcome[]; + readonly attentionPagination: { readonly nextCursor: string | null }; +} + +export type ActivityItemKind = "campaign" | "job" | "conversation" | "call" | "publication" | "signal"; + +export interface ActivityItem { + readonly id: string; + readonly kind: ActivityItemKind; + readonly source: "inbound" | "outbound" | "mixed" | "unknown"; + readonly status: "pending" | "running" | "completed" | "attention"; + readonly title: string; + readonly detail: string; + readonly occurredAt: Date; + readonly href: string; + readonly correlationId: string | null; +} + +export interface ActivityWorkspacePage { + readonly lens: NoosphereLens; + readonly asOf: Date; + readonly state: "not_configured" | "idle" | "active" | "attention"; + readonly headline: string; + readonly counters: readonly { readonly key: string; readonly label: string; readonly value: number }[]; + readonly items: readonly ActivityItem[]; + readonly pagination: { readonly nextCursor: string | null }; } export type SetupReadinessState = "ready" | "optional" | "attention" | "missing"; diff --git a/packages/contracts/openapi/product-research-v1.json b/packages/contracts/openapi/product-research-v1.json index aadb234..d545110 100644 --- a/packages/contracts/openapi/product-research-v1.json +++ b/packages/contracts/openapi/product-research-v1.json @@ -1474,7 +1474,11 @@ }, "/api/v1/workspace/operational-summary": { "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], - "get": { "operationId": "getWorkspaceOperationalSummary", "summary": "Read the tenant-scoped À traiter projection", "responses": { "200": { "description": "Operational counters, exceptions, jobs and account health", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceOperationalSummary" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } + "get": { "operationId": "getWorkspaceOperationalSummary", "summary": "Read the tenant-scoped À traiter projection", "parameters": [{ "name": "attentionCursor", "in": "query", "schema": { "type": "string" } }, { "name": "attentionLimit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } }], "responses": { "200": { "description": "Operational counters, engines, outcomes, exceptions, jobs and account health", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceOperationalSummary" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } + }, + "/api/v1/activity": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "get": { "operationId": "getNoosphereActivity", "summary": "Read one Noosphere lens without triggering a command", "parameters": [{ "name": "lens", "in": "query", "schema": { "$ref": "#/components/schemas/NoosphereLens", "default": "symbiosis" } }, { "name": "cursor", "in": "query", "schema": { "type": "string" } }, { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 } }], "responses": { "200": { "description": "Paginated activity projection", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivityWorkspacePage" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } }, "/api/v1/workspace/setup-readiness": { "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], @@ -2934,21 +2938,29 @@ "required": ["id", "type", "severity", "message", "resourceId", "ageSeconds", "createdAt"], "properties": { "id": { "type": "string" }, "type": { "type": "string", "enum": ["account", "job", "campaign", "decision", "conversation"] }, - "severity": { "type": "string", "enum": ["info", "warning", "critical"] }, "message": { "type": "string" }, "resourceId": { "type": "string" }, + "severity": { "type": "string", "enum": ["info", "warning", "critical"] }, "message": { "type": "string" }, "resourceId": { "type": ["string", "null"] }, "resourceHref": { "type": ["string", "null"] }, "ageSeconds": { "type": "integer", "minimum": 0 }, "action": { "type": ["object", "null"], "properties": { "label": { "type": "string" }, "href": { "type": "string" } } }, + "correlationId": { "type": ["string", "null"] }, "createdAt": { "type": "string", "format": "date-time" } } }, "WorkspaceOperationalSummary": { - "type": "object", "required": ["asOf", "counts", "attention", "jobs", "nextAutomaticResearch", "accountHealth"], + "type": "object", "required": ["asOf", "counts", "attention", "jobs", "nextAutomaticResearch", "accountHealth", "engines", "nextOutcomes", "attentionPagination"], "properties": { - "asOf": { "type": "string", "format": "date-time" }, "counts": { "type": "object", "required": ["activeCampaigns", "prospects", "openConversations", "openOpportunities", "attention"], "additionalProperties": { "type": "integer", "minimum": 0 } }, + "asOf": { "type": "string", "format": "date-time" }, "counts": { "type": "object", "required": ["activeCampaigns", "prospects", "openConversations", "openOpportunities", "bookedCalls", "attention"], "additionalProperties": { "type": "integer", "minimum": 0 } }, "attention": { "type": "array", "items": { "$ref": "#/components/schemas/AttentionItem" } }, "jobs": { "type": "object", "required": ["active", "failed", "running"], "properties": { "active": { "type": "integer" }, "failed": { "type": "integer" }, "running": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } }, - "nextAutomaticResearch": { "type": ["string", "null"], "format": "date-time" }, "accountHealth": { "type": "object", "additionalProperties": { "type": "integer" } } + "nextAutomaticResearch": { "type": ["string", "null"], "format": "date-time" }, "accountHealth": { "type": "object", "additionalProperties": { "type": "integer" } }, + "engines": { "type": "object", "required": ["inbound", "outbound"], "properties": { "inbound": { "$ref": "#/components/schemas/EngineOperationalState" }, "outbound": { "$ref": "#/components/schemas/EngineOperationalState" } } }, + "nextOutcomes": { "type": "array", "items": { "$ref": "#/components/schemas/NextOutcome" } }, + "attentionPagination": { "type": "object", "required": ["nextCursor"], "properties": { "nextCursor": { "type": ["string", "null"] } } } } }, + "NoosphereLens": { "type": "string", "enum": ["inbound", "symbiosis", "outbound"] }, + "EngineOperationalState": { "type": "object", "required": ["status", "label", "summary", "lastActivityAt", "nextAction"], "properties": { "status": { "type": "string", "enum": ["not_configured", "idle", "running", "degraded", "paused"] }, "label": { "type": "string" }, "summary": { "type": "string" }, "lastActivityAt": { "type": ["string", "null"], "format": "date-time" }, "nextAction": { "type": ["object", "null"] } } }, + "NextOutcome": { "type": "object", "required": ["id", "type", "source", "label", "detail", "expectedAt", "href"], "properties": { "id": { "type": "string" }, "type": { "type": "string", "enum": ["publication", "research", "conversation", "call"] }, "source": { "type": "string", "enum": ["inbound", "outbound", "mixed", "unknown"] }, "label": { "type": "string" }, "detail": { "type": "string" }, "expectedAt": { "type": ["string", "null"], "format": "date-time" }, "href": { "type": "string" } } }, + "ActivityWorkspacePage": { "type": "object", "required": ["lens", "asOf", "state", "headline", "counters", "items", "pagination"], "properties": { "lens": { "$ref": "#/components/schemas/NoosphereLens" }, "asOf": { "type": "string", "format": "date-time" }, "state": { "type": "string", "enum": ["not_configured", "idle", "active", "attention"] }, "headline": { "type": "string" }, "counters": { "type": "array", "items": { "type": "object", "required": ["key", "label", "value"] } }, "items": { "type": "array", "items": { "type": "object", "required": ["id", "kind", "source", "status", "title", "detail", "occurredAt", "href", "correlationId"] } }, "pagination": { "type": "object", "required": ["nextCursor"], "properties": { "nextCursor": { "type": ["string", "null"] } } } } }, "SetupReadinessView": { "type": "object", "required": ["ready", "asOf", "items"], "properties": { "ready": { "type": "boolean" }, "asOf": { "type": "string", "format": "date-time" }, "items": { "type": "array", "items": { "type": "object", "required": ["key", "label", "state", "reason", "requiredForLaunch"], "properties": { "key": { "type": "string" }, "label": { "type": "string" }, "state": { "type": "string", "enum": ["ready", "optional", "attention", "missing"] }, "reason": { "type": "string" }, "action": { "type": ["object", "null"] }, "requiredForLaunch": { "type": "boolean" } } } } } diff --git a/packages/infrastructure/src/workspaces/postgres-operational-views.ts b/packages/infrastructure/src/workspaces/postgres-operational-views.ts index 566e411..d64ea14 100644 --- a/packages/infrastructure/src/workspaces/postgres-operational-views.ts +++ b/packages/infrastructure/src/workspaces/postgres-operational-views.ts @@ -1,16 +1,19 @@ -import { and, count, desc, eq, sql } from "drizzle-orm"; +import { and, count, desc, eq, gte, sql } from "drizzle-orm"; import type { + ActivityWorkspacePage, CampaignWorkspaceView, ConversationWorkspacePage, ConversationWorkspaceDetail, ConversationWorkspaceView, SetupReadinessView, + NoosphereLens, WorkspaceOperationalSummary, } from "@outbound/application/workspaces/operational-views"; import type { Database } from "@outbound/infrastructure/database/client"; import { accountHealthAlerts, aiPolicyVersions, + calendarBookings, calendarConnections, campaigns, campaignProspects, @@ -46,52 +49,215 @@ export class PostgresOperationalViews { this.opportunitiesRepository = new PostgresOpportunityRepository(database); } - async getSummary(workspaceId: string): Promise { + async getSummary(workspaceId: string, input: { attentionOffset?: number; attentionLimit?: number } = {}): Promise { const asOf = new Date(); - const [campaignCount, prospectCount, conversationCount, opportunityCount, jobsRows, failedJobs, deadLetterRows, schedule, accountRows, alertCount, accountAttention, campaignAttention, pendingDecisions] = await Promise.all([ + const attentionOffset = input.attentionOffset ?? 0; + const attentionLimit = input.attentionLimit ?? 20; + const attentionQueryLimit = attentionOffset + attentionLimit + 1; + const [ + campaignCount, + prospectCount, + conversationCount, + opportunityCount, + bookedCallCount, + nextBooking, + activeJobCount, + jobsRows, + failedJobs, + deadLetterRows, + retryJobCount, + retryJobRows, + schedule, + accountRows, + alertCount, + accountAttention, + campaignAttentionCount, + campaignAttention, + pendingDecisionCount, + pendingDecisions, + ] = await Promise.all([ this.database.select({ value: count() }).from(campaigns).where(and(eq(campaigns.workspaceId, workspaceId), eq(campaigns.status, "active"))), this.database.select({ value: count() }).from(contacts).where(and(eq(contacts.workspaceId, workspaceId), eq(contacts.status, "active"))), this.database.select({ value: count() }).from(conversations).where(and(eq(conversations.workspaceId, workspaceId), sql`${conversations.status} <> 'closed'`)), this.database.select({ value: count() }).from(opportunities).where(and(eq(opportunities.workspaceId, workspaceId), sql`${opportunities.stage} not in ('won', 'lost')`)), - this.database.select({ id: jobs.id, type: jobs.type, status: jobs.status, updatedAt: jobs.updatedAt }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), sql`${jobs.status} in ('pending', 'running', 'retry')`)).orderBy(desc(jobs.updatedAt)).limit(10), + this.database.select({ value: count() }).from(calendarBookings).where(and(eq(calendarBookings.workspaceId, workspaceId), sql`${calendarBookings.status} in ('requested', 'booked', 'rescheduled')`, gte(calendarBookings.startAt, asOf))), + this.database.select({ id: calendarBookings.id, attendeeName: calendarBookings.attendeeName, startAt: calendarBookings.startAt }).from(calendarBookings).where(and(eq(calendarBookings.workspaceId, workspaceId), sql`${calendarBookings.status} in ('requested', 'booked', 'rescheduled')`, gte(calendarBookings.startAt, asOf))).orderBy(calendarBookings.startAt).limit(1), + this.database.select({ value: count() }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), sql`${jobs.status} in ('pending', 'running', 'retry')`)), + this.database.select({ id: jobs.id, type: jobs.type, status: jobs.status, correlationId: jobs.correlationId, updatedAt: jobs.updatedAt }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), sql`${jobs.status} in ('pending', 'running', 'retry')`)).orderBy(desc(jobs.updatedAt)).limit(10), this.database.select({ value: count() }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), eq(jobs.status, "dead_lettered"))), - this.database.select({ id: jobs.id, type: jobs.type, updatedAt: jobs.updatedAt }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), eq(jobs.status, "dead_lettered"))).orderBy(desc(jobs.updatedAt)).limit(10), + this.database.select({ id: jobs.id, type: jobs.type, correlationId: jobs.correlationId, updatedAt: jobs.updatedAt }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), eq(jobs.status, "dead_lettered"))).orderBy(desc(jobs.updatedAt)).limit(attentionQueryLimit), + this.database.select({ value: count() }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), eq(jobs.status, "retry"))), + this.database.select({ id: jobs.id, type: jobs.type, correlationId: jobs.correlationId, updatedAt: jobs.updatedAt }).from(jobs).where(and(eq(jobs.workspaceId, workspaceId), eq(jobs.status, "retry"))).orderBy(desc(jobs.updatedAt)).limit(attentionQueryLimit), this.database.select({ nextRunAt: dailyProspectingSchedules.nextRunAt }).from(dailyProspectingSchedules).where(and(eq(dailyProspectingSchedules.workspaceId, workspaceId), eq(dailyProspectingSchedules.enabled, true))).limit(1), this.database.select({ status: connectedAccounts.status }).from(connectedAccounts).where(eq(connectedAccounts.workspaceId, workspaceId)), this.database.select({ value: count() }).from(accountHealthAlerts).where(and(eq(accountHealthAlerts.workspaceId, workspaceId), sql`${accountHealthAlerts.status} in ('active', 'acknowledged')`)), - this.database.select({ id: accountHealthAlerts.id, connectedAccountId: accountHealthAlerts.connectedAccountId, reason: accountHealthAlerts.reasonMessage, createdAt: accountHealthAlerts.createdAt }).from(accountHealthAlerts).where(and(eq(accountHealthAlerts.workspaceId, workspaceId), sql`${accountHealthAlerts.status} in ('active', 'acknowledged')`)).orderBy(desc(accountHealthAlerts.createdAt)).limit(10), - this.database.select({ id: campaigns.id, name: campaigns.name, updatedAt: campaigns.updatedAt, errorMessage: campaigns.automationErrorMessage }).from(campaigns).where(and(eq(campaigns.workspaceId, workspaceId), eq(campaigns.automationStage, "attention"))).orderBy(desc(campaigns.updatedAt)).limit(10), - this.database.select({ id: prospectDecisions.id, reason: prospectDecisions.reason, contactId: prospectDecisions.contactId, createdAt: prospectDecisions.createdAt }).from(prospectDecisions).where(and(eq(prospectDecisions.workspaceId, workspaceId), eq(prospectDecisions.status, "pending"))).orderBy(desc(prospectDecisions.priority), desc(prospectDecisions.createdAt)).limit(10), + this.database.select({ id: accountHealthAlerts.id, connectedAccountId: accountHealthAlerts.connectedAccountId, reason: accountHealthAlerts.reasonMessage, createdAt: accountHealthAlerts.createdAt }).from(accountHealthAlerts).where(and(eq(accountHealthAlerts.workspaceId, workspaceId), sql`${accountHealthAlerts.status} in ('active', 'acknowledged')`)).orderBy(desc(accountHealthAlerts.createdAt)).limit(attentionQueryLimit), + this.database.select({ value: count() }).from(campaigns).where(and(eq(campaigns.workspaceId, workspaceId), eq(campaigns.automationStage, "attention"))), + this.database.select({ id: campaigns.id, name: campaigns.name, updatedAt: campaigns.updatedAt, errorMessage: campaigns.automationErrorMessage }).from(campaigns).where(and(eq(campaigns.workspaceId, workspaceId), eq(campaigns.automationStage, "attention"))).orderBy(desc(campaigns.updatedAt)).limit(attentionQueryLimit), + this.database.select({ value: count() }).from(prospectDecisions).where(and(eq(prospectDecisions.workspaceId, workspaceId), eq(prospectDecisions.status, "pending"))), + this.database.select({ id: prospectDecisions.id, reason: prospectDecisions.reason, contactId: prospectDecisions.contactId, correlationId: prospectDecisions.correlationId, createdAt: prospectDecisions.createdAt }).from(prospectDecisions).where(and(eq(prospectDecisions.workspaceId, workspaceId), eq(prospectDecisions.status, "pending"))).orderBy(desc(prospectDecisions.priority), desc(prospectDecisions.createdAt)).limit(attentionQueryLimit), ]); - const attention = [ - ...campaignAttention.map((item) => attentionItem("campaign", "critical", item.id, item.errorMessage ?? `La campagne ${item.name} nécessite une attention.`, item.updatedAt, `/campaigns/${item.id}`)), - ...accountAttention.map((item) => attentionItem("account", "critical", item.id, item.reason ?? "Un compte d’envoi nécessite une reconnexion.", item.createdAt, "/settings/channels")), - ...deadLetterRows.map((job) => attentionItem("job", "critical", job.id, `Le job ${job.type} est en dead letter et nécessite un diagnostic.`, job.updatedAt, "/settings/console")), - ...jobsRows.filter((job) => job.status === "retry").map((job) => attentionItem("job", "warning", job.id, `Le job ${job.type} sera retenté automatiquement.`, job.updatedAt, "/settings/console")), - ...pendingDecisions.map((item) => attentionItem("decision", "info", item.id, item.reason, item.createdAt, "/prospects")), - ].slice(0, 20); + const allAttention = [ + ...campaignAttention.map((item) => attentionItem("campaign", "critical", item.id, item.errorMessage ?? `La campagne ${item.name} nécessite une attention.`, item.updatedAt, `/campaigns/${item.id}`, null)), + ...accountAttention.map((item) => attentionItem("account", "critical", item.id, item.reason ?? "Un compte d’envoi nécessite une reconnexion.", item.createdAt, "/settings/channels", null)), + ...deadLetterRows.map((job) => attentionItem("job", "critical", job.id, `Le job ${job.type} est en dead letter et nécessite un diagnostic.`, job.updatedAt, "/settings/console", job.correlationId)), + ...retryJobRows.map((job) => attentionItem("job", "warning", job.id, `Le job ${job.type} sera retenté automatiquement.`, job.updatedAt, "/settings/console", job.correlationId)), + ...pendingDecisions.map((item) => attentionItem("decision", "info", item.id, item.reason, item.createdAt, "/prospects", item.correlationId)), + ].sort(compareAttention); + const attentionPage = allAttention.slice(attentionOffset, attentionOffset + attentionLimit + 1); + const hasMoreAttention = attentionPage.length > attentionLimit; + const attention = attentionPage.slice(0, attentionLimit); const statuses = accountRows.map((row) => row.status); const connected = statuses.filter((status) => status === "connected").length; const degraded = statuses.filter((status) => status === "degraded" || status === "unknown" || status === "pending").length; const disconnected = statuses.filter((status) => status === "disconnected").length; + const activeCampaigns = valueOf(campaignCount); + const activeJobs = valueOf(activeJobCount); + const failed = valueOf(failedJobs); + const attentionTotal = valueOf(alertCount) + + valueOf(failedJobs) + + valueOf(retryJobCount) + + valueOf(campaignAttentionCount) + + valueOf(pendingDecisionCount); + const outboundStatus = degraded + disconnected + valueOf(alertCount) + failed > 0 + ? "degraded" + : activeJobs > 0 || activeCampaigns > 0 + ? "running" + : "idle"; + const lastJobActivity = jobsRows[0]?.updatedAt ?? null; + const nextAutomaticResearch = schedule[0]?.nextRunAt ?? null; + const nextOutcomes = [ + ...(nextAutomaticResearch ? [{ + id: "outbound:next-research", + type: "research" as const, + source: "outbound" as const, + label: "Prochaine recherche de prospects", + detail: "Les campagnes éligibles seront alimentées automatiquement.", + expectedAt: nextAutomaticResearch, + href: "/activity?lens=outbound", + }] : []), + ...(nextBooking[0] ? [{ + id: `call:${nextBooking[0].id}`, + type: "call" as const, + source: "unknown" as const, + label: nextBooking[0].attendeeName ? `Appel avec ${nextBooking[0].attendeeName}` : "Prochain appel", + detail: "Rendez-vous confirmé dans l’agenda connecté.", + expectedAt: nextBooking[0].startAt, + href: "/appointments", + }] : []), + ].sort((left, right) => (left.expectedAt?.getTime() ?? Number.MAX_SAFE_INTEGER) - (right.expectedAt?.getTime() ?? Number.MAX_SAFE_INTEGER)); return { asOf, counts: { - activeCampaigns: valueOf(campaignCount), + activeCampaigns, prospects: valueOf(prospectCount), openConversations: valueOf(conversationCount), openOpportunities: valueOf(opportunityCount), - attention: attention.length, + bookedCalls: valueOf(bookedCallCount), + attention: attentionTotal, }, attention, jobs: { - active: jobsRows.length, - failed: valueOf(failedJobs), + active: activeJobs, + failed, running: jobsRows.map((job) => ({ id: job.id, type: job.type, status: job.status, updatedAt: job.updatedAt })), }, - nextAutomaticResearch: schedule[0]?.nextRunAt ?? null, + nextAutomaticResearch, accountHealth: { connected, degraded, disconnected, activeAlerts: valueOf(alertCount) }, + engines: { + inbound: { + status: "not_configured", + label: "Inbound à configurer", + summary: "La stratégie éditoriale LinkedIn sera disponible au Lot 2.", + lastActivityAt: null, + nextAction: { label: "Voir la configuration", href: "/settings" }, + }, + outbound: { + status: outboundStatus, + label: outboundStatus === "degraded" ? "Outbound nécessite une attention" : outboundStatus === "running" ? "Outbound actif" : "Outbound en veille", + summary: `${activeCampaigns} campagne${activeCampaigns === 1 ? "" : "s"} active${activeCampaigns === 1 ? "" : "s"} · ${activeJobs} job${activeJobs === 1 ? "" : "s"} en cours`, + lastActivityAt: lastJobActivity, + nextAction: outboundStatus === "degraded" ? { label: "Voir les exceptions", href: "/?attention=1" } : { label: "Voir l’activité", href: "/activity?lens=outbound" }, + }, + }, + nextOutcomes, + attentionPagination: { nextCursor: hasMoreAttention ? String(attentionOffset + attentionLimit) : null }, + }; + } + + async getActivity(input: { workspaceId: string; lens: NoosphereLens; offset?: number; limit?: number }): Promise { + const asOf = new Date(); + const offset = input.offset ?? 0; + const limit = input.limit ?? 25; + if (input.lens === "inbound") { + return { + lens: input.lens, + asOf, + state: "not_configured", + headline: "La création de demande LinkedIn arrive dans le prochain lot.", + counters: [ + { key: "ideas", label: "Idées sourcées", value: 0 }, + { key: "planned", label: "Contenus planifiés", value: 0 }, + { key: "published", label: "Contenus publiés", value: 0 }, + ], + items: [], + pagination: { nextCursor: null }, + }; + } + if (input.lens === "symbiosis") { + const summary = await this.getSummary(input.workspaceId, { attentionLimit: 1 }); + return { + lens: input.lens, + asOf, + state: "not_configured", + headline: "La Symbiose s’activera après la première publication Inbound attribuable.", + counters: [ + { key: "signals", label: "Signaux partagés", value: 0 }, + { key: "conversations", label: "Conversations ouvertes", value: summary.counts.openConversations }, + { key: "calls", label: "Appels réservés", value: summary.counts.bookedCalls }, + ], + items: [], + pagination: { nextCursor: null }, + }; + } + const summary = await this.getSummary(input.workspaceId, { attentionLimit: 1 }); + const rows = await this.database.select({ + id: campaigns.id, + name: campaigns.name, + status: campaigns.status, + automationStage: campaigns.automationStage, + updatedAt: campaigns.updatedAt, + }).from(campaigns).where(eq(campaigns.workspaceId, input.workspaceId)).orderBy(desc(campaigns.updatedAt)).limit(limit + 1).offset(offset); + const hasNext = rows.length > limit; + const items = rows.slice(0, limit).map((campaign) => ({ + id: `campaign:${campaign.id}`, + kind: "campaign" as const, + source: "outbound" as const, + status: campaign.automationStage === "attention" + ? "attention" as const + : campaign.status === "active" + ? "running" as const + : "completed" as const, + title: campaign.name, + detail: `${campaign.automationStage ?? "configuration"} · ${campaign.status}`, + occurredAt: campaign.updatedAt, + href: `/campaigns/${campaign.id}`, + correlationId: null, + })); + return { + lens: input.lens, + asOf, + state: summary.engines.outbound.status === "degraded" ? "attention" : summary.engines.outbound.status === "running" ? "active" : "idle", + headline: summary.engines.outbound.summary, + counters: [ + { key: "campaigns", label: "Campagnes actives", value: summary.counts.activeCampaigns }, + { key: "prospects", label: "Prospects", value: summary.counts.prospects }, + { key: "jobs", label: "Jobs en cours", value: summary.jobs.active }, + { key: "conversations", label: "Conversations", value: summary.counts.openConversations }, + ], + items, + pagination: { nextCursor: hasNext ? String(offset + limit) : null }, }; } @@ -325,7 +491,7 @@ function valueOf(row: readonly [{ value: number | string }] | readonly { value: return Number(row[0]?.value ?? 0); } -function attentionItem(type: "account" | "job" | "campaign" | "decision" | "conversation", severity: "info" | "warning" | "critical", resourceId: string, message: string, createdAt: Date, href: string) { +function attentionItem(type: "account" | "job" | "campaign" | "decision" | "conversation", severity: "info" | "warning" | "critical", resourceId: string, message: string, createdAt: Date, href: string, correlationId: string | null) { return { id: `${type}:${resourceId}`, type, @@ -335,10 +501,17 @@ function attentionItem(type: "account" | "job" | "campaign" | "decision" | "conv resourceHref: href, ageSeconds: Math.max(0, Math.round((Date.now() - createdAt.getTime()) / 1000)), action: { label: severity === "critical" ? "Diagnostiquer" : "Ouvrir", href }, + correlationId, createdAt, } as const; } +function compareAttention(left: ReturnType, right: ReturnType): number { + const severityRank = { critical: 3, warning: 2, info: 1 } as const; + const riskOrder = severityRank[right.severity] - severityRank[left.severity]; + return riskOrder || left.createdAt.getTime() - right.createdAt.getTime(); +} + function readiness(key: "product" | "icp" | "accounts" | "automation" | "calendar" | "knowledge", label: string, ready: boolean, reason: string, href: string, requiredForLaunch: boolean, readyReason?: string) { return { key, diff --git a/packages/interface/src/http/operational-view-handler.ts b/packages/interface/src/http/operational-view-handler.ts index b75cbd0..e7c572a 100644 --- a/packages/interface/src/http/operational-view-handler.ts +++ b/packages/interface/src/http/operational-view-handler.ts @@ -7,18 +7,21 @@ import { WorkspaceContextRequiredError, } from "@outbound/interface/http/request-context"; import type { + ActivityWorkspacePage, CampaignWorkspaceView, ConversationWorkspacePage, ConversationWorkspaceDetail, SetupReadinessView, WorkspaceOperationalSummary, } from "@outbound/application/workspaces/operational-views"; +import { noosphereLenses, type NoosphereLens } from "@outbound/application/workspaces/operational-views"; const campaignViewPath = /^\/api\/v1\/campaigns\/([^/]+)\/workspace-view$/; const conversationViewPath = /^\/api\/v1\/conversations\/([^/]+)$/; export type OperationalViewsPort = { - getSummary(workspaceId: string): Promise; + getSummary(workspaceId: string, input?: { attentionOffset?: number; attentionLimit?: number }): Promise; + getActivity(input: { workspaceId: string; lens: NoosphereLens; offset?: number; limit?: number }): Promise; getSetupReadiness(workspaceId: string): Promise; getCampaignView(workspaceId: string, campaignId: string): Promise; listConversations(input: { workspaceId: string; channel?: string; scope?: string; search?: string; period?: string; read?: string; campaignId?: string; page: number; pageSize: number }): Promise; @@ -39,7 +42,15 @@ export function createOperationalViewHttpHandler(input: { requireViewer(context.role); if (request.method !== "GET") return problem(405, "METHOD_NOT_ALLOWED", "Only GET is supported"); if (url.pathname === "/api/v1/workspace/operational-summary") { - return Response.json(await views.getSummary(context.workspaceId)); + const attentionOffset = parseCursor(url.searchParams.get("attentionCursor")); + const attentionLimit = parsePositiveInt(url.searchParams.get("attentionLimit"), 20, 100); + return Response.json(await views.getSummary(context.workspaceId, { attentionOffset, attentionLimit })); + } + if (url.pathname === "/api/v1/activity") { + const lens = z.enum(noosphereLenses).default("symbiosis").parse(url.searchParams.get("lens") || undefined); + const offset = parseCursor(url.searchParams.get("cursor")); + const limit = parsePositiveInt(url.searchParams.get("limit"), 25, 100); + return Response.json(await views.getActivity({ workspaceId: context.workspaceId, lens, offset, limit })); } if (url.pathname === "/api/v1/workspace/setup-readiness") { return Response.json(await views.getSetupReadiness(context.workspaceId)); @@ -97,6 +108,13 @@ function parsePositiveInt(value: string | null, fallback: number, max: number): return parsed; } +function parseCursor(value: string | null): number { + if (!value) return 0; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 1_000_000) throw new Error("INVALID_PAGINATION"); + return parsed; +} + function requireViewer(role: string): void { if (!["viewer", "operator", "reviewer", "admin", "owner"].includes(role)) throw new WorkspaceAccessDeniedError(); } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f39b3fb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import "dotenv/config"; +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:3000", + browserName: "chromium", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "desktop-1440", use: { viewport: { width: 1440, height: 900 } } }, + { name: "mobile-390", use: { viewport: { width: 390, height: 844 }, isMobile: true } }, + ], + webServer: { + command: "bun scripts/start-e2e-server.ts", + url: "http://localhost:3000/login", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/start-e2e-server.ts b/scripts/start-e2e-server.ts new file mode 100644 index 0000000..7bd3423 --- /dev/null +++ b/scripts/start-e2e-server.ts @@ -0,0 +1,18 @@ +const processes = [ + { name: "api", process: Bun.spawn(["bun", "apps/api/src/index.ts"], { cwd: import.meta.dir + "/..", env: process.env, stdout: "inherit", stderr: "inherit" }) }, + { name: "web", process: Bun.spawn(["bun", "run", "web"], { cwd: import.meta.dir + "/..", env: process.env, stdout: "inherit", stderr: "inherit" }) }, +]; + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + for (const child of processes) child.process.kill(signal); + }); +} + +const completed = await Promise.race(processes.map(async (child) => ({ name: child.name, exitCode: await child.process.exited }))); +for (const child of processes) if (child.name !== completed.name) child.process.kill("SIGTERM"); +await Promise.all(processes.map((child) => child.process.exited)); +if (completed.exitCode !== 0) { + console.error(`${completed.name} exited with code ${completed.exitCode}`); + process.exitCode = completed.exitCode; +} diff --git a/tests/e2e/noosphere-axis.spec.ts b/tests/e2e/noosphere-axis.spec.ts new file mode 100644 index 0000000..738957c --- /dev/null +++ b/tests/e2e/noosphere-axis.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +const workspaceSlug = process.env.BOOTSTRAP_WORKSPACE_SLUG ?? "ignition-ai"; +const email = process.env.BOOTSTRAP_OWNER_EMAIL ?? "owner@ignition.local"; +const password = process.env.BOOTSTRAP_OWNER_PASSWORD ?? "change-me-in-env"; + +test.beforeEach(async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Email professionnel").fill(email); + await page.getByLabel("Mot de passe").fill(password); + await page.getByRole("button", { name: "Accéder au workspace" }).click(); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/?$`)); + await expect(page.getByRole("heading", { name: "Aujourd’hui" })).toBeVisible(); +}); + +test("the five destinations and Noosphere Axis remain GET-only", async ({ page, isMobile }) => { + const mutationRequests: string[] = []; + page.on("request", (request) => { + if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method())) mutationRequests.push(`${request.method()} ${request.url()}`); + }); + + const navigation = page.getByRole("navigation", { name: isMobile ? "Navigation mobile" : "Navigation principale" }); + for (const label of ["Aujourd’hui", "Activité", "Prospects", isMobile ? "Messages" : "Conversations", "Appels"]) { + await expect(navigation.getByRole("link", { name: label, exact: true })).toBeVisible(); + } + + await page.getByRole("tab", { name: "Inbound" }).click(); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/activity\\?lens=inbound`)); + await expect(page.getByRole("heading", { name: "Créer la demande" })).toBeVisible(); + + await page.getByRole("tab", { name: "Symbiose" }).click(); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/activity\\?lens=symbiosis`)); + await expect(page.getByRole("heading", { name: "Relier contenu et revenu" })).toBeVisible(); + + await page.getByRole("tab", { name: "Outbound" }).click(); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/activity\\?lens=outbound`)); + await expect(page.getByRole("heading", { name: "Capter la demande" })).toBeVisible(); + + expect(mutationRequests).toEqual([]); +}); + +test("browser back restores the previous lens without losing URL state", async ({ page }) => { + await page.getByRole("tab", { name: "Inbound" }).click(); + await expect(page).toHaveURL(new RegExp("lens=inbound")); + await page.getByRole("tab", { name: "Outbound" }).click(); + await expect(page).toHaveURL(new RegExp("lens=outbound")); + await page.goBack(); + await expect(page).toHaveURL(new RegExp("lens=inbound")); + await expect(page.getByRole("tab", { name: "Inbound" })).toHaveAttribute("aria-selected", "true"); +}); diff --git a/tests/http/operational-view-http.test.ts b/tests/http/operational-view-http.test.ts index 93240c3..a72cd46 100644 --- a/tests/http/operational-view-http.test.ts +++ b/tests/http/operational-view-http.test.ts @@ -14,6 +14,10 @@ describe("workspace operational view HTTP routes", () => { expect(summary.status).toBe(200); expect(calls).toEqual([`summary:${workspaceId}`]); + const activity = await handler(new Request("http://localhost/api/v1/activity?lens=outbound&cursor=25")); + expect(activity.status).toBe(200); + expect(calls.at(-1)).toBe(`activity:${workspaceId}:outbound:25`); + const conversations = await handler(new Request("http://localhost/api/v1/conversations?channel=linkedin&scope=outside_campaign&page=2&pageSize=10&search=salim")); expect(conversations.status).toBe(200); expect(calls.at(-1)).toBe(`conversations:${workspaceId}:linkedin:outside_campaign:salim:2:10`); @@ -40,6 +44,22 @@ describe("workspace operational view HTTP routes", () => { const response = await handler(new Request("http://localhost/api/v1/conversations?channel=carrier-pigeon")); expect(response.status).toBe(422); }); + + test("treats the Noosphere Axis as read-only projection navigation", async () => { + const calls: string[] = []; + const handler = createOperationalViewHttpHandler({ contextResolver: context("viewer"), database: undefined as never, views: fakeViews(calls) }); + for (const lens of ["inbound", "symbiosis", "outbound"] as const) { + const response = await handler(new Request(`http://localhost/api/v1/activity?lens=${lens}`)); + expect(response.status).toBe(200); + } + expect(calls).toEqual([ + `activity:${workspaceId}:inbound:0`, + `activity:${workspaceId}:symbiosis:0`, + `activity:${workspaceId}:outbound:0`, + ]); + const invalid = await handler(new Request("http://localhost/api/v1/activity?lens=command")); + expect(invalid.status).toBe(422); + }); }); function context(role: "viewer") { @@ -48,7 +68,8 @@ function context(role: "viewer") { function fakeViews(calls: string[]): OperationalViewsPort { return { - async getSummary(receivedWorkspaceId) { calls.push(`summary:${receivedWorkspaceId}`); return { asOf: new Date(), counts: { activeCampaigns: 0, prospects: 0, openConversations: 0, openOpportunities: 0, attention: 0 }, attention: [], jobs: { active: 0, failed: 0, running: [] }, nextAutomaticResearch: null, accountHealth: { connected: 0, degraded: 0, disconnected: 0, activeAlerts: 0 } }; }, + async getSummary(receivedWorkspaceId) { calls.push(`summary:${receivedWorkspaceId}`); return { asOf: new Date(), counts: { activeCampaigns: 0, prospects: 0, openConversations: 0, openOpportunities: 0, bookedCalls: 0, attention: 0 }, attention: [], jobs: { active: 0, failed: 0, running: [] }, nextAutomaticResearch: null, accountHealth: { connected: 0, degraded: 0, disconnected: 0, activeAlerts: 0 }, engines: { inbound: { status: "not_configured", label: "Inbound", summary: "", lastActivityAt: null, nextAction: null }, outbound: { status: "idle", label: "Outbound", summary: "", lastActivityAt: null, nextAction: null } }, nextOutcomes: [], attentionPagination: { nextCursor: null } }; }, + async getActivity(input) { calls.push(`activity:${input.workspaceId}:${input.lens}:${input.offset ?? 0}`); return { lens: input.lens, asOf: new Date(), state: "idle", headline: "", counters: [], items: [], pagination: { nextCursor: null } }; }, async getSetupReadiness() { return { ready: true, asOf: new Date(), items: [] }; }, async getCampaignView() { return { campaign: {}, autopilot: {}, population: { total: 0, eligible: 0, contacted: 0, replies: 0 }, timeline: [], nextAction: null } as never; }, async listConversations(input) { calls.push(`conversations:${input.workspaceId}:${input.channel}:${input.scope}:${input.search}:${input.page}:${input.pageSize}`); return { data: [], pagination: { page: input.page, pageSize: input.pageSize, total: 0, hasNext: false }, sync: { totalAccounts: 0, readyAccounts: 0, backfillingAccounts: 0, errorAccounts: 0, lastSuccessAt: null } }; }, diff --git a/tests/integration/noosphere-operational-views.test.ts b/tests/integration/noosphere-operational-views.test.ts new file mode 100644 index 0000000..52f5166 --- /dev/null +++ b/tests/integration/noosphere-operational-views.test.ts @@ -0,0 +1,68 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { createDatabase } from "@outbound/infrastructure/database/client"; +import { jobs, workspaces } from "@outbound/infrastructure/database/schema"; +import { PostgresOperationalViews } from "@outbound/infrastructure/workspaces/postgres-operational-views"; + +const databaseUrl = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL; +const databaseDescribe = databaseUrl ? describe : describe.skip; + +databaseDescribe("Noosphere operational projections", () => { + if (!databaseUrl) return; + const database = createDatabase(databaseUrl); + const views = new PostgresOperationalViews(database.db); + const workspaceA = crypto.randomUUID(); + const workspaceB = crypto.randomUUID(); + const jobA = crypto.randomUUID(); + const jobB = crypto.randomUUID(); + const lockedAt = new Date("2026-08-20T06:00:00.000Z"); + const lockedUntil = new Date("2026-08-20T06:05:00.000Z"); + + beforeAll(async () => { + await migrate(database.db, { migrationsFolder: resolve(import.meta.dir, "../../packages/infrastructure/migrations") }); + await database.db.insert(workspaces).values([ + { id: workspaceA, slug: `noosphere-a-${workspaceA}`, name: "Noosphere A" }, + { id: workspaceB, slug: `noosphere-b-${workspaceB}`, name: "Noosphere B" }, + ]); + await database.db.insert(jobs).values([ + { id: jobA, workspaceId: workspaceA, type: "campaign.autopilot", payload: {}, idempotencyKey: "axis-a", correlationId: "axis-proof-a", status: "running", attempts: 1, maxAttempts: 3, availableAt: lockedAt, lockedAt, lockedUntil, lockedBy: "worker-a" }, + { id: jobB, workspaceId: workspaceB, type: "campaign.autopilot", payload: {}, idempotencyKey: "axis-b", correlationId: "axis-proof-b", status: "running", attempts: 1, maxAttempts: 3, availableAt: lockedAt, lockedAt, lockedUntil, lockedBy: "worker-b" }, + ]); + }); + + afterAll(async () => { + await database.client`delete from jobs where workspace_id in (${workspaceA}, ${workspaceB})`; + await database.client`delete from workspaces where id in (${workspaceA}, ${workspaceB})`; + await database.close(); + }); + + test("switching the three lenses never changes a running job or its lease", async () => { + const [before] = await database.db.select().from(jobs).where(eq(jobs.id, jobA)); + const pages = await Promise.all([ + views.getActivity({ workspaceId: workspaceA, lens: "inbound" }), + views.getActivity({ workspaceId: workspaceA, lens: "symbiosis" }), + views.getActivity({ workspaceId: workspaceA, lens: "outbound" }), + ]); + expect(pages.map((page) => page.lens)).toEqual(["inbound", "symbiosis", "outbound"]); + const [after] = await database.db.select().from(jobs).where(eq(jobs.id, jobA)); + expect(after).toMatchObject({ + id: before!.id, + status: before!.status, + lockedAt: before!.lockedAt, + lockedUntil: before!.lockedUntil, + lockedBy: before!.lockedBy, + attempts: before!.attempts, + }); + }); + + test("summary and activity stay isolated to the session workspace", async () => { + const summaryA = await views.getSummary(workspaceA); + const summaryB = await views.getSummary(workspaceB); + expect(summaryA.jobs.running.map((job) => job.id)).toEqual([jobA]); + expect(summaryB.jobs.running.map((job) => job.id)).toEqual([jobB]); + expect(summaryA.engines.inbound.status).toBe("not_configured"); + expect(summaryA.engines.outbound.status).toBe("running"); + }); +}); diff --git a/tests/unit/product-research-openapi.test.ts b/tests/unit/product-research-openapi.test.ts index 126bdc8..00ce073 100644 --- a/tests/unit/product-research-openapi.test.ts +++ b/tests/unit/product-research-openapi.test.ts @@ -132,6 +132,7 @@ test("the OpenAPI contract declares every F-009 HTTP route", () => { "/api/v1/pipeline/forecast", "/api/v1/pipeline/view", "/api/v1/workspace/operational-summary", + "/api/v1/activity", "/api/v1/workspace/setup-readiness", "/api/v1/workspaces/{workspaceId}/lost-reasons", "/api/v1/workspaces", From 213e9e8f214d9eebeea2c42bb29c14801c9d1a69 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 21:40:38 +0200 Subject: [PATCH 128/169] feat(noosphere): compose outbound workspace surfaces --- .../app/w/[workspaceSlug]/activity/page.tsx | 2 +- .../w/[workspaceSlug]/appointments/page.tsx | 27 +++++++---- .../campaigns/[campaignId]/page.tsx | 8 ++-- apps/web/app/w/[workspaceSlug]/inbox/page.tsx | 4 +- .../app/w/[workspaceSlug]/prospects/page.tsx | 22 ++++++++- apps/web/components/app-shell.tsx | 2 +- apps/web/lib/api.ts | 7 +++ .../openapi/product-research-v1.json | 2 +- .../calendar/postgres-calendar-integration.ts | 47 +++++++++++++++++-- .../crm/postgres-prospect-view-repository.ts | 6 ++- .../workspaces/postgres-operational-views.ts | 22 +++++++-- .../src/http/calendar-booking-handler.ts | 2 +- packages/interface/src/http/crm-handler.ts | 18 +++++++ tests/e2e/noosphere-axis.spec.ts | 43 +++++++++++++++-- tests/http/calendar-booking-http.test.ts | 4 +- tests/integration/calendar-setter.test.ts | 3 ++ tests/integration/crm-foundation.test.ts | 21 +++++++++ 17 files changed, 206 insertions(+), 34 deletions(-) diff --git a/apps/web/app/w/[workspaceSlug]/activity/page.tsx b/apps/web/app/w/[workspaceSlug]/activity/page.tsx index 844809c..9abbfd4 100644 --- a/apps/web/app/w/[workspaceSlug]/activity/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/activity/page.tsx @@ -28,7 +28,7 @@ export default async function ActivityPage({

{titleFor(lens)}

{descriptionFor(lens)}

- {lens === "outbound" ? Lancer un ICP : Configuration} + {lens === "outbound" ?
Toutes les campagnesLancer un ICP
: Configuration}
diff --git a/apps/web/app/w/[workspaceSlug]/appointments/page.tsx b/apps/web/app/w/[workspaceSlug]/appointments/page.tsx index 512e1ef..9025e1a 100644 --- a/apps/web/app/w/[workspaceSlug]/appointments/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/appointments/page.tsx @@ -1,9 +1,9 @@ -import { CalendarCheck2, CalendarClock, ExternalLink, Settings2, UserRound, Video } from "lucide-react"; +import { CalendarCheck2, CalendarClock, ExternalLink, Settings2, Target, UserRound, Video } from "lucide-react"; import Link from "next/link"; import { CrmPermissionState } from "@/components/crm-states"; import { getCalendarConnection, listCalendarBookings, OutboundApiError, type CalendarBooking } from "@/lib/api"; -export const metadata = { title: "Rendez-vous" }; +export const metadata = { title: "Appels" }; export const dynamic = "force-dynamic"; type View = "upcoming" | "past" | "all"; @@ -42,7 +42,7 @@ export default async function AppointmentsPage({
Résultat commercial
-

Rendez-vous

+

Appels

Les appels réservés par le Setter apparaissent ici automatiquement. Vous n’avez plus qu’à les prendre.

Agenda @@ -55,9 +55,10 @@ export default async function AppointmentsPage({ ) : null} -
+
Date.parse(booking.startAt) <= now + 7 * 86_400_000).length} tone="signal" /> + booking.campaignId).length} /> booking.status === "completed").length} />
@@ -66,7 +67,7 @@ export default async function AppointmentsPage({
-

Prochain appel

{displayName(next)}

{formatDate(next.startAt)} · {duration(next)}

+

Prochain appel

{displayName(next)}

{formatDate(next.startAt)} · {duration(next)}

{next.meetingUrl ? : null}
@@ -85,8 +86,8 @@ export default async function AppointmentsPage({ {visible.map((booking) => (

{formatDay(booking.startAt)}

{formatTime(booking.startAt)} · {duration(booking)}

-

{displayName(booking)}

{booking.attendeeEmail ?? booking.attendeePhone ?? booking.meetingType?.title ?? "Prospect qualifié"}

-
{statusLabel(booking.status)}{booking.rescheduleCount ?

Replanifié {booking.rescheduleCount} fois

: null}
+

{booking.contactId ? {displayName(booking)} : displayName(booking)}

{booking.attendeeEmail ?? booking.attendeePhone ?? booking.meetingType?.title ?? "Prospect qualifié"}

+
{statusLabel(booking.status)}{booking.opportunityStage ?

Pipeline · {opportunityLabel(booking.opportunityStage)}

: null}{booking.rescheduleCount ?

Replanifié {booking.rescheduleCount} fois

: null}

{booking.attendeeTimeZone} → {booking.organizerTimeZone}

{booking.opportunityId ? Suivi : null} {booking.meetingUrl && isUpcoming(booking) ? : null} @@ -115,10 +116,16 @@ function ViewLink({ active, href, label }: { active: boolean; href: string; labe return {label}; } -function displayName(booking: CalendarBooking): string { return booking.attendeeName?.trim() || booking.attendeeEmail || booking.attendeePhone || "Prospect"; } +function BookingOrigin({ booking, workspaceSlug }: { booking: CalendarBooking; workspaceSlug: string }) { + if (!booking.campaignId) return Origine inconnue; + return Outbound · {booking.campaignName ?? "Campagne"}; +} + +function displayName(booking: CalendarBooking): string { return booking.contactName?.trim() || booking.attendeeName?.trim() || booking.attendeeEmail || booking.attendeePhone || "Prospect"; } function duration(booking: CalendarBooking): string { if (booking.meetingType) return `${booking.meetingType.lengthMinutes} min`; if (!booking.endAt) return "durée à confirmer"; return `${Math.max(1, Math.round((Date.parse(booking.endAt) - Date.parse(booking.startAt)) / 60_000))} min`; } function formatDate(value: string): string { return new Intl.DateTimeFormat("fr-FR", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/Paris" }).format(new Date(value)); } function formatDay(value: string): string { return new Intl.DateTimeFormat("fr-FR", { weekday: "short", day: "2-digit", month: "short", timeZone: "Europe/Paris" }).format(new Date(value)); } function formatTime(value: string): string { return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris" }).format(new Date(value)); } -function statusLabel(status: string): string { return ({ scheduled: "Confirmé", rescheduled: "Replanifié", completed: "Terminé", cancelled: "Annulé", no_show: "Absent" } as Record)[status] ?? status; } -function statusBadge(status: string): string { if (["scheduled", "rescheduled"].includes(status)) return "badge badge-success"; if (["cancelled", "no_show"].includes(status)) return "badge badge-warning"; return "badge"; } +function statusLabel(status: string): string { return ({ requested: "Demandé", booked: "Confirmé", scheduled: "Confirmé", rescheduled: "Replanifié", completed: "Terminé", cancelled: "Annulé", no_show: "Absent" } as Record)[status] ?? status; } +function statusBadge(status: string): string { if (["requested", "booked", "scheduled", "rescheduled"].includes(status)) return "badge badge-success"; if (["cancelled", "no_show"].includes(status)) return "badge badge-warning"; return "badge"; } +function opportunityLabel(stage: string): string { return ({ qualified: "Qualifié", meeting_requested: "Appel demandé", meeting_booked: "Appel réservé", meeting_completed: "Appel terminé", meeting_no_show: "À replanifier", won: "Gagné", lost: "Perdu" } as Record)[stage] ?? stage; } diff --git a/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx b/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx index 7492325..f6ee71b 100644 --- a/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/campaigns/[campaignId]/page.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, ArrowLeft, Building2, Clock3, ExternalLink, Mail, MessageCircle, Phone, RefreshCw, Search, Send, UserRound } from "lucide-react"; +import { AlertTriangle, ArrowLeft, ArrowRight, Building2, Clock3, ExternalLink, Mail, MessageCircle, Phone, RefreshCw, Search, Send, UserRound } from "lucide-react"; import Link from "next/link"; import { getCampaign, getCampaignAutopilotPolicy, getCampaignWorkspaceView } from "@/lib/api"; import { prospectDetailHref } from "@/lib/prospect-navigation"; @@ -59,11 +59,13 @@ export default async function CampaignDetailPage({
{campaign.automationStage === "attention" || campaign.discoveryStatus === "failed" ? ( -
+
{campaign.automationErrorMessage ?? campaign.discoveryErrorMessage ?? campaign.automationErrorCode ?? campaign.discoveryErrorCode ?? "L’autopilote est suspendu sur une exception fournisseur."}
) : null} + {workspaceView?.nextAction ?

Prochaine action durable

{workspaceView.nextAction.label}

Cette action provient de l’état persistant de la campagne, pas d’une estimation de l’interface.

Ouvrir
: null} + {workspaceView ? <>

Boucle de campagne

Chaque étape est durable, observable et relançable sans perdre le job.

{workspaceView.population.total} prospects
@@ -79,7 +81,7 @@ export default async function CampaignDetailPage({ ) : null}
-
+

Prospects enrichis

{campaign.prospects.length} diff --git a/apps/web/app/w/[workspaceSlug]/inbox/page.tsx b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx index 513758e..beddb4d 100644 --- a/apps/web/app/w/[workspaceSlug]/inbox/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx @@ -7,7 +7,7 @@ import { type WorkspaceConversationView, } from "@/lib/api"; -export const metadata = { title: "Messages" }; +export const metadata = { title: "Conversations" }; export const dynamic = "force-dynamic"; type Query = { @@ -55,7 +55,7 @@ export default async function InboxPage({ return ( <>
-

Messages

+

Conversations

Toutes les conversations des comptes LinkedIn, email et WhatsApp associés.

diff --git a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx index faf56a9..912d8c2 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx @@ -17,6 +17,8 @@ export default async function ProspectsPage({ campaign?: string; campaignScope?: "in_campaign" | "outside_campaign"; channel?: string; + status?: "active" | "suppressed"; + period?: "today" | "7d" | "30d" | "90d"; signalType?: SignalType; signalFreshness?: "current" | "history"; prospect?: string; @@ -30,6 +32,8 @@ export default async function ProspectsPage({ ...(query.campaign ? { campaignId: query.campaign } : {}), ...(!query.campaign && query.campaignScope ? { campaignScope: query.campaignScope } : {}), ...(query.channel ? { channel: query.channel } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.period ? { period: query.period } : {}), }); let signals: IntentSignal[] = []; try { signals = (await listSignals(workspaceSlug)).data; } @@ -57,7 +61,7 @@ export default async function ProspectsPage({
-
+
@@ -150,7 +166,7 @@ function ChannelBadges({ channels }: { channels: { linkedin: boolean; email: boo return <>{channels.linkedin ? LinkedIn : null}{channels.email ? Email : null}{channels.whatsapp ? WhatsApp : null}; } -function prospectListHref(workspaceSlug: string, query: { search?: string; icp?: string; campaign?: string; campaignScope?: string; channel?: string; signalType?: string; signalFreshness?: string }) { +function prospectListHref(workspaceSlug: string, query: { search?: string; icp?: string; campaign?: string; campaignScope?: string; channel?: string; signalType?: string; signalFreshness?: string; status?: string; period?: string }) { const params = new URLSearchParams(); if (query.search) params.set("search", query.search); if (query.icp) params.set("icp", query.icp); @@ -159,6 +175,8 @@ function prospectListHref(workspaceSlug: string, query: { search?: string; icp?: if (query.channel) params.set("channel", query.channel); if (query.signalType) params.set("signalType", query.signalType); if (query.signalFreshness) params.set("signalFreshness", query.signalFreshness); + if (query.status) params.set("status", query.status); + if (query.period) params.set("period", query.period); return `/w/${workspaceSlug}/prospects${params.size ? `?${params.toString()}` : ""}`; } diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx index 2557140..616fc37 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -157,7 +157,7 @@ export function AppShell({ - +
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 4276d18..68c0dee 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -855,7 +855,10 @@ export interface CalendarBooking { readonly id: string; readonly contactId: string | null; readonly campaignId: string | null; + readonly campaignName: string | null; readonly opportunityId: string | null; + readonly opportunityStage: string | null; + readonly contactName: string | null; readonly status: string; readonly attendeeName: string | null; readonly attendeeEmail: string | null; @@ -1522,6 +1525,8 @@ export async function listProspectViews( campaignId?: string; campaignScope?: "in_campaign" | "outside_campaign"; channel?: string; + status?: "active" | "suppressed"; + period?: "today" | "7d" | "30d" | "90d"; } = {}, ): Promise<{ data: ProspectViewSummary[]; @@ -1536,6 +1541,8 @@ export async function listProspectViews( if (filters.campaignId) query.set("campaignId", filters.campaignId); if (filters.campaignScope) query.set("campaignScope", filters.campaignScope); if (filters.channel) query.set("channel", filters.channel); + if (filters.status) query.set("status", filters.status); + if (filters.period) query.set("period", filters.period); return crmFetch(workspaceSlug, `/api/v1/prospects?${query.toString()}`); } diff --git a/packages/contracts/openapi/product-research-v1.json b/packages/contracts/openapi/product-research-v1.json index d545110..15257aa 100644 --- a/packages/contracts/openapi/product-research-v1.json +++ b/packages/contracts/openapi/product-research-v1.json @@ -1755,7 +1755,7 @@ "CorrelationTrace": { "type": "object", "required": ["correlationId", "jobs", "events", "audit"], "properties": { "correlationId": { "type": "string" }, "jobs": { "type": "array", "items": { "$ref": "#/components/schemas/ConsoleJob" } }, "events": { "type": "array", "items": { "type": "object" } }, "audit": { "type": "array", "items": { "type": "object" } } } }, "CalendarMeetingType": { "type": "object", "required": ["id", "providerEventTypeId", "slug", "title", "lengthMinutes", "bookingUrl", "timeZone", "isDefault", "active"], "properties": { "id": { "type": "string", "format": "uuid" }, "providerEventTypeId": { "type": "integer" }, "slug": { "type": "string" }, "title": { "type": "string" }, "lengthMinutes": { "type": "integer" }, "bookingUrl": { "type": "string", "format": "uri" }, "timeZone": { "type": "string" }, "isDefault": { "type": "boolean" }, "active": { "type": "boolean" } } }, "CalendarMeetingTypeList": { "type": "object", "required": ["data"], "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/CalendarMeetingType" } } } }, - "CalendarBooking": { "type": "object", "required": ["id", "status", "attendeeTimeZone", "organizerTimeZone", "startAt", "rescheduleCount", "history", "createdAt", "updatedAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "contactId": { "type": ["string", "null"], "format": "uuid" }, "campaignId": { "type": ["string", "null"], "format": "uuid" }, "opportunityId": { "type": ["string", "null"], "format": "uuid" }, "status": { "type": "string", "enum": ["requested", "booked", "cancelled", "no_show", "completed"] }, "attendeeName": { "type": ["string", "null"] }, "attendeeEmail": { "type": ["string", "null"], "format": "email" }, "attendeePhone": { "type": ["string", "null"] }, "attendeeTimeZone": { "type": "string" }, "organizerTimeZone": { "type": "string" }, "startAt": { "type": "string", "format": "date-time" }, "endAt": { "type": ["string", "null"], "format": "date-time" }, "meetingUrl": { "type": ["string", "null"], "format": "uri" }, "cancellationReason": { "type": ["string", "null"] }, "noShowAt": { "type": ["string", "null"], "format": "date-time" }, "rescheduleCount": { "type": "integer" }, "meetingType": { "oneOf": [{ "$ref": "#/components/schemas/CalendarMeetingType" }, { "type": "null" }] }, "history": { "type": "array", "items": { "type": "object" } }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } } }, + "CalendarBooking": { "type": "object", "required": ["id", "contactName", "campaignName", "opportunityStage", "status", "attendeeTimeZone", "organizerTimeZone", "startAt", "rescheduleCount", "history", "createdAt", "updatedAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "contactId": { "type": ["string", "null"], "format": "uuid" }, "contactName": { "type": ["string", "null"] }, "campaignId": { "type": ["string", "null"], "format": "uuid" }, "campaignName": { "type": ["string", "null"] }, "opportunityId": { "type": ["string", "null"], "format": "uuid" }, "opportunityStage": { "type": ["string", "null"], "enum": ["qualified", "meeting_requested", "meeting_booked", "meeting_no_show", "meeting_completed", "won", "lost", null] }, "status": { "type": "string", "enum": ["requested", "booked", "rescheduled", "cancelled", "no_show", "completed"] }, "attendeeName": { "type": ["string", "null"] }, "attendeeEmail": { "type": ["string", "null"], "format": "email" }, "attendeePhone": { "type": ["string", "null"] }, "attendeeTimeZone": { "type": "string" }, "organizerTimeZone": { "type": "string" }, "startAt": { "type": "string", "format": "date-time" }, "endAt": { "type": ["string", "null"], "format": "date-time" }, "meetingUrl": { "type": ["string", "null"], "format": "uri" }, "cancellationReason": { "type": ["string", "null"] }, "noShowAt": { "type": ["string", "null"], "format": "date-time" }, "rescheduleCount": { "type": "integer" }, "meetingType": { "oneOf": [{ "$ref": "#/components/schemas/CalendarMeetingType" }, { "type": "null" }] }, "history": { "type": "array", "items": { "type": "object" } }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } } }, "CalendarBookingList": { "type": "object", "required": ["data"], "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/CalendarBooking" } } } }, "CalendarBookingMutation": { "type": "object", "additionalProperties": false, "required": ["requestKey", "reason"], "properties": { "requestKey": { "type": "string", "minLength": 1, "maxLength": 500 }, "reason": { "type": "string", "minLength": 3, "maxLength": 1000 }, "start": { "type": "string", "format": "date-time" } } }, "KnowledgeSourceInput": { "type": "object", "additionalProperties": false, "required": ["type", "title", "content", "researchDocumentId", "authorName", "publishedAt", "freshnessUntil"], "properties": { "type": { "type": "string", "enum": ["product_document", "proof", "customer_case", "objection_response"] }, "title": { "type": "string", "minLength": 1, "maxLength": 500 }, "content": { "type": ["string", "null"], "maxLength": 200000 }, "researchDocumentId": { "type": ["string", "null"], "format": "uuid" }, "authorName": { "type": "string", "minLength": 1, "maxLength": 300 }, "publishedAt": { "type": "string", "format": "date-time" }, "freshnessUntil": { "type": ["string", "null"], "format": "date-time" } } }, diff --git a/packages/infrastructure/src/calendar/postgres-calendar-integration.ts b/packages/infrastructure/src/calendar/postgres-calendar-integration.ts index 3203531..5c1a186 100644 --- a/packages/infrastructure/src/calendar/postgres-calendar-integration.ts +++ b/packages/infrastructure/src/calendar/postgres-calendar-integration.ts @@ -7,11 +7,13 @@ import { calendarBookings, calendarConnections, calendarMeetingTypes, + campaigns, campaignProspects, contactIdentities, contacts, conversations, integrationEvents, + opportunities, outboxEvents, outreachActions, sequenceEnrollments, @@ -99,7 +101,10 @@ export interface CalendarProductBookingView { readonly id: string; readonly contactId: string | null; readonly campaignId: string | null; + readonly campaignName: string | null; readonly opportunityId: string | null; + readonly opportunityStage: OpportunityStage | null; + readonly contactName: string | null; readonly status: string; readonly attendeeName: string | null; readonly attendeeEmail: string | null; @@ -705,10 +710,34 @@ export class PostgresCalendarIntegration implements WorkspaceCalendarScheduler { const predicates = [eq(calendarBookings.workspaceId, input.workspaceId)]; if (input.contactId) predicates.push(eq(calendarBookings.contactId, input.contactId)); if (input.opportunityId) predicates.push(eq(calendarBookings.opportunityId, input.opportunityId)); - const rows = await this.database.select({ booking: calendarBookings, meetingType: calendarMeetingTypes }).from(calendarBookings).leftJoin(calendarMeetingTypes, and(eq(calendarMeetingTypes.workspaceId, calendarBookings.workspaceId), eq(calendarMeetingTypes.id, calendarBookings.meetingTypeId))).where(and(...predicates)).orderBy(desc(calendarBookings.startAt)).limit(input.limit); + const rows = await this.database.select({ + booking: calendarBookings, + meetingType: calendarMeetingTypes, + campaignName: campaigns.name, + contactFirstName: contacts.firstName, + contactLastName: contacts.lastName, + opportunityStage: opportunities.stage, + }) + .from(calendarBookings) + .leftJoin(calendarMeetingTypes, and(eq(calendarMeetingTypes.workspaceId, calendarBookings.workspaceId), eq(calendarMeetingTypes.id, calendarBookings.meetingTypeId))) + .leftJoin(campaigns, and(eq(campaigns.workspaceId, calendarBookings.workspaceId), eq(campaigns.id, calendarBookings.campaignId))) + .leftJoin(contacts, and(eq(contacts.workspaceId, calendarBookings.workspaceId), eq(contacts.id, calendarBookings.contactId))) + .leftJoin(opportunities, and(eq(opportunities.workspaceId, calendarBookings.workspaceId), eq(opportunities.id, calendarBookings.opportunityId))) + .where(and(...predicates)) + .orderBy(desc(calendarBookings.startAt)) + .limit(input.limit); const ids = rows.map((row) => row.booking.id); const history = ids.length ? await this.database.select().from(calendarBookingHistory).where(and(eq(calendarBookingHistory.workspaceId, input.workspaceId), inArray(calendarBookingHistory.bookingId, ids))).orderBy(calendarBookingHistory.createdAt) : []; - return rows.map(({ booking, meetingType }) => productBookingView(booking, meetingType, history.filter((entry) => entry.bookingId === booking.id))); + return rows.map(({ booking, meetingType, campaignName, contactFirstName, contactLastName, opportunityStage }) => productBookingView( + booking, + meetingType, + history.filter((entry) => entry.bookingId === booking.id), + { + campaignName, + contactName: [contactFirstName, contactLastName].filter(Boolean).join(" ") || null, + opportunityStage: opportunityStage as OpportunityStage | null, + }, + )); } async rescheduleById(input: { workspaceId: string; bookingId: string; start: string; reason: string; requestKey: string; actorUserId: string; now: Date }): Promise { @@ -1173,12 +1202,24 @@ function meetingTypeView(row: typeof calendarMeetingTypes.$inferSelect): Calenda return { id: row.id, providerEventTypeId: row.providerEventTypeId, slug: row.slug, title: row.title, lengthMinutes: row.lengthMinutes, bookingUrl: row.bookingUrl, timeZone: row.timeZone, isDefault: row.isDefault, active: row.active }; } -function productBookingView(booking: typeof calendarBookings.$inferSelect, meetingType: typeof calendarMeetingTypes.$inferSelect | null, history: readonly (typeof calendarBookingHistory.$inferSelect)[]): CalendarProductBookingView { +function productBookingView( + booking: typeof calendarBookings.$inferSelect, + meetingType: typeof calendarMeetingTypes.$inferSelect | null, + history: readonly (typeof calendarBookingHistory.$inferSelect)[], + context: { campaignName: string | null; contactName: string | null; opportunityStage: OpportunityStage | null } = { + campaignName: null, + contactName: null, + opportunityStage: null, + }, +): CalendarProductBookingView { return { id: booking.id, contactId: booking.contactId, campaignId: booking.campaignId, + campaignName: context.campaignName, opportunityId: booking.opportunityId, + opportunityStage: context.opportunityStage, + contactName: context.contactName, status: booking.status, attendeeName: booking.attendeeName, attendeeEmail: booking.attendeeEmail, diff --git a/packages/infrastructure/src/crm/postgres-prospect-view-repository.ts b/packages/infrastructure/src/crm/postgres-prospect-view-repository.ts index 0d4b619..ac0e830 100644 --- a/packages/infrastructure/src/crm/postgres-prospect-view-repository.ts +++ b/packages/infrastructure/src/crm/postgres-prospect-view-repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, ilike, inArray, ne, or, sql, type SQL } from "drizzle-orm"; +import { and, desc, eq, gte, ilike, inArray, ne, or, sql, type SQL } from "drizzle-orm"; import type { Database } from "@outbound/infrastructure/database/client"; import { campaignProspects, @@ -29,9 +29,13 @@ export class PostgresProspectViewRepository { campaignId?: string; campaignScope?: "in_campaign" | "outside_campaign"; channel?: "linkedin" | "email" | "whatsapp"; + status?: "active" | "suppressed"; + updatedSince?: Date; limit: number; }) { const conditions: SQL[] = [eq(contacts.workspaceId, input.workspaceId)]; + if (input.status) conditions.push(eq(contacts.status, input.status)); + if (input.updatedSince) conditions.push(gte(contacts.updatedAt, input.updatedSince)); if (input.search) { const pattern = `%${input.search}%`; conditions.push(or(ilike(contacts.firstName, pattern), ilike(contacts.lastName, pattern))!); diff --git a/packages/infrastructure/src/workspaces/postgres-operational-views.ts b/packages/infrastructure/src/workspaces/postgres-operational-views.ts index d64ea14..53de415 100644 --- a/packages/infrastructure/src/workspaces/postgres-operational-views.ts +++ b/packages/infrastructure/src/workspaces/postgres-operational-views.ts @@ -226,6 +226,8 @@ export class PostgresOperationalViews { id: campaigns.id, name: campaigns.name, status: campaigns.status, + channel: campaigns.channel, + prospectCount: campaigns.prospectCount, automationStage: campaigns.automationStage, updatedAt: campaigns.updatedAt, }).from(campaigns).where(eq(campaigns.workspaceId, input.workspaceId)).orderBy(desc(campaigns.updatedAt)).limit(limit + 1).offset(offset); @@ -240,7 +242,7 @@ export class PostgresOperationalViews { ? "running" as const : "completed" as const, title: campaign.name, - detail: `${campaign.automationStage ?? "configuration"} · ${campaign.status}`, + detail: `${campaign.channel} · ${campaign.prospectCount} prospect${campaign.prospectCount === 1 ? "" : "s"} · ${campaignStageLabel(campaign.automationStage)}`, occurredAt: campaign.updatedAt, href: `/campaigns/${campaign.id}`, correlationId: null, @@ -304,11 +306,11 @@ export class PostgresOperationalViews { const total = campaign.prospects.length; const eligible = campaign.prospects.filter((item) => item.eligible).length; const nextAction = autopilot.health === "attention" - ? { label: "Voir l’exception", href: `/w/${workspaceId}/campaigns/${campaignId}` } + ? { label: "Voir l’exception", href: `/campaigns/${campaignId}#exception` } : autopilot.currentStep === "research" - ? { label: "Relancer le sourcing", href: `/w/${workspaceId}/campaigns/${campaignId}#sourcing` } + ? { label: "Relancer le sourcing", href: `/campaigns/${campaignId}#sourcing` } : autopilot.currentStep === "meeting" - ? { label: "Ouvrir le pipeline", href: `/w/${workspaceId}/pipeline` } + ? { label: "Voir les appels", href: "/appointments" } : null; return { campaign, @@ -523,6 +525,18 @@ function readiness(key: "product" | "icp" | "accounts" | "automation" | "calenda } as const; } +function campaignStageLabel(stage: string): string { + return ({ + sourcing: "Sourcing", + enriching: "Enrichissement", + composing: "Rédaction", + scheduled: "Planifiée", + running: "Envoi et relances", + completed: "Terminée", + attention: "Exception localisée", + } as Record)[stage] ?? stage; +} + function timelineFor(currentStep: string, health: string) { const steps = [ ["sourcing", "Sourcer"], ["enrichment", "Enrichir"], ["scoring", "Scorer"], ["composition", "Rédiger"], ["outreach", "Envoyer"], ["follow_up", "Relancer"], ["setter", "Qualifier"], ["meeting", "Réserver"], diff --git a/packages/interface/src/http/calendar-booking-handler.ts b/packages/interface/src/http/calendar-booking-handler.ts index dae5372..3fa031e 100644 --- a/packages/interface/src/http/calendar-booking-handler.ts +++ b/packages/interface/src/http/calendar-booking-handler.ts @@ -60,7 +60,7 @@ export function createCalendarBookingHttpHandler(input: { integration: CalendarP function serializeBooking(booking: Awaited>[number] | Awaited>, role: WorkspaceRole) { const redact = role === "viewer"; - return { ...booking, ...(redact ? { contactId: null, campaignId: null, opportunityId: null, attendeeName: null, attendeeEmail: null, attendeePhone: null, meetingUrl: null } : {}) }; + return { ...booking, ...(redact ? { contactId: null, contactName: null, campaignId: null, campaignName: null, opportunityId: null, attendeeName: null, attendeeEmail: null, attendeePhone: null, meetingUrl: null } : {}) }; } class CalendarPermissionError extends Error {} function requireReader(role: WorkspaceRole) { if (!(["owner", "admin", "operator", "reviewer", "viewer"] as WorkspaceRole[]).includes(role)) throw new CalendarPermissionError("Workspace access required"); } diff --git a/packages/interface/src/http/crm-handler.ts b/packages/interface/src/http/crm-handler.ts index bed2d47..1f928d0 100644 --- a/packages/interface/src/http/crm-handler.ts +++ b/packages/interface/src/http/crm-handler.ts @@ -197,6 +197,8 @@ export function createCrmHttpHandler(dependencies: CrmHttpDependencies) { if (url.pathname === "/api/v1/prospects" && request.method === "GET") { requireViewer(context.role); const channel = url.searchParams.get("channel"); + const status = url.searchParams.get("status"); + const period = url.searchParams.get("period"); const campaignScope = url.searchParams.get("campaignScope"); const campaignId = url.searchParams.get("campaignId"); if (campaignId && campaignScope === "outside_campaign") { @@ -219,6 +221,12 @@ export function createCrmHttpHandler(dependencies: CrmHttpDependencies) { ...(channel ? { channel: z.enum(["linkedin", "email", "whatsapp"]).parse(channel) } : {}), + ...(status + ? { status: z.enum(["active", "suppressed"]).parse(status) } + : {}), + ...(period + ? { updatedSince: prospectPeriodStart(z.enum(["today", "7d", "30d", "90d"]).parse(period), now()) } + : {}), limit: parseLimit(url.searchParams.get("limit")), }); return json(result); @@ -502,6 +510,16 @@ export function createCrmHttpHandler(dependencies: CrmHttpDependencies) { }; } +function prospectPeriodStart(period: "today" | "7d" | "30d" | "90d", now: Date): Date { + if (period === "today") { + const start = new Date(now); + start.setUTCHours(0, 0, 0, 0); + return start; + } + const days = period === "7d" ? 7 : period === "30d" ? 30 : 90; + return new Date(now.getTime() - days * 86_400_000); +} + class WorkspacePermissionError extends Error {} function requireViewer(role: string): void { diff --git a/tests/e2e/noosphere-axis.spec.ts b/tests/e2e/noosphere-axis.spec.ts index 738957c..3eca014 100644 --- a/tests/e2e/noosphere-axis.spec.ts +++ b/tests/e2e/noosphere-axis.spec.ts @@ -13,14 +13,16 @@ test.beforeEach(async ({ page }) => { await expect(page.getByRole("heading", { name: "Aujourd’hui" })).toBeVisible(); }); -test("the five destinations and Noosphere Axis remain GET-only", async ({ page, isMobile }) => { +test("the five destinations and Noosphere Axis remain GET-only", async ({ page }) => { const mutationRequests: string[] = []; page.on("request", (request) => { if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method())) mutationRequests.push(`${request.method()} ${request.url()}`); }); - const navigation = page.getByRole("navigation", { name: isMobile ? "Navigation mobile" : "Navigation principale" }); - for (const label of ["Aujourd’hui", "Activité", "Prospects", isMobile ? "Messages" : "Conversations", "Appels"]) { + const navigation = page.viewportSize()?.width === 390 + ? page.getByRole("navigation", { name: "Navigation mobile" }) + : page.getByRole("navigation", { name: "Navigation principale" }); + for (const label of ["Aujourd’hui", "Activité", "Prospects", "Conversations", "Appels"]) { await expect(navigation.getByRole("link", { name: label, exact: true })).toBeVisible(); } @@ -48,3 +50,38 @@ test("browser back restores the previous lens without losing URL state", async ( await expect(page).toHaveURL(new RegExp("lens=inbound")); await expect(page.getByRole("tab", { name: "Inbound" })).toHaveAttribute("aria-selected", "true"); }); + +test("Outbound surfaces preserve prospect and conversation filters in the URL", async ({ page }) => { + const navigation = page.viewportSize()?.width === 390 + ? page.getByRole("navigation", { name: "Navigation mobile" }) + : page.getByRole("navigation", { name: "Navigation principale" }); + + await navigation.getByRole("link", { name: "Prospects", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Prospects", exact: true })).toBeVisible(); + await page.locator('select[name="campaignScope"]').selectOption("outside_campaign"); + await page.getByLabel("Statut du contact").selectOption("active"); + await page.getByLabel("Période prospect").selectOption("30d"); + await page.getByRole("button", { name: "Filtrer" }).click(); + await expect(page).toHaveURL(/campaignScope=outside_campaign/); + await expect(page).toHaveURL(/status=active/); + await expect(page).toHaveURL(/period=30d/); + + await navigation.getByRole("link", { name: "Conversations", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Conversations", exact: true, level: 1 })).toBeVisible(); + await page.getByLabel("Canal", { exact: true }).selectOption("linkedin"); + await page.getByLabel("Origine", { exact: true }).selectOption("outside_campaign"); + await page.getByLabel("Période", { exact: true }).selectOption("7d"); + await page.getByRole("button", { name: "Filtrer" }).click(); + await expect(page).toHaveURL(/channel=linkedin/); + await expect(page).toHaveURL(/scope=outside_campaign/); + await expect(page).toHaveURL(/period=7d/); + + await navigation.getByRole("link", { name: "Appels", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Appels", exact: true })).toBeVisible(); + await page.goBack(); + await expect(page).toHaveURL(/scope=outside_campaign/); + + await page.goto(`/w/${workspaceSlug}/settings`); + await expect(page.getByRole("heading", { name: "Configuration", exact: true, level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Lancement guidé", exact: true })).toBeVisible(); +}); diff --git a/tests/http/calendar-booking-http.test.ts b/tests/http/calendar-booking-http.test.ts index da6d779..f36f8b7 100644 --- a/tests/http/calendar-booking-http.test.ts +++ b/tests/http/calendar-booking-http.test.ts @@ -10,7 +10,7 @@ describe("F-043 calendar booking HTTP", () => { const response = await handler("viewer")(request("/api/v1/calendar-bookings")); expect(response.status).toBe(200); const body = await response.json() as { data: Array> }; - expect(body.data[0]).toMatchObject({ attendeeName: null, attendeeEmail: null, meetingUrl: null, contactId: null }); + expect(body.data[0]).toMatchObject({ attendeeName: null, attendeeEmail: null, meetingUrl: null, contactId: null, contactName: null, campaignName: null }); }); test("allows operators to mutate bookings but not meeting type configuration", async () => { @@ -30,7 +30,7 @@ describe("F-043 calendar booking HTTP", () => { }); function handler(role: "owner" | "operator" | "reviewer" | "viewer") { - const booking = { id: bookingId, contactId: userId, campaignId: null, opportunityId: null, status: "booked", attendeeName: "Marie", attendeeEmail: "marie@example.com", attendeePhone: "+33123456789", attendeeTimeZone: "Europe/Paris", organizerTimeZone: "Europe/Madrid", startAt: new Date("2027-01-10T10:00:00.000Z"), endAt: new Date("2027-01-10T10:30:00.000Z"), meetingUrl: "https://meet.example/secret", cancellationReason: null, noShowAt: null, rescheduleCount: 0, meetingType: null, history: [], createdAt: new Date(), updatedAt: new Date() }; + const booking = { id: bookingId, contactId: userId, contactName: "Marie Martin", campaignId: null, campaignName: null, opportunityId: null, opportunityStage: null, status: "booked", attendeeName: "Marie", attendeeEmail: "marie@example.com", attendeePhone: "+33123456789", attendeeTimeZone: "Europe/Paris", organizerTimeZone: "Europe/Madrid", startAt: new Date("2027-01-10T10:00:00.000Z"), endAt: new Date("2027-01-10T10:30:00.000Z"), meetingUrl: "https://meet.example/secret", cancellationReason: null, noShowAt: null, rescheduleCount: 0, meetingType: null, history: [], createdAt: new Date(), updatedAt: new Date() }; const integration = { async listBookings() { return [booking]; }, async listMeetingTypes() { return []; }, diff --git a/tests/integration/calendar-setter.test.ts b/tests/integration/calendar-setter.test.ts index abf66c4..a80374f 100644 --- a/tests/integration/calendar-setter.test.ts +++ b/tests/integration/calendar-setter.test.ts @@ -181,6 +181,9 @@ databaseDescribe("Setter Cal.com scheduling", () => { expect(await database.db.select().from(opportunities).where(eq(opportunities.workspaceId, workspaceId))).toMatchObject([ { stage: "meeting_booked", contactId }, ]); + expect(await integration.listBookings({ workspaceId, contactId, limit: 20 })).toMatchObject([ + { contactName: "Marie Dupont", campaignName: null, opportunityStage: "meeting_booked" }, + ]); expect(await database.db.select().from(opportunityStageHistory).where(eq(opportunityStageHistory.workspaceId, workspaceId))).toHaveLength(1); expect(await database.db.select().from(outboxEvents).where(eq(outboxEvents.workspaceId, workspaceId))).toHaveLength(1); }); diff --git a/tests/integration/crm-foundation.test.ts b/tests/integration/crm-foundation.test.ts index af5ae2b..c79991d 100644 --- a/tests/integration/crm-foundation.test.ts +++ b/tests/integration/crm-foundation.test.ts @@ -262,6 +262,27 @@ databaseDescribe("F-020/F-021 CRM foundation", () => { .toContain(insideContactId); }); + test("prospects: filters durable status and update period without broadening the workspace", async () => { + const recentActiveId = crypto.randomUUID(); + const recentSuppressedId = crypto.randomUUID(); + const oldActiveId = crypto.randomUUID(); + await database.db.insert(contacts).values([ + { id: recentActiveId, workspaceId, firstName: "Recent", lastName: "Active", status: "active", updatedAt: new Date() }, + { id: recentSuppressedId, workspaceId, firstName: "Recent", lastName: "Suppressed", status: "suppressed", updatedAt: new Date() }, + { id: oldActiveId, workspaceId, firstName: "Old", lastName: "Active", status: "active", updatedAt: new Date("2020-01-01T00:00:00.000Z") }, + ]); + + const response = await handle(new Request("http://localhost/api/v1/prospects?limit=100&period=7d&status=active")); + expect(response.status).toBe(200); + const ids = ((await response.json()) as { data: { id: string }[] }).data.map((item) => item.id); + expect(ids).toContain(recentActiveId); + expect(ids).not.toContain(recentSuppressedId); + expect(ids).not.toContain(oldActiveId); + + expect((await handle(new Request("http://localhost/api/v1/prospects?period=forever"))).status).toBe(400); + expect((await handle(new Request("http://localhost/api/v1/prospects?status=unknown"))).status).toBe(400); + }); + test("schedules a tenant-scoped manual decision in simulation-only mode", async () => { const contactId = crypto.randomUUID(); await database.db.insert(contacts).values({ From 9c265ff9733c2b197b4f93d036ac27a541dd1367 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 22:13:01 +0200 Subject: [PATCH 129/169] feat(inbound): derive grounded editorial strategy --- apps/api/src/index.ts | 17 + .../app/w/[workspaceSlug]/activity/page.tsx | 5 +- .../content/strategy/actions.ts | 11 + .../[workspaceSlug]/content/strategy/page.tsx | 50 +++ .../content/strategy/strategy-actions.tsx | 35 +++ .../app/w/[workspaceSlug]/settings/page.tsx | 1 + apps/web/lib/api.ts | 42 +++ .../src/content/editorial-strategy.ts | 138 ++++++++ .../openapi/product-research-v1.json | 29 ++ packages/contracts/src/content.ts | 28 ++ .../domain/src/content/editorial-strategy.ts | 27 ++ .../0067_noosphere_editorial_strategy.sql | 73 +++++ .../0068_editorial_strategy_immutability.sql | 13 + .../migrations/meta/_journal.json | 14 + .../langchain-editorial-strategy-generator.ts | 98 ++++++ .../postgres-editorial-strategy-repository.ts | 297 ++++++++++++++++++ .../infrastructure/src/database/schema.ts | 85 +++++ .../workspaces/postgres-operational-views.ts | 59 +++- .../src/http/content-strategy-handler.ts | 78 +++++ tests/e2e/noosphere-axis.spec.ts | 8 + tests/http/content-strategy-http.test.ts | 45 +++ tests/integration/editorial-strategy.test.ts | 193 ++++++++++++ tests/unit/editorial-strategy.test.ts | 72 +++++ tests/unit/product-research-openapi.test.ts | 3 + 24 files changed, 1410 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/w/[workspaceSlug]/content/strategy/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/content/strategy/strategy-actions.tsx create mode 100644 packages/application/src/content/editorial-strategy.ts create mode 100644 packages/contracts/src/content.ts create mode 100644 packages/domain/src/content/editorial-strategy.ts create mode 100644 packages/infrastructure/migrations/0067_noosphere_editorial_strategy.sql create mode 100644 packages/infrastructure/migrations/0068_editorial_strategy_immutability.sql create mode 100644 packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts create mode 100644 packages/infrastructure/src/content/postgres-editorial-strategy-repository.ts create mode 100644 packages/interface/src/http/content-strategy-handler.ts create mode 100644 tests/http/content-strategy-http.test.ts create mode 100644 tests/integration/editorial-strategy.test.ts create mode 100644 tests/unit/editorial-strategy.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index acaae5c..a50355b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -57,6 +57,11 @@ import { createOperatorConsoleHttpHandler, isOperatorConsoleRoute } from "@outbo import { PostgresWorkspaceOnboarding } from "@outbound/infrastructure/workspaces/postgres-workspace-onboarding"; import { createWorkspaceOnboardingHttpHandler, isWorkspaceOnboardingRoute } from "@outbound/interface/http/workspace-onboarding-handler"; import { createOperationalViewHttpHandler } from "@outbound/interface/http/operational-view-handler"; +import { EditorialStrategyApplication } from "@outbound/application/content/editorial-strategy"; +import { PostgresEditorialStrategyRepository } from "@outbound/infrastructure/content/postgres-editorial-strategy-repository"; +import { LangChainEditorialStrategyGenerator } from "@outbound/infrastructure/content/langchain-editorial-strategy-generator"; +import { PostgresAiRunRecorder } from "@outbound/infrastructure/ai/postgres-ai-run-recorder"; +import { createContentStrategyHttpHandler, isContentStrategyRoute } from "@outbound/interface/http/content-strategy-handler"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -256,6 +261,17 @@ const operationalViews = createOperationalViewHttpHandler({ database: database.db, contextResolver: auth.contextResolver, }); +const contentStrategy = createContentStrategyHttpHandler({ + contextResolver: auth.contextResolver, + application: new EditorialStrategyApplication( + new PostgresEditorialStrategyRepository(database.db), + new LangChainEditorialStrategyGenerator( + process.env, + workspaceAiSettingsRepository, + new PostgresAiRunRecorder(database.db, clock, ids), + ), + ), +}); const port = positiveIntegerEnvironment("PORT", 3000); const server = Bun.serve({ port, @@ -284,6 +300,7 @@ const server = Bun.serve({ if (isOperatorConsoleRoute(pathname)) return operatorConsole(request); if (isWorkspaceOnboardingRoute(pathname)) return workspaceOnboarding(request); if (isWorkspaceDataRoute(pathname, request.method)) return workspaceData(request); + if (isContentStrategyRoute(pathname)) return contentStrategy(request); if ( pathname === "/api/v1/workspace/operational-summary" || pathname === "/api/v1/activity" diff --git a/apps/web/app/w/[workspaceSlug]/activity/page.tsx b/apps/web/app/w/[workspaceSlug]/activity/page.tsx index 9abbfd4..385ff80 100644 --- a/apps/web/app/w/[workspaceSlug]/activity/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/activity/page.tsx @@ -57,7 +57,10 @@ function ActivityContent({ activity, workspaceSlug }: { activity: ActivityWorksp

{activity.lens === "inbound" ? "Inbound LinkedIn sera le prochain moteur activé" : "La Symbiose attend une première preuve Inbound"}

{activity.lens === "inbound" ? "La stratégie, les idées sourcées, le pipeline éditorial et la publication durable seront installés sans perturber Outbound." : "Elle reliera ensuite publications, interactions, conversations et appels sans modifier l’origine campagne/hors campagne."}

- Vérifier les prérequis +
+ {activity.lens === "inbound" ? Préparer la stratégie : null} + Vérifier les prérequis +
); } diff --git a/apps/web/app/w/[workspaceSlug]/content/strategy/actions.ts b/apps/web/app/w/[workspaceSlug]/content/strategy/actions.ts new file mode 100644 index 0000000..e8ca50a --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/strategy/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { deriveEditorialStrategy, publishEditorialStrategy } from "@/lib/api"; + +export async function deriveStrategyAction(workspaceSlug: string) { + return deriveEditorialStrategy(workspaceSlug, `strategy:derive:${crypto.randomUUID()}`); +} + +export async function publishStrategyAction(workspaceSlug: string) { + return publishEditorialStrategy(workspaceSlug, `strategy:publish:${crypto.randomUUID()}`); +} diff --git a/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx b/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx new file mode 100644 index 0000000..3873827 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx @@ -0,0 +1,50 @@ +import { AlertTriangle, ArrowLeft, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { getEditorialStrategy } from "@/lib/api"; +import { StrategyActions } from "./strategy-actions"; + +export const metadata = { title: "Stratégie Inbound — Noosphere" }; +export const dynamic = "force-dynamic"; + +export default async function EditorialStrategyPage({ params }: { params: Promise<{ workspaceSlug: string }> }) { + const { workspaceSlug } = await params; + const strategy = await getEditorialStrategy(workspaceSlug); + return ( + <> +
+
+ Activité Inbound +
Stratégie éditoriale ancrée
+

Stratégie LinkedIn

+

Noosphere relie l’offre publiée, l’ICP actif et les claims autorisés avant de produire une seule idée.

+
+ +
+ + {!strategy ?

Aucune stratégie dérivée

Publiez d’abord une offre et un ICP. La dérivation K3 conservera exactement les versions utilisées.

Vérifier les prérequis
: ( + <> +
+ + + +
+ +
+
+

Piliers éditoriaux

Chaque promesse indique le type de preuve requis avant rédaction.

{strategy.draft.pillars.length}
+
{strategy.draft.pillars.map((pillar, index) =>
{index + 1}

{pillar.name}

{pillar.promise}

{pillar.proofTypes.map((proof) => {proof})}
)}
+
+ +
+

Contrat de vérité

{strategy.draft.allowedClaimIds.length} claim{strategy.draft.allowedClaimIds.length === 1 ? "" : "s"} sourcé{strategy.draft.allowedClaimIds.length === 1 ? "" : "s"} ou validé{strategy.draft.allowedClaimIds.length === 1 ? "" : "s"} peuvent être utilisés. Les autres faits restent des opinions explicites.

Claims vérifiés côté serveur
+

Voix

{strategy.draft.voice.traits.map((trait) => {trait})}

À éviter

    {strategy.draft.voice.avoid.map((item) =>
  • — {item}
  • )}
+

Traçabilité IA

Provider
{strategy.derivation.provider}
Modèle
{strategy.derivation.model}
Prompt
{strategy.derivation.promptVersion}
+
+
+ + )} + + ); +} + +function Metric({ label, value }: { label: string; value: string }) { return

{label}

{value}
; } diff --git a/apps/web/app/w/[workspaceSlug]/content/strategy/strategy-actions.tsx b/apps/web/app/w/[workspaceSlug]/content/strategy/strategy-actions.tsx new file mode 100644 index 0000000..f2db04a --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/strategy/strategy-actions.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { deriveStrategyAction, publishStrategyAction } from "./actions"; + +export function StrategyActions({ workspaceSlug, hasStrategy, currentVersion }: { workspaceSlug: string; hasStrategy: boolean; currentVersion: number }) { + const router = useRouter(); + const [pending, setPending] = useState<"derive" | "publish" | null>(null); + const [error, setError] = useState(null); + async function run(kind: "derive" | "publish") { + setPending(kind); + setError(null); + try { + if (kind === "derive") await deriveStrategyAction(workspaceSlug); + else await publishStrategyAction(workspaceSlug); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "L’opération a échoué"); + } finally { + setPending(null); + } + } + return ( +
+ {error ? {error} : null} + + {hasStrategy ? : null} +
+ ); +} diff --git a/apps/web/app/w/[workspaceSlug]/settings/page.tsx b/apps/web/app/w/[workspaceSlug]/settings/page.tsx index 8d5f328..0d27391 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/settings/page.tsx @@ -59,6 +59,7 @@ export default async function WorkspaceSettingsPage({ params, searchParams }: {
} label="Produit et offre" detail="Ce que l’IA doit vendre" /> } label="ICP et segments" detail="Les profils à rechercher" /> + } label="Stratégie Inbound" detail="Audience, piliers, preuves et cadence LinkedIn" /> } label="Comptes et canaux" detail="LinkedIn, email, WhatsApp" /> } label="Automatisation" detail="Setter, relances et sécurité" /> } label="Agenda" detail="Créneaux et réservations" /> diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 68c0dee..74b709b 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -112,6 +112,31 @@ export interface ActivityWorkspacePage { readonly pagination: { readonly nextCursor: string | null }; } +export interface EditorialStrategySnapshot { + readonly audience: { readonly name: string; readonly summary: string; readonly awareness: "unaware" | "problem_aware" | "solution_aware" | "product_aware" | "mixed" }; + readonly pillars: readonly { readonly name: string; readonly promise: string; readonly proofTypes: readonly string[] }[]; + readonly voice: { readonly traits: readonly string[]; readonly avoid: readonly string[] }; + readonly formats: readonly ("linkedin_text" | "linkedin_document" | "linkedin_image" | "linkedin_video")[]; + readonly cadence: { readonly postsPerWeek: number; readonly preferredDays: readonly number[]; readonly timezone: string }; + readonly callsToAction: readonly string[]; + readonly allowedClaimIds: readonly string[]; + readonly forbiddenTopics: readonly string[]; +} + +export interface EditorialStrategy { + readonly id: string; + readonly name: string; + readonly offerId: string; + readonly offerVersionId: string; + readonly icpId: string; + readonly icpVersionId: string; + readonly currentVersion: number; + readonly draft: EditorialStrategySnapshot; + readonly derivation: { readonly provider: string; readonly model: string; readonly promptVersion: string; readonly aiRunId: string | null }; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface SetupReadinessItem { readonly key: "product" | "icp" | "accounts" | "automation" | "calendar" | "knowledge"; readonly label: string; @@ -178,6 +203,23 @@ export async function getOperationalSummary(workspaceSlug: string, input: { atte return crmFetch(workspaceSlug, `/api/v1/workspace/operational-summary${params.size ? `?${params}` : ""}`); } +export async function getEditorialStrategy(workspaceSlug: string): Promise { + try { + return await crmFetch(workspaceSlug, "/api/v1/content/strategy"); + } catch (error) { + if (error instanceof OutboundApiError && error.status === 404) return null; + throw error; + } +} + +export async function deriveEditorialStrategy(workspaceSlug: string, requestKey: string): Promise { + return crmFetch(workspaceSlug, "/api/v1/content/strategy/derive", { method: "POST", body: { requestKey } }); +} + +export async function publishEditorialStrategy(workspaceSlug: string, requestKey: string): Promise<{ readonly id: string; readonly version: number }> { + return crmFetch(workspaceSlug, "/api/v1/content/strategy/publish", { method: "POST", body: { requestKey } }); +} + export async function getActivity(workspaceSlug: string, lens: NoosphereLens, cursor?: string): Promise { const params = new URLSearchParams({ lens }); if (cursor) params.set("cursor", cursor); diff --git a/packages/application/src/content/editorial-strategy.ts b/packages/application/src/content/editorial-strategy.ts new file mode 100644 index 0000000..21553f9 --- /dev/null +++ b/packages/application/src/content/editorial-strategy.ts @@ -0,0 +1,138 @@ +import { + assertStrategyClaimsAreAuthorized, + type EditorialStrategySnapshot, +} from "@outbound/domain/content/editorial-strategy"; +import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; + +export interface EditorialStrategyGrounding { + readonly offer: { + readonly id: string; + readonly versionId: string; + readonly name: string; + readonly category: string; + readonly valueProposition: string; + readonly targetAudience: string; + readonly pricing: unknown; + readonly commercialRules: unknown; + readonly constraints: unknown; + readonly objections: unknown; + readonly claims: readonly { + readonly id: string; + readonly claim: string; + readonly validationStatus: "hypothesis" | "sourced" | "validated" | "invalidated"; + readonly evidenceUri: string | null; + }[]; + }; + readonly icp: { + readonly id: string; + readonly versionId: string; + readonly name: string; + readonly criteria: unknown; + readonly buyingCommittee: unknown; + readonly problems: unknown; + readonly signals: unknown; + readonly exclusions: unknown; + }; +} + +export interface EditorialStrategyView { + readonly id: string; + readonly workspaceId: string; + readonly name: string; + readonly offerId: string; + readonly offerVersionId: string; + readonly icpId: string; + readonly icpVersionId: string; + readonly currentVersion: number; + readonly draft: EditorialStrategySnapshot; + readonly derivation: { + readonly provider: string; + readonly model: string; + readonly promptVersion: string; + readonly aiRunId: string | null; + }; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface EditorialStrategyVersionView { + readonly id: string; + readonly strategyId: string; + readonly version: number; + readonly snapshot: EditorialStrategySnapshot; + readonly offerVersionId: string; + readonly icpVersionId: string; + readonly provider: string; + readonly model: string; + readonly promptVersion: string; + readonly aiRunId: string | null; + readonly publishedAt: Date; +} + +export interface EditorialStrategyRepository { + grounding(workspaceId: string): Promise; + find(workspaceId: string): Promise; + findRequest(input: { workspaceId: string; operation: string; requestKey: string }): Promise; + saveDerived(input: { + workspaceId: string; + userId: string; + requestKey: string; + grounding: EditorialStrategyGrounding; + snapshot: EditorialStrategySnapshot; + derivation: EditorialStrategyView["derivation"]; + }): Promise; + updateDraft(input: { + workspaceId: string; + userId: string; + requestKey: string; + snapshot: EditorialStrategySnapshot; + }): Promise; + publish(input: { workspaceId: string; userId: string; requestKey: string }): Promise; +} + +export interface EditorialStrategyGenerator { + generate(input: { workspaceId: string; grounding: EditorialStrategyGrounding }): Promise<{ + snapshot: EditorialStrategySnapshot; + metadata: EditorialStrategyView["derivation"]; + }>; +} + +export class EditorialStrategyApplication { + constructor( + private readonly repository: EditorialStrategyRepository, + private readonly generator: EditorialStrategyGenerator, + ) {} + + find(workspaceId: string): Promise { + return this.repository.find(workspaceId); + } + + async derive(input: { workspaceId: string; userId: string; requestKey: string }): Promise { + const replay = await this.repository.findRequest({ ...input, operation: "strategy.derive" }); + if (replay) return replay as EditorialStrategyView; + const grounding = await this.repository.grounding(input.workspaceId); + const generated = await this.generator.generate({ workspaceId: input.workspaceId, grounding }); + const snapshot = editorialStrategySnapshotSchema.parse(generated.snapshot); + assertStrategyClaimsAreAuthorized(snapshot, grounding.offer.claims + .filter((claim) => claim.validationStatus === "sourced" || claim.validationStatus === "validated") + .map((claim) => claim.id)); + return this.repository.saveDerived({ ...input, grounding, snapshot, derivation: generated.metadata }); + } + + async updateDraft(input: { workspaceId: string; userId: string; requestKey: string; snapshot: EditorialStrategySnapshot }): Promise { + const replay = await this.repository.findRequest({ ...input, operation: "strategy.update" }); + if (replay) return replay as EditorialStrategyView; + const grounding = await this.repository.grounding(input.workspaceId); + const snapshot = editorialStrategySnapshotSchema.parse(input.snapshot); + assertStrategyClaimsAreAuthorized(snapshot, grounding.offer.claims + .filter((claim) => claim.validationStatus === "sourced" || claim.validationStatus === "validated") + .map((claim) => claim.id)); + return this.repository.updateDraft({ ...input, snapshot }); + } + + async publish(input: { workspaceId: string; userId: string; requestKey: string }): Promise { + const replay = await this.repository.findRequest({ ...input, operation: "strategy.publish" }); + if (replay) return replay as EditorialStrategyVersionView; + return this.repository.publish(input); + } +} diff --git a/packages/contracts/openapi/product-research-v1.json b/packages/contracts/openapi/product-research-v1.json index 15257aa..292f093 100644 --- a/packages/contracts/openapi/product-research-v1.json +++ b/packages/contracts/openapi/product-research-v1.json @@ -1472,6 +1472,19 @@ "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }, { "$ref": "#/components/parameters/ContactId" }], "get": { "operationId": "listContactMerges", "responses": { "200": { "description": "Contact merge history" } } } }, + "/api/v1/content/strategy": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "get": { "operationId": "getEditorialStrategy", "summary": "Read the workspace editorial strategy derived from published offer and ICP snapshots", "responses": { "200": { "description": "Current editorial strategy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorialStrategy" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "404": { "$ref": "#/components/responses/RunNotFound" } } }, + "put": { "operationId": "updateEditorialStrategyDraft", "summary": "Idempotently replace the strategy draft while enforcing authorized claims", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": ["requestKey", "snapshot"], "properties": { "requestKey": { "type": "string", "minLength": 8, "maxLength": 300 }, "snapshot": { "$ref": "#/components/schemas/EditorialStrategySnapshot" } } } } } }, "responses": { "200": { "description": "Updated strategy draft", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorialStrategy" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "422": { "$ref": "#/components/responses/InvalidRequest" } } } + }, + "/api/v1/content/strategy/derive": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "post": { "operationId": "deriveEditorialStrategy", "summary": "Derive a grounded strategy with the configured Kimi K3 principal model", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentRequestKey" } } } }, "responses": { "201": { "description": "Editorial strategy derived", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorialStrategy" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "409": { "$ref": "#/components/responses/Conflict" }, "422": { "$ref": "#/components/responses/InvalidRequest" } } } + }, + "/api/v1/content/strategy/publish": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "post": { "operationId": "publishEditorialStrategy", "summary": "Publish an immutable editorial strategy version", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentRequestKey" } } } }, "responses": { "201": { "description": "Immutable strategy version", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorialStrategyVersion" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "404": { "$ref": "#/components/responses/RunNotFound" } } } + }, "/api/v1/workspace/operational-summary": { "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], "get": { "operationId": "getWorkspaceOperationalSummary", "summary": "Read the tenant-scoped À traiter projection", "parameters": [{ "name": "attentionCursor", "in": "query", "schema": { "type": "string" } }, { "name": "attentionLimit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } }], "responses": { "200": { "description": "Operational counters, engines, outcomes, exceptions, jobs and account health", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceOperationalSummary" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } @@ -2969,6 +2982,22 @@ "type": "object", "required": ["data", "pagination"], "properties": { "data": { "type": "array", "items": { "type": "object", "required": ["id", "contactId", "channel", "status", "unreadCount", "lastMessageAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "contactId": { "type": "string", "format": "uuid" }, "firstName": { "type": "string" }, "lastName": { "type": "string" }, "campaignId": { "type": ["string", "null"], "format": "uuid" }, "campaignName": { "type": ["string", "null"] }, "channel": { "type": "string", "enum": ["linkedin", "email", "whatsapp"] }, "status": { "type": "string" }, "unreadCount": { "type": "integer" }, "lastMessage": { "type": ["object", "null"] }, "lastMessageAt": { "type": "string", "format": "date-time" } } } }, "pagination": { "type": "object", "required": ["page", "pageSize", "total", "hasNext"], "properties": { "page": { "type": "integer" }, "pageSize": { "type": "integer" }, "total": { "type": "integer" }, "hasNext": { "type": "boolean" } } } } }, + "ContentRequestKey": { "type": "object", "additionalProperties": false, "required": ["requestKey"], "properties": { "requestKey": { "type": "string", "minLength": 8, "maxLength": 300 } } }, + "EditorialStrategySnapshot": { + "type": "object", "additionalProperties": false, "required": ["audience", "pillars", "voice", "formats", "cadence", "callsToAction", "allowedClaimIds", "forbiddenTopics"], + "properties": { + "audience": { "type": "object", "additionalProperties": false, "required": ["name", "summary", "awareness"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 500 }, "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, "awareness": { "type": "string", "enum": ["unaware", "problem_aware", "solution_aware", "product_aware", "mixed"] } } }, + "pillars": { "type": "array", "minItems": 3, "maxItems": 6, "items": { "type": "object", "additionalProperties": false, "required": ["name", "promise", "proofTypes"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "promise": { "type": "string", "minLength": 1, "maxLength": 1000 }, "proofTypes": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 200 } } } } }, + "voice": { "type": "object", "additionalProperties": false, "required": ["traits", "avoid"], "properties": { "traits": { "type": "array", "minItems": 2, "maxItems": 8, "items": { "type": "string" } }, "avoid": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "type": "string" } } } }, + "formats": { "type": "array", "minItems": 1, "maxItems": 4, "items": { "type": "string", "enum": ["linkedin_text", "linkedin_document", "linkedin_image", "linkedin_video"] } }, + "cadence": { "type": "object", "additionalProperties": false, "required": ["postsPerWeek", "preferredDays", "timezone"], "properties": { "postsPerWeek": { "type": "integer", "minimum": 1, "maximum": 7 }, "preferredDays": { "type": "array", "minItems": 1, "maxItems": 7, "items": { "type": "integer", "minimum": 1, "maximum": 7 } }, "timezone": { "type": "string", "minLength": 1, "maxLength": 120 } } }, + "callsToAction": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string" } }, + "allowedClaimIds": { "type": "array", "maxItems": 100, "items": { "type": "string", "format": "uuid" } }, + "forbiddenTopics": { "type": "array", "maxItems": 30, "items": { "type": "string" } } + } + }, + "EditorialStrategy": { "type": "object", "required": ["id", "workspaceId", "name", "offerId", "offerVersionId", "icpId", "icpVersionId", "currentVersion", "draft", "derivation", "createdAt", "updatedAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "offerId": { "type": "string", "format": "uuid" }, "offerVersionId": { "type": "string", "format": "uuid" }, "icpId": { "type": "string", "format": "uuid" }, "icpVersionId": { "type": "string", "format": "uuid" }, "currentVersion": { "type": "integer", "minimum": 0 }, "draft": { "$ref": "#/components/schemas/EditorialStrategySnapshot" }, "derivation": { "type": "object", "required": ["provider", "model", "promptVersion", "aiRunId"], "properties": { "provider": { "type": "string" }, "model": { "type": "string" }, "promptVersion": { "type": "string" }, "aiRunId": { "type": ["string", "null"], "format": "uuid" } } }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } } }, + "EditorialStrategyVersion": { "type": "object", "required": ["id", "strategyId", "version", "snapshot", "offerVersionId", "icpVersionId", "provider", "model", "promptVersion", "aiRunId", "publishedAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "strategyId": { "type": "string", "format": "uuid" }, "version": { "type": "integer", "minimum": 1 }, "snapshot": { "$ref": "#/components/schemas/EditorialStrategySnapshot" }, "offerVersionId": { "type": "string", "format": "uuid" }, "icpVersionId": { "type": "string", "format": "uuid" }, "provider": { "type": "string" }, "model": { "type": "string" }, "promptVersion": { "type": "string" }, "aiRunId": { "type": ["string", "null"], "format": "uuid" }, "publishedAt": { "type": "string", "format": "date-time" } } }, "Problem": { "type": "object", "required": [ diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts new file mode 100644 index 0000000..39768dd --- /dev/null +++ b/packages/contracts/src/content.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import type { EditorialStrategySnapshot } from "@outbound/domain/content/editorial-strategy"; + +export const editorialStrategySnapshotSchema: z.ZodType = z.object({ + audience: z.object({ + name: z.string().trim().min(1).max(500), + summary: z.string().trim().min(1).max(2_000), + awareness: z.enum(["unaware", "problem_aware", "solution_aware", "product_aware", "mixed"]), + }).strict(), + pillars: z.array(z.object({ + name: z.string().trim().min(1).max(200), + promise: z.string().trim().min(1).max(1_000), + proofTypes: z.array(z.string().trim().min(1).max(200)).min(1).max(8), + }).strict()).min(3).max(6), + voice: z.object({ + traits: z.array(z.string().trim().min(1).max(120)).min(2).max(8), + avoid: z.array(z.string().trim().min(1).max(240)).min(1).max(12), + }).strict(), + formats: z.array(z.enum(["linkedin_text", "linkedin_document", "linkedin_image", "linkedin_video"])).min(1).max(4), + cadence: z.object({ + postsPerWeek: z.number().int().min(1).max(7), + preferredDays: z.array(z.number().int().min(1).max(7)).min(1).max(7), + timezone: z.string().trim().min(1).max(120), + }).strict(), + callsToAction: z.array(z.string().trim().min(1).max(300)).min(1).max(8), + allowedClaimIds: z.array(z.string().uuid()).max(100), + forbiddenTopics: z.array(z.string().trim().min(1).max(300)).max(30), +}).strict(); diff --git a/packages/domain/src/content/editorial-strategy.ts b/packages/domain/src/content/editorial-strategy.ts new file mode 100644 index 0000000..aa85271 --- /dev/null +++ b/packages/domain/src/content/editorial-strategy.ts @@ -0,0 +1,27 @@ +export interface EditorialStrategySnapshot { + readonly audience: { + readonly name: string; + readonly summary: string; + readonly awareness: "unaware" | "problem_aware" | "solution_aware" | "product_aware" | "mixed"; + }; + readonly pillars: readonly { + readonly name: string; + readonly promise: string; + readonly proofTypes: readonly string[]; + }[]; + readonly voice: { readonly traits: readonly string[]; readonly avoid: readonly string[] }; + readonly formats: readonly ("linkedin_text" | "linkedin_document" | "linkedin_image" | "linkedin_video")[]; + readonly cadence: { readonly postsPerWeek: number; readonly preferredDays: readonly number[]; readonly timezone: string }; + readonly callsToAction: readonly string[]; + readonly allowedClaimIds: readonly string[]; + readonly forbiddenTopics: readonly string[]; +} + +export function assertStrategyClaimsAreAuthorized( + snapshot: EditorialStrategySnapshot, + authorizedClaimIds: readonly string[], +): void { + const authorized = new Set(authorizedClaimIds); + const invalid = snapshot.allowedClaimIds.filter((id) => !authorized.has(id)); + if (invalid.length > 0) throw new Error("EDITORIAL_STRATEGY_UNAUTHORIZED_CLAIM"); +} diff --git a/packages/infrastructure/migrations/0067_noosphere_editorial_strategy.sql b/packages/infrastructure/migrations/0067_noosphere_editorial_strategy.sql new file mode 100644 index 0000000..02b8c33 --- /dev/null +++ b/packages/infrastructure/migrations/0067_noosphere_editorial_strategy.sql @@ -0,0 +1,73 @@ +CREATE TYPE "editorial_strategy_status" AS ENUM ('draft', 'active', 'archived'); + +CREATE TABLE "editorial_strategies" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL, + "name" varchar(500) NOT NULL, + "offer_id" uuid NOT NULL, + "offer_version_id" uuid NOT NULL, + "icp_id" uuid NOT NULL, + "icp_version_id" uuid NOT NULL, + "status" "editorial_strategy_status" DEFAULT 'draft' NOT NULL, + "current_version" integer DEFAULT 0 NOT NULL, + "draft" jsonb NOT NULL, + "provider" varchar(120) NOT NULL, + "model" varchar(200) NOT NULL, + "prompt_version" varchar(120) NOT NULL, + "ai_run_id" uuid, + "created_by" uuid, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "editorial_strategies_workspace_id_uq" UNIQUE ("workspace_id", "id"), + CONSTRAINT "editorial_strategies_workspace_offer_fk" FOREIGN KEY ("workspace_id", "offer_id") REFERENCES "offers" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategies_workspace_offer_version_fk" FOREIGN KEY ("workspace_id", "offer_version_id") REFERENCES "offer_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategies_workspace_icp_fk" FOREIGN KEY ("workspace_id", "icp_id") REFERENCES "icps" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategies_workspace_icp_version_fk" FOREIGN KEY ("workspace_id", "icp_version_id") REFERENCES "icp_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategies_workspace_ai_run_fk" FOREIGN KEY ("workspace_id", "ai_run_id") REFERENCES "ai_runs" ("workspace_id", "id") ON DELETE set null, + CONSTRAINT "editorial_strategies_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "auth_users" ("id") ON DELETE set null +); + +CREATE UNIQUE INDEX "editorial_strategies_workspace_grounding_uq" + ON "editorial_strategies" ("workspace_id", "offer_id", "icp_id") + WHERE "deleted_at" IS NULL; + +CREATE TABLE "editorial_strategy_versions" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL, + "strategy_id" uuid NOT NULL, + "version" integer NOT NULL, + "offer_version_id" uuid NOT NULL, + "icp_version_id" uuid NOT NULL, + "snapshot" jsonb NOT NULL, + "provider" varchar(120) NOT NULL, + "model" varchar(200) NOT NULL, + "prompt_version" varchar(120) NOT NULL, + "ai_run_id" uuid, + "published_by" uuid, + "published_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "editorial_strategy_versions_workspace_id_uq" UNIQUE ("workspace_id", "id"), + CONSTRAINT "editorial_strategy_versions_workspace_strategy_fk" FOREIGN KEY ("workspace_id", "strategy_id") REFERENCES "editorial_strategies" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategy_versions_workspace_offer_fk" FOREIGN KEY ("workspace_id", "offer_version_id") REFERENCES "offer_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategy_versions_workspace_icp_fk" FOREIGN KEY ("workspace_id", "icp_version_id") REFERENCES "icp_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "editorial_strategy_versions_workspace_ai_run_fk" FOREIGN KEY ("workspace_id", "ai_run_id") REFERENCES "ai_runs" ("workspace_id", "id") ON DELETE set null, + CONSTRAINT "editorial_strategy_versions_published_by_fk" FOREIGN KEY ("published_by") REFERENCES "auth_users" ("id") ON DELETE set null +); + +CREATE UNIQUE INDEX "editorial_strategy_versions_strategy_version_uq" + ON "editorial_strategy_versions" ("workspace_id", "strategy_id", "version"); + +CREATE TABLE "content_operation_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL REFERENCES "workspaces" ("id") ON DELETE cascade, + "operation" varchar(120) NOT NULL, + "request_key" varchar(300) NOT NULL, + "resource_type" varchar(120) NOT NULL, + "resource_id" uuid NOT NULL, + "response" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX "content_operation_requests_workspace_key_uq" + ON "content_operation_requests" ("workspace_id", "operation", "request_key"); diff --git a/packages/infrastructure/migrations/0068_editorial_strategy_immutability.sql b/packages/infrastructure/migrations/0068_editorial_strategy_immutability.sql new file mode 100644 index 0000000..3ea9dab --- /dev/null +++ b/packages/infrastructure/migrations/0068_editorial_strategy_immutability.sql @@ -0,0 +1,13 @@ +CREATE OR REPLACE FUNCTION "public"."reject_editorial_strategy_version_mutation"() RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'EDITORIAL_STRATEGY_VERSION_IMMUTABLE'; +END; +$$; + +DROP TRIGGER IF EXISTS "editorial_strategy_versions_immutable_trg" ON "editorial_strategy_versions"; + +CREATE TRIGGER "editorial_strategy_versions_immutable_trg" +BEFORE UPDATE OR DELETE ON "editorial_strategy_versions" +FOR EACH ROW EXECUTE FUNCTION "public"."reject_editorial_strategy_version_mutation"(); diff --git a/packages/infrastructure/migrations/meta/_journal.json b/packages/infrastructure/migrations/meta/_journal.json index a3f59e5..4ce7d3c 100644 --- a/packages/infrastructure/migrations/meta/_journal.json +++ b/packages/infrastructure/migrations/meta/_journal.json @@ -470,6 +470,20 @@ "when": 1790031600000, "tag": "0066_continuous_empty_campaign_sourcing", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1797775200000, + "tag": "0067_noosphere_editorial_strategy", + "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1797775260000, + "tag": "0068_editorial_strategy_immutability", + "breakpoints": true } ] } diff --git a/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts b/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts new file mode 100644 index 0000000..06c42bf --- /dev/null +++ b/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts @@ -0,0 +1,98 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { tool } from "@langchain/core/tools"; +import type { + EditorialStrategyGenerator, + EditorialStrategyGrounding, +} from "@outbound/application/content/editorial-strategy"; +import type { AiRunRecorder } from "@outbound/application/ai/ai-run-recorder"; +import type { WorkspaceAiModelPolicyReader } from "@outbound/application/workspaces/workspace-ai-settings"; +import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; +import { + buildChatModelFields, + resolveResearchModelConfigurationFromEnvironment, +} from "@outbound/infrastructure/ai/langchain-research-agent-executor"; + +type StrategyModelInvoker = (input: { + readonly fields: ConstructorParameters[0]; + readonly grounding: EditorialStrategyGrounding; +}) => Promise; + +export class LangChainEditorialStrategyGenerator implements EditorialStrategyGenerator { + readonly #configuration: ReturnType; + + constructor( + environment: Readonly> = process.env, + private readonly modelPolicyReader?: WorkspaceAiModelPolicyReader, + private readonly aiRunRecorder?: AiRunRecorder, + private readonly invokeModel: StrategyModelInvoker = invokeStrategyModel, + ) { + this.#configuration = resolveResearchModelConfigurationFromEnvironment(environment); + } + + async generate(input: Parameters[0]) { + const startedAt = performance.now(); + const workspacePolicy = await this.modelPolicyReader?.find(input.workspaceId); + const model = workspacePolicy?.researchModels[0] ?? this.#configuration.researchModels[0]!; + const snapshot = editorialStrategySnapshotSchema.parse(await this.invokeModel({ + fields: buildChatModelFields(this.#configuration, model, "max"), + grounding: input.grounding, + })); + const promptVersion = "noosphere-editorial-strategy-v1"; + const aiRun = await this.aiRunRecorder?.record({ + workspaceId: input.workspaceId, + purpose: "content_strategy", + provider: this.#configuration.provider, + model, + promptVersion, + shadow: false, + inputHash: new Bun.CryptoHasher("sha256").update(JSON.stringify(input.grounding)).digest("hex"), + output: snapshot, + status: "completed", + cost: null, + latencyMs: Math.max(0, Math.round(performance.now() - startedAt)), + }); + return { + snapshot, + metadata: { + provider: this.#configuration.provider, + model, + promptVersion, + aiRunId: aiRun?.id ?? null, + }, + }; + } +} + +async function invokeStrategyModel(input: Parameters[0]) { + const submit = tool(async (value) => value, { + name: "submit_editorial_strategy", + description: "Submit the complete grounded LinkedIn editorial strategy.", + schema: editorialStrategySnapshotSchema, + }); + const authorizedClaims = input.grounding.offer.claims.filter((claim) => + claim.validationStatus === "sourced" || claim.validationStatus === "validated" + ); + const response = await new ChatOpenAI(input.fields).bindTools([submit], { tool_choice: "auto" }).invoke([ + { + role: "system", + content: [ + "You are Noosphere's principal LinkedIn editorial strategist.", + "Derive a specific strategy only from the supplied published offer and ICP snapshots.", + "Do not invent customer proof, market facts, performance numbers, intent or product capabilities.", + "Only IDs listed in authorizedClaims may appear in allowedClaimIds. Hypothesis and invalidated claims are forbidden.", + "Pillars must map a real ICP problem to the offer and name the proof type required before a factual post can be written.", + "Voice traits must be operational. Avoid generic B2B language, empty thought leadership, manufactured urgency and interchangeable hooks.", + "Use linkedin_text as the only format unless the supplied constraints explicitly prove another format is available.", + "Cadence must be sustainable: default to three posts per week in Europe/Paris unless the inputs justify less.", + "Call submit_editorial_strategy exactly once.", + ].join("\n"), + }, + { + role: "user", + content: JSON.stringify({ ...input.grounding, authorizedClaims }), + }, + ]); + const call = response.tool_calls?.find((candidate) => candidate.name === "submit_editorial_strategy"); + if (!call) throw new Error("EDITORIAL_STRATEGY_TOOL_CALL_MISSING"); + return call.args; +} diff --git a/packages/infrastructure/src/content/postgres-editorial-strategy-repository.ts b/packages/infrastructure/src/content/postgres-editorial-strategy-repository.ts new file mode 100644 index 0000000..ba08a1e --- /dev/null +++ b/packages/infrastructure/src/content/postgres-editorial-strategy-repository.ts @@ -0,0 +1,297 @@ +import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import type { + EditorialStrategyGrounding, + EditorialStrategyRepository, + EditorialStrategyVersionView, + EditorialStrategyView, +} from "@outbound/application/content/editorial-strategy"; +import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; +import type { Database } from "@outbound/infrastructure/database/client"; +import { + auditLogs, + contentOperationRequests, + editorialStrategies, + editorialStrategyVersions, + icpVersions, + offerClaims, + offerVersions, + outboxEvents, +} from "@outbound/infrastructure/database/schema"; + +export class PostgresEditorialStrategyRepository implements EditorialStrategyRepository { + constructor(private readonly database: Database) {} + + async grounding(workspaceId: string): Promise { + const [offers, icps] = await Promise.all([ + this.database.select().from(offerVersions) + .where(eq(offerVersions.workspaceId, workspaceId)) + .orderBy(desc(offerVersions.publishedAt)).limit(1), + this.database.select().from(icpVersions) + .where(eq(icpVersions.workspaceId, workspaceId)) + .orderBy(desc(icpVersions.publishedAt)).limit(1), + ]); + const offer = offers[0]; + const icp = icps[0]; + if (!offer) throw new Error("EDITORIAL_STRATEGY_OFFER_REQUIRED"); + if (!icp) throw new Error("EDITORIAL_STRATEGY_ICP_REQUIRED"); + const claims = await this.database.select().from(offerClaims).where(and( + eq(offerClaims.workspaceId, workspaceId), + eq(offerClaims.offerVersionId, offer.id), + )); + return { + offer: { + id: offer.offerId, + versionId: offer.id, + name: offer.name, + category: offer.category, + valueProposition: offer.valueProposition, + targetAudience: offer.targetAudience, + pricing: offer.pricing, + commercialRules: offer.commercialRules, + constraints: offer.constraints, + objections: offer.objections, + claims: claims.map((claim) => ({ + id: claim.id, + claim: claim.claim, + validationStatus: claim.validationStatus, + evidenceUri: claim.evidenceUri, + })), + }, + icp: { + id: icp.icpId, + versionId: icp.id, + name: icp.name, + criteria: icp.criteria, + buyingCommittee: icp.buyingCommittee, + problems: icp.problems, + signals: icp.signals, + exclusions: icp.exclusions, + }, + }; + } + + async find(workspaceId: string): Promise { + const rows = await this.database.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, workspaceId), + isNull(editorialStrategies.deletedAt), + )).orderBy(desc(editorialStrategies.updatedAt)).limit(1); + return rows[0] ? toStrategy(rows[0]) : null; + } + + async findRequest(input: { workspaceId: string; operation: string; requestKey: string }): Promise { + const rows = await this.database.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, input.operation), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + const request = rows[0]; + if (!request) return null; + if (request.resourceType === "EditorialStrategyVersion") { + const versions = await this.database.select().from(editorialStrategyVersions).where(and( + eq(editorialStrategyVersions.workspaceId, input.workspaceId), + eq(editorialStrategyVersions.id, request.resourceId), + )).limit(1); + return versions[0] ? toVersion(versions[0]) : null; + } + const strategies = await this.database.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.id, request.resourceId), + )).limit(1); + return strategies[0] ? toStrategy(strategies[0]) : null; + } + + async saveDerived(input: Parameters[0]): Promise { + return this.database.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${input.workspaceId}:editorial-strategy`}, 0))`); + const replay = await tx.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, "strategy.derive"), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + if (replay[0]) { + const current = await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.id, replay[0].resourceId), + )).limit(1); + if (current[0]) return toStrategy(current[0]); + } + const existing = await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.offerId, input.grounding.offer.id), + eq(editorialStrategies.icpId, input.grounding.icp.id), + isNull(editorialStrategies.deletedAt), + )).limit(1); + const now = new Date(); + const values = { + workspaceId: input.workspaceId, + name: `${input.grounding.offer.name} · ${input.grounding.icp.name}`, + offerId: input.grounding.offer.id, + offerVersionId: input.grounding.offer.versionId, + icpId: input.grounding.icp.id, + icpVersionId: input.grounding.icp.versionId, + draft: input.snapshot, + provider: input.derivation.provider, + model: input.derivation.model, + promptVersion: input.derivation.promptVersion, + aiRunId: input.derivation.aiRunId, + updatedAt: now, + }; + const saved = existing[0] + ? (await tx.update(editorialStrategies).set(values).where(eq(editorialStrategies.id, existing[0].id)).returning())[0]! + : (await tx.insert(editorialStrategies).values({ id: crypto.randomUUID(), createdBy: input.userId, ...values }).returning())[0]!; + await tx.insert(contentOperationRequests).values({ + workspaceId: input.workspaceId, + operation: "strategy.derive", + requestKey: input.requestKey, + resourceType: "EditorialStrategy", + resourceId: saved.id, + response: { strategyId: saved.id }, + }); + await appendEvent(tx, { + workspaceId: input.workspaceId, + userId: input.userId, + strategyId: saved.id, + eventType: "EditorialStrategyDerived", + changes: { offerVersionId: saved.offerVersionId, icpVersionId: saved.icpVersionId, model: saved.model }, + }); + return toStrategy(saved); + }); + } + + async updateDraft(input: Parameters[0]): Promise { + return this.database.transaction(async (tx) => { + const current = await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + isNull(editorialStrategies.deletedAt), + )).orderBy(desc(editorialStrategies.updatedAt)).limit(1); + if (!current[0]) throw new Error("EDITORIAL_STRATEGY_NOT_FOUND"); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${current[0].id}, 0))`); + const replay = await tx.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, "strategy.update"), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + if (replay[0]) { + const retained = await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.id, replay[0].resourceId), + )).limit(1); + if (retained[0]) return toStrategy(retained[0]); + } + const saved = (await tx.update(editorialStrategies).set({ draft: input.snapshot, updatedAt: new Date() }) + .where(and(eq(editorialStrategies.workspaceId, input.workspaceId), eq(editorialStrategies.id, current[0].id))).returning())[0]!; + await tx.insert(contentOperationRequests).values({ workspaceId: input.workspaceId, operation: "strategy.update", requestKey: input.requestKey, resourceType: "EditorialStrategy", resourceId: saved.id, response: { strategyId: saved.id } }); + await appendEvent(tx, { workspaceId: input.workspaceId, userId: input.userId, strategyId: saved.id, eventType: "EditorialStrategyDraftUpdated", changes: {} }); + return toStrategy(saved); + }); + } + + async publish(input: Parameters[0]): Promise { + return this.database.transaction(async (tx) => { + const strategies = await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + isNull(editorialStrategies.deletedAt), + )).orderBy(desc(editorialStrategies.updatedAt)).limit(1); + const strategy = strategies[0]; + if (!strategy) throw new Error("EDITORIAL_STRATEGY_NOT_FOUND"); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${strategy.id}, 0))`); + const replay = await tx.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, "strategy.publish"), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + if (replay[0]) { + const retained = await tx.select().from(editorialStrategyVersions).where(and( + eq(editorialStrategyVersions.workspaceId, input.workspaceId), + eq(editorialStrategyVersions.id, replay[0].resourceId), + )).limit(1); + if (retained[0]) return toVersion(retained[0]); + } + const current = (await tx.select().from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.id, strategy.id), + isNull(editorialStrategies.deletedAt), + )).limit(1))[0]; + if (!current) throw new Error("EDITORIAL_STRATEGY_NOT_FOUND"); + const latest = await tx.select().from(editorialStrategyVersions).where(and( + eq(editorialStrategyVersions.workspaceId, input.workspaceId), + eq(editorialStrategyVersions.strategyId, current.id), + )).orderBy(desc(editorialStrategyVersions.version)).limit(1); + const latestVersion = latest[0]; + let version = latestVersion; + if (!latestVersion || JSON.stringify(latestVersion.snapshot) !== JSON.stringify(current.draft)) { + version = (await tx.insert(editorialStrategyVersions).values({ + id: crypto.randomUUID(), + workspaceId: input.workspaceId, + strategyId: current.id, + version: (latestVersion?.version ?? 0) + 1, + offerVersionId: current.offerVersionId, + icpVersionId: current.icpVersionId, + snapshot: current.draft, + provider: current.provider, + model: current.model, + promptVersion: current.promptVersion, + aiRunId: current.aiRunId, + publishedBy: input.userId, + publishedAt: new Date(), + }).returning())[0]!; + await tx.update(editorialStrategies).set({ status: "active", currentVersion: version.version, updatedAt: version.publishedAt }).where(eq(editorialStrategies.id, current.id)); + await appendEvent(tx, { workspaceId: input.workspaceId, userId: input.userId, strategyId: current.id, eventType: "EditorialStrategyVersionPublished", changes: { version: version.version, versionId: version.id } }); + } + await tx.insert(contentOperationRequests).values({ workspaceId: input.workspaceId, operation: "strategy.publish", requestKey: input.requestKey, resourceType: "EditorialStrategyVersion", resourceId: version!.id, response: { strategyVersionId: version!.id } }); + return toVersion(version!); + }); + } +} + +function toStrategy(row: typeof editorialStrategies.$inferSelect): EditorialStrategyView { + return { + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + offerId: row.offerId, + offerVersionId: row.offerVersionId, + icpId: row.icpId, + icpVersionId: row.icpVersionId, + currentVersion: row.currentVersion, + draft: editorialStrategySnapshotSchema.parse(row.draft), + derivation: { provider: row.provider, model: row.model, promptVersion: row.promptVersion, aiRunId: row.aiRunId }, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toVersion(row: typeof editorialStrategyVersions.$inferSelect): EditorialStrategyVersionView { + return { + id: row.id, + strategyId: row.strategyId, + version: row.version, + snapshot: editorialStrategySnapshotSchema.parse(row.snapshot), + offerVersionId: row.offerVersionId, + icpVersionId: row.icpVersionId, + provider: row.provider, + model: row.model, + promptVersion: row.promptVersion, + aiRunId: row.aiRunId, + publishedAt: row.publishedAt, + }; +} + +async function appendEvent(tx: any, input: { workspaceId: string; userId: string; strategyId: string; eventType: string; changes: unknown }) { + const events = await tx.insert(outboxEvents).values({ + workspaceId: input.workspaceId, + aggregateType: "EditorialStrategy", + aggregateId: input.strategyId, + eventType: input.eventType, + payload: { type: input.eventType, strategyId: input.strategyId, workspaceId: input.workspaceId, ...input.changes as object }, + }).returning({ id: outboxEvents.id }); + if (events[0]) await tx.insert(auditLogs).values({ + workspaceId: input.workspaceId, + actorUserId: input.userId, + action: input.eventType, + subjectType: "EditorialStrategy", + subjectId: input.strategyId, + changes: input.changes, + sourceEventId: events[0].id, + }); +} diff --git a/packages/infrastructure/src/database/schema.ts b/packages/infrastructure/src/database/schema.ts index 9d719be..61cf487 100644 --- a/packages/infrastructure/src/database/schema.ts +++ b/packages/infrastructure/src/database/schema.ts @@ -148,6 +148,11 @@ export const offerClaimValidationStatusEnum = pgEnum("offer_claim_validation_sta "validated", "invalidated", ]); +export const editorialStrategyStatusEnum = pgEnum("editorial_strategy_status", [ + "draft", + "active", + "archived", +]); export const connectedAccountStatusEnum = pgEnum("connected_account_status", [ "pending", "connected", @@ -1354,6 +1359,86 @@ export const offerClaims = pgTable( ], ); +export const editorialStrategies = pgTable( + "editorial_strategies", + { + id: uuid("id").primaryKey(), + workspaceId: uuid("workspace_id").notNull(), + name: varchar("name", { length: 500 }).notNull(), + offerId: uuid("offer_id").notNull(), + offerVersionId: uuid("offer_version_id").notNull(), + icpId: uuid("icp_id").notNull(), + icpVersionId: uuid("icp_version_id").notNull(), + status: editorialStrategyStatusEnum("status").notNull().default("draft"), + currentVersion: integer("current_version").notNull().default(0), + draft: jsonb("draft").notNull(), + provider: varchar("provider", { length: 120 }).notNull(), + model: varchar("model", { length: 200 }).notNull(), + promptVersion: varchar("prompt_version", { length: 120 }).notNull(), + aiRunId: uuid("ai_run_id"), + createdBy: uuid("created_by").references(() => authUsers.id, { onDelete: "set null" }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.workspaceId, table.offerId], foreignColumns: [offers.workspaceId, offers.id], name: "editorial_strategies_workspace_offer_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.offerVersionId], foreignColumns: [offerVersions.workspaceId, offerVersions.id], name: "editorial_strategies_workspace_offer_version_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.icpId], foreignColumns: [icps.workspaceId, icps.id], name: "editorial_strategies_workspace_icp_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.icpVersionId], foreignColumns: [icpVersions.workspaceId, icpVersions.id], name: "editorial_strategies_workspace_icp_version_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.aiRunId], foreignColumns: [aiRuns.workspaceId, aiRuns.id], name: "editorial_strategies_workspace_ai_run_fk" }).onDelete("set null"), + unique("editorial_strategies_workspace_id_uq").on(table.workspaceId, table.id), + uniqueIndex("editorial_strategies_workspace_grounding_uq") + .on(table.workspaceId, table.offerId, table.icpId) + .where(sql`${table.deletedAt} is null`), + ], +); + +export const editorialStrategyVersions = pgTable( + "editorial_strategy_versions", + { + id: uuid("id").primaryKey(), + workspaceId: uuid("workspace_id").notNull(), + strategyId: uuid("strategy_id").notNull(), + version: integer("version").notNull(), + offerVersionId: uuid("offer_version_id").notNull(), + icpVersionId: uuid("icp_version_id").notNull(), + snapshot: jsonb("snapshot").notNull(), + provider: varchar("provider", { length: 120 }).notNull(), + model: varchar("model", { length: 200 }).notNull(), + promptVersion: varchar("prompt_version", { length: 120 }).notNull(), + aiRunId: uuid("ai_run_id"), + publishedBy: uuid("published_by").references(() => authUsers.id, { onDelete: "set null" }), + publishedAt: timestamp("published_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.workspaceId, table.strategyId], foreignColumns: [editorialStrategies.workspaceId, editorialStrategies.id], name: "editorial_strategy_versions_workspace_strategy_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.offerVersionId], foreignColumns: [offerVersions.workspaceId, offerVersions.id], name: "editorial_strategy_versions_workspace_offer_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.icpVersionId], foreignColumns: [icpVersions.workspaceId, icpVersions.id], name: "editorial_strategy_versions_workspace_icp_fk" }).onDelete("restrict"), + foreignKey({ columns: [table.workspaceId, table.aiRunId], foreignColumns: [aiRuns.workspaceId, aiRuns.id], name: "editorial_strategy_versions_workspace_ai_run_fk" }).onDelete("set null"), + unique("editorial_strategy_versions_workspace_id_uq").on(table.workspaceId, table.id), + uniqueIndex("editorial_strategy_versions_strategy_version_uq").on(table.workspaceId, table.strategyId, table.version), + ], +); + +export const contentOperationRequests = pgTable( + "content_operation_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }), + operation: varchar("operation", { length: 120 }).notNull(), + requestKey: varchar("request_key", { length: 300 }).notNull(), + resourceType: varchar("resource_type", { length: 120 }).notNull(), + resourceId: uuid("resource_id").notNull(), + response: jsonb("response").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("content_operation_requests_workspace_key_uq").on(table.workspaceId, table.operation, table.requestKey), + ], +); + export const knowledgeSources = pgTable( "knowledge_sources", { diff --git a/packages/infrastructure/src/workspaces/postgres-operational-views.ts b/packages/infrastructure/src/workspaces/postgres-operational-views.ts index 53de415..60827a7 100644 --- a/packages/infrastructure/src/workspaces/postgres-operational-views.ts +++ b/packages/infrastructure/src/workspaces/postgres-operational-views.ts @@ -21,6 +21,8 @@ import { contacts, conversations, dailyProspectingSchedules, + editorialStrategies, + editorialStrategyVersions, jobs, knowledgeSources, icpVersions, @@ -109,6 +111,16 @@ export class PostgresOperationalViews { const hasMoreAttention = attentionPage.length > attentionLimit; const attention = attentionPage.slice(0, attentionLimit); const statuses = accountRows.map((row) => row.status); + const editorialRows = await this.database.select({ + id: editorialStrategies.id, + status: editorialStrategies.status, + currentVersion: editorialStrategies.currentVersion, + updatedAt: editorialStrategies.updatedAt, + }).from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, workspaceId), + sql`${editorialStrategies.deletedAt} is null`, + )).orderBy(desc(editorialStrategies.updatedAt)).limit(1); + const editorial = editorialRows[0]; const connected = statuses.filter((status) => status === "connected").length; const degraded = statuses.filter((status) => status === "degraded" || status === "unknown" || status === "pending").length; const disconnected = statuses.filter((status) => status === "disconnected").length; @@ -167,11 +179,11 @@ export class PostgresOperationalViews { accountHealth: { connected, degraded, disconnected, activeAlerts: valueOf(alertCount) }, engines: { inbound: { - status: "not_configured", - label: "Inbound à configurer", - summary: "La stratégie éditoriale LinkedIn sera disponible au Lot 2.", - lastActivityAt: null, - nextAction: { label: "Voir la configuration", href: "/settings" }, + status: editorial ? (editorial.status === "active" ? "idle" : "paused") : "not_configured", + label: editorial ? (editorial.status === "active" ? "Inbound prêt" : "Stratégie Inbound en brouillon") : "Inbound à configurer", + summary: editorial ? `Stratégie LinkedIn v${editorial.currentVersion || "brouillon"} · le pipeline éditorial attend sa prochaine étape.` : "Une offre publiée et un ICP actif sont requis pour dériver la stratégie.", + lastActivityAt: editorial?.updatedAt ?? null, + nextAction: editorial ? { label: "Voir la stratégie", href: "/content/strategy" } : { label: "Vérifier la configuration", href: "/settings" }, }, outbound: { status: outboundStatus, @@ -191,18 +203,45 @@ export class PostgresOperationalViews { const offset = input.offset ?? 0; const limit = input.limit ?? 25; if (input.lens === "inbound") { + const [strategies, versions] = await Promise.all([ + this.database.select({ + id: editorialStrategies.id, + name: editorialStrategies.name, + status: editorialStrategies.status, + currentVersion: editorialStrategies.currentVersion, + model: editorialStrategies.model, + updatedAt: editorialStrategies.updatedAt, + }).from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + sql`${editorialStrategies.deletedAt} is null`, + )).orderBy(desc(editorialStrategies.updatedAt)).limit(limit + 1).offset(offset), + this.database.select({ value: count() }).from(editorialStrategyVersions).where(eq(editorialStrategyVersions.workspaceId, input.workspaceId)), + ]); + const hasNext = strategies.length > limit; + const items = strategies.slice(0, limit).map((strategy) => ({ + id: `strategy:${strategy.id}`, + kind: "publication" as const, + source: "inbound" as const, + status: strategy.status === "active" ? "completed" as const : "pending" as const, + title: strategy.name, + detail: `${strategy.currentVersion > 0 ? `Version ${strategy.currentVersion} active` : "Brouillon dérivé"} · réflexion ${strategy.model}`, + occurredAt: strategy.updatedAt, + href: "/content/strategy", + correlationId: null, + })); return { lens: input.lens, asOf, - state: "not_configured", - headline: "La création de demande LinkedIn arrive dans le prochain lot.", + state: strategies.length ? (strategies[0]!.status === "active" ? "idle" : "attention") : "not_configured", + headline: strategies.length ? "La stratégie éditoriale LinkedIn est durable et ancrée dans l’offre et l’ICP publiés." : "Publiez une offre et un ICP pour dériver la stratégie Inbound.", counters: [ + { key: "strategies", label: "Stratégies", value: strategies.length }, + { key: "versions", label: "Versions publiées", value: valueOf(versions) }, { key: "ideas", label: "Idées sourcées", value: 0 }, { key: "planned", label: "Contenus planifiés", value: 0 }, - { key: "published", label: "Contenus publiés", value: 0 }, ], - items: [], - pagination: { nextCursor: null }, + items, + pagination: { nextCursor: hasNext ? String(offset + limit) : null }, }; } if (input.lens === "symbiosis") { diff --git a/packages/interface/src/http/content-strategy-handler.ts b/packages/interface/src/http/content-strategy-handler.ts new file mode 100644 index 0000000..bd8e8f8 --- /dev/null +++ b/packages/interface/src/http/content-strategy-handler.ts @@ -0,0 +1,78 @@ +import { ZodError, z } from "zod"; +import type { EditorialStrategyApplication } from "@outbound/application/content/editorial-strategy"; +import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; +import type { RequestContextResolver } from "@outbound/interface/http/request-context"; +import { + RequestAuthenticationError, + WorkspaceAccessDeniedError, + WorkspaceContextRequiredError, +} from "@outbound/interface/http/request-context"; + +const requestSchema = z.object({ requestKey: z.string().trim().min(8).max(300) }).strict(); +const updateSchema = requestSchema.extend({ snapshot: editorialStrategySnapshotSchema }).strict(); + +export function isContentStrategyRoute(pathname: string): boolean { + return pathname === "/api/v1/content/strategy" + || pathname === "/api/v1/content/strategy/derive" + || pathname === "/api/v1/content/strategy/publish"; +} + +export function createContentStrategyHttpHandler(input: { + readonly application: EditorialStrategyApplication; + readonly contextResolver: RequestContextResolver; +}) { + return async function handle(request: Request): Promise { + try { + const context = await input.contextResolver.resolve(request); + const pathname = new URL(request.url).pathname; + if (pathname === "/api/v1/content/strategy" && request.method === "GET") { + requireViewer(context.role); + const strategy = await input.application.find(context.workspaceId); + return strategy ? json(normalize(strategy)) : problem(404, "EDITORIAL_STRATEGY_NOT_FOUND", "No editorial strategy exists for this workspace"); + } + if (pathname === "/api/v1/content/strategy/derive" && request.method === "POST") { + requireOperator(context.role); + const body = requestSchema.parse(await request.json()); + return json(normalize(await input.application.derive({ workspaceId: context.workspaceId, userId: context.userId, requestKey: body.requestKey })), 201); + } + if (pathname === "/api/v1/content/strategy" && request.method === "PUT") { + requireOperator(context.role); + const body = updateSchema.parse(await request.json()); + return json(normalize(await input.application.updateDraft({ workspaceId: context.workspaceId, userId: context.userId, requestKey: body.requestKey, snapshot: body.snapshot }))); + } + if (pathname === "/api/v1/content/strategy/publish" && request.method === "POST") { + requireOperator(context.role); + const body = requestSchema.parse(await request.json()); + return json(normalize(await input.application.publish({ workspaceId: context.workspaceId, userId: context.userId, requestKey: body.requestKey })), 201); + } + return problem(405, "METHOD_NOT_ALLOWED", "The HTTP method is not allowed"); + } catch (error) { + if (error instanceof ZodError || error instanceof SyntaxError) return problem(422, "VALIDATION_FAILED", "The request is invalid"); + if (error instanceof RequestAuthenticationError) return problem(401, "AUTHENTICATION_REQUIRED", error.message); + if (error instanceof WorkspaceContextRequiredError) return problem(400, "WORKSPACE_CONTEXT_REQUIRED", error.message); + if (error instanceof WorkspaceAccessDeniedError || error instanceof PermissionError) return problem(403, "WORKSPACE_FORBIDDEN", error.message); + const code = error instanceof Error ? error.message : ""; + if (code === "EDITORIAL_STRATEGY_OFFER_REQUIRED") return problem(409, code, "Publish an offer before deriving the strategy"); + if (code === "EDITORIAL_STRATEGY_ICP_REQUIRED") return problem(409, code, "Publish an ICP before deriving the strategy"); + if (code === "EDITORIAL_STRATEGY_NOT_FOUND") return problem(404, code, "No editorial strategy exists for this workspace"); + if (code === "EDITORIAL_STRATEGY_UNAUTHORIZED_CLAIM") return problem(422, code, "The strategy references an unauthorized offer claim"); + return problem(500, "INTERNAL_ERROR", "An unexpected error occurred"); + } + }; +} + +function normalize(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +class PermissionError extends Error {} +function requireViewer(role: string) { + if (!['viewer', 'operator', 'reviewer', 'admin', 'owner'].includes(role)) throw new PermissionError("Workspace access is required"); +} +function requireOperator(role: string) { + if (!['operator', 'admin', 'owner'].includes(role)) throw new PermissionError("Operator access is required"); +} +function json(body: unknown, status = 200) { return Response.json(body, { status }); } +function problem(status: number, code: string, detail: string) { + return Response.json({ type: `https://api.noosphere.local/problems/${code.toLowerCase()}`, title: code, status, detail, code }, { status, headers: { "content-type": "application/problem+json; charset=utf-8" } }); +} diff --git a/tests/e2e/noosphere-axis.spec.ts b/tests/e2e/noosphere-axis.spec.ts index 3eca014..5f8852a 100644 --- a/tests/e2e/noosphere-axis.spec.ts +++ b/tests/e2e/noosphere-axis.spec.ts @@ -51,6 +51,14 @@ test("browser back restores the previous lens without losing URL state", async ( await expect(page.getByRole("tab", { name: "Inbound" })).toHaveAttribute("aria-selected", "true"); }); +test("Inbound exposes its grounded editorial strategy without a provider mutation", async ({ page }) => { + await page.getByRole("tab", { name: "Inbound" }).click(); + await page.getByRole("link", { name: "Préparer la stratégie" }).click(); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/content/strategy`)); + await expect(page.getByRole("heading", { name: "Stratégie LinkedIn" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Aucune stratégie dérivée" })).toBeVisible(); +}); + test("Outbound surfaces preserve prospect and conversation filters in the URL", async ({ page }) => { const navigation = page.viewportSize()?.width === 390 ? page.getByRole("navigation", { name: "Navigation mobile" }) diff --git a/tests/http/content-strategy-http.test.ts b/tests/http/content-strategy-http.test.ts new file mode 100644 index 0000000..b1127f3 --- /dev/null +++ b/tests/http/content-strategy-http.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { createContentStrategyHttpHandler } from "@outbound/interface/http/content-strategy-handler"; + +const workspaceId = "20000000-0000-4000-8000-000000000001"; +const userId = "20000000-0000-4000-8000-000000000002"; + +describe("Noosphere content strategy HTTP", () => { + test("derives workspace and user exclusively from the authenticated session", async () => { + const calls: unknown[] = []; + const handler = createContentStrategyHttpHandler({ + contextResolver: context("operator"), + application: { + async find() { return null; }, + async derive(input: unknown) { calls.push(input); return strategy(); }, + async updateDraft() { return strategy(); }, + async publish() { return { id: crypto.randomUUID(), version: 1, publishedAt: new Date() }; }, + } as never, + }); + const response = await handler(request("/api/v1/content/strategy/derive", "POST", { requestKey: "derive:request:1", workspaceId: crypto.randomUUID() })); + expect(response.status).toBe(422); + const accepted = await handler(request("/api/v1/content/strategy/derive", "POST", { requestKey: "derive:request:2" })); + expect(accepted.status).toBe(201); + expect(calls).toEqual([{ workspaceId, userId, requestKey: "derive:request:2" }]); + }); + + test("allows viewers to read but never mutate", async () => { + const handler = createContentStrategyHttpHandler({ contextResolver: context("viewer"), application: { async find() { return strategy(); } } as never }); + expect((await handler(request("/api/v1/content/strategy"))).status).toBe(200); + expect((await handler(request("/api/v1/content/strategy/derive", "POST", { requestKey: "derive:request:3" }))).status).toBe(403); + }); + + test("maps missing published grounding to a recoverable conflict", async () => { + const handler = createContentStrategyHttpHandler({ + contextResolver: context("owner"), + application: { async derive() { throw new Error("EDITORIAL_STRATEGY_OFFER_REQUIRED"); } } as never, + }); + const response = await handler(request("/api/v1/content/strategy/derive", "POST", { requestKey: "derive:request:4" })); + expect(response.status).toBe(409); + expect((await response.json()).code).toBe("EDITORIAL_STRATEGY_OFFER_REQUIRED"); + }); +}); + +function context(role: "viewer" | "operator" | "owner") { return { async resolve() { return { workspaceId, userId, role }; } }; } +function request(path: string, method = "GET", body?: unknown) { return new Request(`http://localhost${path}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); } +function strategy() { return { id: crypto.randomUUID(), workspaceId, name: "Strategy", offerId: crypto.randomUUID(), offerVersionId: crypto.randomUUID(), icpId: crypto.randomUUID(), icpVersionId: crypto.randomUUID(), currentVersion: 0, draft: { audience: { name: "Audience", summary: "Summary", awareness: "mixed" }, pillars: [{ name: "A", promise: "A", proofTypes: ["A"] }, { name: "B", promise: "B", proofTypes: ["B"] }, { name: "C", promise: "C", proofTypes: ["C"] }], voice: { traits: ["direct", "clair"], avoid: ["générique"] }, formats: ["linkedin_text"], cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, callsToAction: ["Répondre"], allowedClaimIds: [], forbiddenTopics: [] }, derivation: { provider: "kimi-code", model: "k3", promptVersion: "v1", aiRunId: null }, createdAt: new Date(), updatedAt: new Date() }; } diff --git a/tests/integration/editorial-strategy.test.ts b/tests/integration/editorial-strategy.test.ts new file mode 100644 index 0000000..4b5111a --- /dev/null +++ b/tests/integration/editorial-strategy.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { EditorialStrategyApplication } from "@outbound/application/content/editorial-strategy"; +import type { EditorialStrategySnapshot } from "@outbound/domain/content/editorial-strategy"; +import { PostgresEditorialStrategyRepository } from "@outbound/infrastructure/content/postgres-editorial-strategy-repository"; +import { createDatabase } from "@outbound/infrastructure/database/client"; +import { + authUsers, + icps, + icpVersions, + offerClaims, + offers, + offerVersions, + workspaces, +} from "@outbound/infrastructure/database/schema"; + +const databaseUrl = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL; +const databaseDescribe = databaseUrl ? describe : describe.skip; + +databaseDescribe("Noosphere editorial strategy persistence", () => { + if (!databaseUrl) return; + const database = createDatabase(databaseUrl); + const workspaceId = crypto.randomUUID(); + const otherWorkspaceId = crypto.randomUUID(); + const userId = crypto.randomUUID(); + let generated = 0; + const repository = new PostgresEditorialStrategyRepository(database.db); + const application = new EditorialStrategyApplication(repository, { + async generate({ grounding }) { + generated += 1; + return { + snapshot: snapshot(grounding.offer.claims[0]!.id), + metadata: { provider: "kimi-code", model: "k3", promptVersion: "integration-v1", aiRunId: null }, + }; + }, + }); + + beforeAll(async () => { + await migrate(database.db, { migrationsFolder: resolve(import.meta.dir, "../../packages/infrastructure/migrations") }); + await database.db.insert(workspaces).values([ + { id: workspaceId, slug: `strategy-a-${workspaceId}`, name: "Strategy A" }, + { id: otherWorkspaceId, slug: `strategy-b-${otherWorkspaceId}`, name: "Strategy B" }, + ]); + await database.db.insert(authUsers).values({ id: userId, name: "Strategy Owner", email: `strategy-${userId}@example.com` }); + await seedGrounding(workspaceId, "Noosphere A"); + await seedGrounding(otherWorkspaceId, "Noosphere B"); + }); + + afterAll(async () => { + await database.client`drop trigger if exists audit_logs_immutable_trg on audit_logs`; + await database.client`drop trigger if exists editorial_strategy_versions_immutable_trg on editorial_strategy_versions`; + await database.client`alter table offer_claims disable trigger offer_claims_immutable_trg`; + await database.client`alter table offer_versions disable trigger offer_versions_immutable_trg`; + await database.client`alter table icp_versions disable trigger icp_versions_immutable_trg`; + try { + await database.client`delete from content_operation_requests where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from audit_logs where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from outbox_events where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from editorial_strategy_versions where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from editorial_strategies where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from offer_claims where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from offer_versions where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from offers where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from icp_versions where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from icps where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from auth_users where id = ${userId}`; + await database.client`delete from workspaces where id in (${workspaceId}, ${otherWorkspaceId})`; + } finally { + await database.client`alter table offer_claims enable trigger offer_claims_immutable_trg`; + await database.client`alter table offer_versions enable trigger offer_versions_immutable_trg`; + await database.client`alter table icp_versions enable trigger icp_versions_immutable_trg`; + await database.client`create trigger editorial_strategy_versions_immutable_trg before update or delete on editorial_strategy_versions for each row execute function reject_editorial_strategy_version_mutation()`; + await database.client`create trigger audit_logs_immutable_trg before update or delete on audit_logs for each row execute function reject_audit_log_mutation()`; + } + await database.close(); + }); + + test("derivation and publication replay safely by request key", async () => { + const derived = await application.derive({ workspaceId, userId, requestKey: "derive:stable" }); + const replay = await application.derive({ workspaceId, userId, requestKey: "derive:stable" }); + expect(replay.id).toBe(derived.id); + expect(generated).toBe(1); + + const published = await application.publish({ workspaceId, userId, requestKey: "publish:stable" }); + const publishedReplay = await application.publish({ workspaceId, userId, requestKey: "publish:stable" }); + expect(publishedReplay.id).toBe(published.id); + expect(publishedReplay.version).toBe(1); + + const concurrent = await Promise.all(Array.from({ length: 5 }, () => + application.publish({ workspaceId, userId, requestKey: "publish:concurrent" }) + )); + expect(new Set(concurrent.map((version) => version.id)).size).toBe(1); + expect(concurrent[0]?.id).toBe(published.id); + }); + + test("isolates workspaces and keeps published versions immutable", async () => { + const first = await repository.find(workspaceId); + expect(first?.workspaceId).toBe(workspaceId); + expect(await repository.find(otherWorkspaceId)).toBeNull(); + + const other = await application.derive({ workspaceId: otherWorkspaceId, userId, requestKey: "derive:other" }); + expect(other.workspaceId).toBe(otherWorkspaceId); + expect(other.id).not.toBe(first?.id); + + const published = await application.publish({ workspaceId, userId, requestKey: "publish:immutable" }); + await expectRejected( + () => database.client`update editorial_strategy_versions set version = 99 where id = ${published.id}`, + "EDITORIAL_STRATEGY_VERSION_IMMUTABLE", + ); + }); + + async function seedGrounding(targetWorkspaceId: string, name: string) { + const offerId = crypto.randomUUID(); + const offerVersionId = crypto.randomUUID(); + const claimId = crypto.randomUUID(); + const icpId = crypto.randomUUID(); + const icpVersionId = crypto.randomUUID(); + await database.db.insert(offers).values({ + id: offerId, + workspaceId: targetWorkspaceId, + name, + category: "saas", + valueProposition: "Relier création et capture de demande", + targetAudience: "Fondateurs B2B", + createdBy: userId, + currentVersion: 1, + }); + await database.db.insert(offerVersions).values({ + id: offerVersionId, + workspaceId: targetWorkspaceId, + offerId, + version: 1, + name, + category: "saas", + valueProposition: "Relier création et capture de demande", + targetAudience: "Fondateurs B2B", + publishedBy: userId, + publishedAt: new Date(), + }); + await database.db.insert(offerClaims).values({ + id: claimId, + workspaceId: targetWorkspaceId, + offerVersionId, + claim: "Unifie les opérations Inbound et Outbound", + validationStatus: "validated", + evidenceUri: "https://example.test/noosphere-proof", + }); + await database.db.insert(icps).values({ id: icpId, workspaceId: targetWorkspaceId, name: "SaaS B2B", currentVersion: 1 }); + await database.db.insert(icpVersions).values({ + id: icpVersionId, + workspaceId: targetWorkspaceId, + icpId, + version: 1, + name: "SaaS B2B", + confidence: "0.9000", + criteria: { industries: ["software"] }, + buyingCommittee: [{ title: "Founder" }], + problems: ["Acquisition fragmentée"], + signals: ["Équipe commerciale en croissance"], + exclusions: [], + unknowns: [], + unresolvedContradictions: [], + blockedFindings: [], + publishedBy: userId, + publishedAt: new Date(), + }); + } +}); + +function snapshot(claimId: string): EditorialStrategySnapshot { + return { + audience: { name: "Fondateurs SaaS B2B", summary: "Équipes qui veulent relier contenu, prospection et appels.", awareness: "solution_aware" }, + pillars: [ + { name: "Système", promise: "Montrer le pipeline complet.", proofTypes: ["capture produit"] }, + { name: "Preuves", promise: "Expliquer les décisions avec leurs sources.", proofTypes: ["journal d’audit"] }, + { name: "Terrain", promise: "Partager les apprentissages des conversations.", proofTypes: ["conversation anonymisée"] }, + ], + voice: { traits: ["direct", "technique"], avoid: ["hooks interchangeables"] }, + formats: ["linkedin_text"], + cadence: { postsPerWeek: 3, preferredDays: [2, 3, 5], timezone: "Europe/Paris" }, + callsToAction: ["Demander un retour terrain"], + allowedClaimIds: [claimId], + forbiddenTopics: ["chiffres non sourcés"], + }; +} + +async function expectRejected(operation: () => Promise, message: string) { + let error: unknown; + try { await operation(); } catch (caught) { error = caught; } + expect(error).toBeDefined(); + expect(String(error)).toContain(message); +} diff --git a/tests/unit/editorial-strategy.test.ts b/tests/unit/editorial-strategy.test.ts new file mode 100644 index 0000000..64956e2 --- /dev/null +++ b/tests/unit/editorial-strategy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { EditorialStrategyApplication, type EditorialStrategyGrounding, type EditorialStrategyRepository } from "@outbound/application/content/editorial-strategy"; +import type { EditorialStrategySnapshot } from "@outbound/domain/content/editorial-strategy"; + +const workspaceId = "10000000-0000-4000-8000-000000000001"; +const userId = "10000000-0000-4000-8000-000000000002"; +const claimId = "10000000-0000-4000-8000-000000000003"; + +describe("Noosphere editorial strategy", () => { + test("derives a complete strategy from published offer and ICP snapshots", async () => { + const calls: string[] = []; + const repository = fakeRepository(calls); + const application = new EditorialStrategyApplication(repository, { + async generate(input) { + calls.push(`generate:${input.workspaceId}:${input.grounding.offer.versionId}:${input.grounding.icp.versionId}`); + return { snapshot: sampleSnapshot(), metadata: { provider: "kimi-code", model: "k3", promptVersion: "v1", aiRunId: null } }; + }, + }); + const strategy = await application.derive({ workspaceId, userId, requestKey: "derive:one" }); + expect(strategy.draft.pillars).toHaveLength(3); + expect(calls).toEqual([ + `grounding:${workspaceId}`, + `generate:${workspaceId}:10000000-0000-4000-8000-000000000011:10000000-0000-4000-8000-000000000021`, + "save:derive:one", + ]); + }); + + test("rejects a model claim that is not sourced or validated", async () => { + const application = new EditorialStrategyApplication(fakeRepository([]), { + async generate() { return { snapshot: { ...sampleSnapshot(), allowedClaimIds: [crypto.randomUUID()] }, metadata: { provider: "kimi-code", model: "k3", promptVersion: "v1", aiRunId: null } }; }, + }); + expect(application.derive({ workspaceId, userId, requestKey: "derive:bad" })).rejects.toThrow("EDITORIAL_STRATEGY_UNAUTHORIZED_CLAIM"); + }); +}); + +function fakeRepository(calls: string[]): EditorialStrategyRepository { + return { + async grounding(id) { calls.push(`grounding:${id}`); return grounding(); }, + async find() { return null; }, + async findRequest() { return null; }, + async saveDerived(input) { + calls.push(`save:${input.requestKey}`); + return { id: crypto.randomUUID(), workspaceId, name: "Strategy", offerId: input.grounding.offer.id, offerVersionId: input.grounding.offer.versionId, icpId: input.grounding.icp.id, icpVersionId: input.grounding.icp.versionId, currentVersion: 0, draft: input.snapshot, derivation: input.derivation, createdAt: new Date(), updatedAt: new Date() }; + }, + async updateDraft() { throw new Error("unused"); }, + async publish() { throw new Error("unused"); }, + }; +} + +function grounding(): EditorialStrategyGrounding { + return { + offer: { id: "10000000-0000-4000-8000-000000000010", versionId: "10000000-0000-4000-8000-000000000011", name: "Noosphere", category: "saas", valueProposition: "Créer et capter la demande", targetAudience: "Fondateurs B2B", pricing: {}, commercialRules: {}, constraints: {}, objections: [], claims: [{ id: claimId, claim: "Automatisation multicanale", validationStatus: "validated", evidenceUri: "https://example.test/proof" }] }, + icp: { id: "10000000-0000-4000-8000-000000000020", versionId: "10000000-0000-4000-8000-000000000021", name: "SaaS B2B", criteria: {}, buyingCommittee: {}, problems: [], signals: [], exclusions: [] }, + }; +} + +function sampleSnapshot(): EditorialStrategySnapshot { + return { + audience: { name: "Fondateurs SaaS B2B", summary: "Équipes qui veulent relier contenu, prospection et appels.", awareness: "solution_aware" }, + pillars: [ + { name: "Système", promise: "Montrer le pipeline complet.", proofTypes: ["capture produit"] }, + { name: "Preuves", promise: "Expliquer les décisions avec leurs sources.", proofTypes: ["journal d’audit"] }, + { name: "Terrain", promise: "Partager les apprentissages des conversations.", proofTypes: ["conversation anonymisée"] }, + ], + voice: { traits: ["direct", "technique"], avoid: ["hooks interchangeables"] }, + formats: ["linkedin_text"], + cadence: { postsPerWeek: 3, preferredDays: [2, 3, 5], timezone: "Europe/Paris" }, + callsToAction: ["Demander un retour terrain"], + allowedClaimIds: [claimId], + forbiddenTopics: ["chiffres non sourcés"], + }; +} diff --git a/tests/unit/product-research-openapi.test.ts b/tests/unit/product-research-openapi.test.ts index 00ce073..6994726 100644 --- a/tests/unit/product-research-openapi.test.ts +++ b/tests/unit/product-research-openapi.test.ts @@ -123,6 +123,9 @@ test("the OpenAPI contract declares every F-009 HTTP route", () => { "/api/v1/merge-candidates/{candidateId}/actions/reject", "/api/v1/contacts/{contactId}/actions/undo-merge", "/api/v1/contacts/{contactId}/merges", + "/api/v1/content/strategy", + "/api/v1/content/strategy/derive", + "/api/v1/content/strategy/publish", "/api/v1/conversations", "/api/v1/opportunities", "/api/v1/opportunities/{opportunityId}", From 214ecb75a4defeeb653e57c741bbf66ad81d7a60 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 22:19:06 +0200 Subject: [PATCH 130/169] test(noosphere): stabilize workspace login journey --- tests/e2e/noosphere-axis.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/noosphere-axis.spec.ts b/tests/e2e/noosphere-axis.spec.ts index 5f8852a..c24700a 100644 --- a/tests/e2e/noosphere-axis.spec.ts +++ b/tests/e2e/noosphere-axis.spec.ts @@ -9,8 +9,8 @@ test.beforeEach(async ({ page }) => { await page.getByLabel("Email professionnel").fill(email); await page.getByLabel("Mot de passe").fill(password); await page.getByRole("button", { name: "Accéder au workspace" }).click(); - await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/?$`)); - await expect(page.getByRole("heading", { name: "Aujourd’hui" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Aujourd’hui" })).toBeVisible({ timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceSlug}/?$`), { timeout: 20_000 }); }); test("the five destinations and Noosphere Axis remain GET-only", async ({ page }) => { From 37886f1192cf44b03b6d3f4859d6ef2b73b32a04 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 22:45:59 +0200 Subject: [PATCH 131/169] feat(content): add durable sourced idea radar --- apps/api/src/index.ts | 8 + .../[workspaceSlug]/content/ideas/actions.ts | 7 + .../content/ideas/ideas-controls.tsx | 29 ++ .../w/[workspaceSlug]/content/ideas/page.tsx | 48 +++ .../[workspaceSlug]/content/strategy/page.tsx | 5 +- apps/web/lib/api.ts | 58 ++++ apps/worker/src/index.ts | 24 +- apps/worker/src/research-worker.ts | 4 + .../application/src/content/content-ideas.ts | 160 +++++++++ .../openapi/product-research-v1.json | 13 + packages/contracts/src/content.ts | 20 ++ packages/domain/src/content/content-idea.ts | 44 +++ .../0069_noosphere_content_ideas.sql | 83 +++++ .../migrations/meta/_journal.json | 7 + .../content/crawler-content-idea-source.ts | 28 ++ .../content/daily-content-idea-scheduler.ts | 69 ++++ .../langchain-content-idea-generator.ts | 79 +++++ .../postgres-content-idea-repository.ts | 323 ++++++++++++++++++ .../infrastructure/src/database/schema.ts | 100 ++++++ .../workspaces/postgres-operational-views.ts | 47 ++- .../src/http/content-idea-handler.ts | 65 ++++ tests/e2e/noosphere-axis.spec.ts | 7 + tests/http/content-idea-http.test.ts | 32 ++ .../content-idea-discovery.test.ts | 121 +++++++ tests/unit/content-idea.test.ts | 45 +++ tests/unit/product-research-openapi.test.ts | 3 + 26 files changed, 1418 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/ideas-controls.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/page.tsx create mode 100644 packages/application/src/content/content-ideas.ts create mode 100644 packages/domain/src/content/content-idea.ts create mode 100644 packages/infrastructure/migrations/0069_noosphere_content_ideas.sql create mode 100644 packages/infrastructure/src/content/crawler-content-idea-source.ts create mode 100644 packages/infrastructure/src/content/daily-content-idea-scheduler.ts create mode 100644 packages/infrastructure/src/content/langchain-content-idea-generator.ts create mode 100644 packages/infrastructure/src/content/postgres-content-idea-repository.ts create mode 100644 packages/interface/src/http/content-idea-handler.ts create mode 100644 tests/http/content-idea-http.test.ts create mode 100644 tests/integration/content-idea-discovery.test.ts create mode 100644 tests/unit/content-idea.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a50355b..d4fd942 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -62,6 +62,9 @@ import { PostgresEditorialStrategyRepository } from "@outbound/infrastructure/co import { LangChainEditorialStrategyGenerator } from "@outbound/infrastructure/content/langchain-editorial-strategy-generator"; import { PostgresAiRunRecorder } from "@outbound/infrastructure/ai/postgres-ai-run-recorder"; import { createContentStrategyHttpHandler, isContentStrategyRoute } from "@outbound/interface/http/content-strategy-handler"; +import { ContentIdeaApplication } from "@outbound/application/content/content-ideas"; +import { PostgresContentIdeaRepository } from "@outbound/infrastructure/content/postgres-content-idea-repository"; +import { createContentIdeaHttpHandler, isContentIdeaRoute } from "@outbound/interface/http/content-idea-handler"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -272,6 +275,10 @@ const contentStrategy = createContentStrategyHttpHandler({ ), ), }); +const contentIdeas = createContentIdeaHttpHandler({ + contextResolver: auth.contextResolver, + application: new ContentIdeaApplication(new PostgresContentIdeaRepository(database.db)), +}); const port = positiveIntegerEnvironment("PORT", 3000); const server = Bun.serve({ port, @@ -301,6 +308,7 @@ const server = Bun.serve({ if (isWorkspaceOnboardingRoute(pathname)) return workspaceOnboarding(request); if (isWorkspaceDataRoute(pathname, request.method)) return workspaceData(request); if (isContentStrategyRoute(pathname)) return contentStrategy(request); + if (isContentIdeaRoute(pathname)) return contentIdeas(request); if ( pathname === "/api/v1/workspace/operational-summary" || pathname === "/api/v1/activity" diff --git a/apps/web/app/w/[workspaceSlug]/content/ideas/actions.ts b/apps/web/app/w/[workspaceSlug]/content/ideas/actions.ts new file mode 100644 index 0000000..d305506 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/ideas/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { discoverContentIdeas } from "@/lib/api"; + +export async function discoverIdeasAction(workspaceSlug: string) { + return discoverContentIdeas(workspaceSlug, `ideas:discover:${crypto.randomUUID()}`); +} diff --git a/apps/web/app/w/[workspaceSlug]/content/ideas/ideas-controls.tsx b/apps/web/app/w/[workspaceSlug]/content/ideas/ideas-controls.tsx new file mode 100644 index 0000000..c1e3704 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/ideas/ideas-controls.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { discoverIdeasAction } from "./actions"; + +export function IdeasControls({ workspaceSlug, runId, running }: { workspaceSlug: string; runId?: string; running: boolean }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + if (!running) return; + const timer = setInterval(() => router.refresh(), 3_000); + return () => clearInterval(timer); + }, [router, running, runId]); + async function launch() { + setPending(true); + setError(null); + try { + const run = await discoverIdeasAction(workspaceSlug); + router.replace(`/w/${workspaceSlug}/content/ideas?run=${run.id}`); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "La recherche n’a pas pu démarrer"); + } finally { setPending(false); } + } + return
{error ? {error} : null}
; +} diff --git a/apps/web/app/w/[workspaceSlug]/content/ideas/page.tsx b/apps/web/app/w/[workspaceSlug]/content/ideas/page.tsx new file mode 100644 index 0000000..b7441f0 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/ideas/page.tsx @@ -0,0 +1,48 @@ +import { AlertTriangle, ArrowLeft, CheckCircle2, Clock3, ExternalLink, Lightbulb, Radar, ShieldCheck } from "lucide-react"; +import Link from "next/link"; +import { getContentIdeaDiscoveryRun, listContentIdeas, type ContentIdeaStatus } from "@/lib/api"; +import { IdeasControls } from "./ideas-controls"; + +export const metadata = { title: "Idées sourcées — Noosphere" }; +export const dynamic = "force-dynamic"; + +type Query = { run?: string; cursor?: string; status?: string }; + +export default async function ContentIdeasPage({ params, searchParams }: { params: Promise<{ workspaceSlug: string }>; searchParams: Promise }) { + const { workspaceSlug } = await params; + const query = await searchParams; + const status = validStatus(query.status); + const [ideas, run] = await Promise.all([ + listContentIdeas(workspaceSlug, { ...(query.cursor ? { cursor: query.cursor } : {}), ...(status ? { status } : {}), limit: 25 }), + query.run ? getContentIdeaDiscoveryRun(workspaceSlug, query.run) : Promise.resolve(null), + ]); + const running = run?.status === "queued" || run?.status === "running"; + return <> +
+
Activité Inbound
Radar quotidien

Idées sourcées

Chaque angle conserve ses preuves, sa fraîcheur et son ICP. Ce radar ne rédige et ne publie rien.

+ +
+ + {run ? : null} + + + + {ideas.data.length ?
{ideas.data.map((idea) =>
{idea.pillar}{idea.audience}

{idea.angle}

{idea.priority}

{idea.rationale}

Frais jusqu’au {formatDate(idea.freshnessUntil)} {idea.sources.length} preuve{idea.sources.length === 1 ? "" : "s"}
Voir les preuves résolubles
    {idea.sources.map((source) =>
  • {source.title}{source.excerpt}{source.canonicalUrl ? Source : {sourceType(source.type)}}
  • )}
)}
:

Aucune idée dans ce filtre

Activez d’abord la stratégie, puis lancez le radar. Les résultats apparaissent sans quitter la page.

} + + {ideas.nextCursor ?
Afficher la suite
: null} + ; +} + +function RunStatus({ run }: { run: NonNullable>> }) { + if (run.status === "failed") return
Recherche interrompue

{run.lastErrorMessage ?? "Le worker retentera selon sa politique durable."}

; + if (run.status === "completed" || run.status === "partial") return
Recherche {run.status === "partial" ? "partielle" : "terminée"}

{run.ideaCount} nouvelle{run.ideaCount === 1 ? "" : "s"} idée{run.ideaCount === 1 ? "" : "s"}, {run.sourceCount} source{run.sourceCount === 1 ? "" : "s"} collectée{run.sourceCount === 1 ? "" : "s"}.

; + return
Recherche en cours

{run.cursor}/{run.queryLimit} requêtes · le job reste durable si vous quittez cette page.

; +} +function Filter({ href, selected, children }: { href: string; selected: boolean; children: React.ReactNode }) { return {children}; } +function validStatus(value?: string): ContentIdeaStatus | undefined { return value === "discovered" || value === "shortlisted" || value === "briefed" || value === "discarded" || value === "expired" ? value : undefined; } +function statusLabel(value: ContentIdeaStatus): string { return ({ discovered: "Découvertes", shortlisted: "Prioritaires", briefed: "Briefées", discarded: "Écartées", expired: "Expirées" })[value]; } +function sourceType(value: string): string { return ({ offer_claim: "Claim offre", knowledge_claim: "Connaissance", conversation_message: "Conversation", public_web: "Web" } as Record)[value] ?? value; } +function formatDate(value: string): string { return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "numeric", timeZone: "Europe/Paris" }).format(new Date(value)); } diff --git a/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx b/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx index 3873827..9d3525b 100644 --- a/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/content/strategy/page.tsx @@ -18,7 +18,10 @@ export default async function EditorialStrategyPage({ params }: { params: Promis

Stratégie LinkedIn

Noosphere relie l’offre publiée, l’ICP actif et les claims autorisés avant de produire une seule idée.

- +
+ {strategy?.currentVersion ? Ouvrir le radar d’idées : null} + +
{!strategy ?

Aucune stratégie dérivée

Publiez d’abord une offre et un ICP. La dérivation K3 conservera exactement les versions utilisées.

Vérifier les prérequis
: ( diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 74b709b..06b1d0d 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -137,6 +137,47 @@ export interface EditorialStrategy { readonly updatedAt: string; } +export type ContentIdeaStatus = "discovered" | "shortlisted" | "briefed" | "discarded" | "expired"; +export interface ContentIdeaEvidence { + readonly key: string; + readonly type: "offer_claim" | "knowledge_claim" | "conversation_message" | "public_web"; + readonly sourceRef: string; + readonly canonicalUrl: string | null; + readonly title: string; + readonly excerpt: string; + readonly contentHash: string; + readonly collectedAt: string; +} +export interface ContentIdea { + readonly id: string; + readonly status: ContentIdeaStatus; + readonly angle: string; + readonly rationale: string; + readonly audience: string; + readonly pillar: string; + readonly priority: number; + readonly freshnessUntil: string; + readonly firstSeenAt: string; + readonly lastSeenAt: string; + readonly sources: readonly ContentIdeaEvidence[]; +} +export interface ContentIdeaDiscoveryRun { + readonly id: string; + readonly status: "queued" | "running" | "completed" | "partial" | "failed"; + readonly trigger: "manual" | "daily"; + readonly cursor: number; + readonly queryCount: number; + readonly sourceCount: number; + readonly ideaCount: number; + readonly queryLimit: number; + readonly sourceLimit: number; + readonly deadlineAt: string; + readonly lastErrorCode: string | null; + readonly lastErrorMessage: string | null; + readonly createdAt: string; + readonly completedAt: string | null; +} + export interface SetupReadinessItem { readonly key: "product" | "icp" | "accounts" | "automation" | "calendar" | "knowledge"; readonly label: string; @@ -220,6 +261,23 @@ export async function publishEditorialStrategy(workspaceSlug: string, requestKey return crmFetch(workspaceSlug, "/api/v1/content/strategy/publish", { method: "POST", body: { requestKey } }); } +export async function listContentIdeas(workspaceSlug: string, input: { cursor?: string; status?: ContentIdeaStatus; limit?: number } = {}): Promise<{ data: readonly ContentIdea[]; nextCursor: string | null }> { + const params = new URLSearchParams(); + if (input.cursor) params.set("cursor", input.cursor); + if (input.status) params.set("status", input.status); + if (input.limit) params.set("limit", String(input.limit)); + return crmFetch(workspaceSlug, `/api/v1/content/ideas${params.size ? `?${params}` : ""}`); +} + +export async function discoverContentIdeas(workspaceSlug: string, requestKey: string): Promise { + return crmFetch(workspaceSlug, "/api/v1/content/ideas/discover", { method: "POST", body: { requestKey } }); +} + +export async function getContentIdeaDiscoveryRun(workspaceSlug: string, runId: string): Promise { + try { return await crmFetch(workspaceSlug, `/api/v1/content/idea-discovery-runs/${runId}`); } + catch (error) { if (error instanceof OutboundApiError && error.status === 404) return null; throw error; } +} + export async function getActivity(workspaceSlug: string, lens: NoosphereLens, cursor?: string): Promise { const params = new URLSearchParams({ lens }); if (cursor) params.set("cursor", cursor); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 9a0e809..4f6dd3c 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -77,6 +77,11 @@ import { PostgresActiveAiConfigurationReader } from "@outbound/infrastructure/ai import { PostgresAiRunRecorder } from "@outbound/infrastructure/ai/postgres-ai-run-recorder"; import { ProspectDecisionJobProcessor } from "@outbound/infrastructure/campaigns/prospect-decision-runner"; import { LangChainProspectDecisionAgent } from "@outbound/infrastructure/campaigns/langchain-prospect-decision-agent"; +import { ContentIdeaDiscoveryJobProcessor } from "@outbound/application/content/content-ideas"; +import { PostgresContentIdeaRepository } from "@outbound/infrastructure/content/postgres-content-idea-repository"; +import { CrawlerContentIdeaSource } from "@outbound/infrastructure/content/crawler-content-idea-source"; +import { LangChainContentIdeaGenerator } from "@outbound/infrastructure/content/langchain-content-idea-generator"; +import { DailyContentIdeaScheduler } from "@outbound/infrastructure/content/daily-content-idea-scheduler"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -271,6 +276,18 @@ const dailyProspectingScheduler = new DailyProspectingScheduler(database.db, clo localTime: process.env.DAILY_PROSPECTING_TIME ?? "06:00", timezone: process.env.DAILY_PROSPECTING_TIMEZONE ?? "Europe/Paris", }); +const contentIdeaRepository = new PostgresContentIdeaRepository(database.db); +const contentIdeaDiscoveryProcessor = new ContentIdeaDiscoveryJobProcessor( + contentIdeaRepository, + new CrawlerContentIdeaSource(discoveryCrawler), + new LangChainContentIdeaGenerator(process.env, workspaceAiSettings, aiRunRecorder), + queue, + () => clock.now(), +); +const dailyContentIdeaScheduler = new DailyContentIdeaScheduler(database.db, contentIdeaRepository, clock, { + localTime: process.env.DAILY_CONTENT_IDEA_TIME ?? "06:00", + timezone: process.env.DAILY_CONTENT_IDEA_TIMEZONE ?? "Europe/Paris", +}); const prospectAssessmentReconciler = new ProspectAssessmentReconciler(database.db, clock); const campaignHealthReconciler = new CampaignHealthReconciler(database.db, clock); const sourcingRetentionReconciler = new SourcingRetentionReconciler(database.db, clock); @@ -286,8 +303,9 @@ const unipileInboxSynchronizer = process.env.UNIPILE_DSN : null; const maintenance = { async reconcile() { - const [dailyRuns, assessmentJobs, repairedCampaigns, retainedSourcing, inboundEvents] = await Promise.all([ + const [dailyRuns, dailyIdeaRuns, assessmentJobs, repairedCampaigns, retainedSourcing, inboundEvents] = await Promise.all([ dailyProspectingScheduler.reconcile(), + dailyContentIdeaScheduler.reconcile(), prospectAssessmentReconciler.reconcile(), campaignHealthReconciler.reconcile(), sourcingRetentionReconciler.reconcile(), @@ -296,7 +314,7 @@ const maintenance = { if (inboundEvents > 0) { console.info(JSON.stringify({ event: "unipile_inbox_mirror_updated", importedMessages: inboundEvents })); } - return dailyRuns + assessmentJobs + repairedCampaigns + retainedSourcing + inboundEvents; + return dailyRuns + dailyIdeaRuns + assessmentJobs + repairedCampaigns + retainedSourcing + inboundEvents; }, }; const orchestrator = new ResearchOrchestrator( @@ -321,7 +339,7 @@ const worker = new ResearchWorker(queue, orchestrator, clock, { pollIntervalMs: positiveIntegerEnvironment("JOB_POLL_INTERVAL_MS", 1_000), ...optionalJobTypes("WORKER_JOB_TYPES"), ...optionalExcludedJobTypes("WORKER_EXCLUDED_JOB_TYPES"), -}, documentService, discoveryProcessor, channelAssessmentProcessor, campaignAutomationProcessor, campaignCompositionProcessor, outreachDispatchProcessor, inboundReplyProcessor, automatedReplySendProcessor, conversationCommandProcessor, process.env.WORKER_DISABLE_MAINTENANCE === "true" ? undefined : maintenance, process.env.WORKER_DISABLE_OUTBOX === "true" ? undefined : outboxDispatcher, importService, process.env.WORKER_DISABLE_OUTREACH_SCHEDULER === "true" ? undefined : outreachScheduler, enrichmentProcessor, signalProcessor, workspaceExportProcessor, retentionPurgeProcessor, knowledgeExpirationProcessor, evaluationRunProcessor, prospectDecisionProcessor); +}, documentService, discoveryProcessor, channelAssessmentProcessor, campaignAutomationProcessor, campaignCompositionProcessor, outreachDispatchProcessor, inboundReplyProcessor, automatedReplySendProcessor, conversationCommandProcessor, process.env.WORKER_DISABLE_MAINTENANCE === "true" ? undefined : maintenance, process.env.WORKER_DISABLE_OUTBOX === "true" ? undefined : outboxDispatcher, importService, process.env.WORKER_DISABLE_OUTREACH_SCHEDULER === "true" ? undefined : outreachScheduler, enrichmentProcessor, signalProcessor, workspaceExportProcessor, retentionPurgeProcessor, knowledgeExpirationProcessor, evaluationRunProcessor, prospectDecisionProcessor, contentIdeaDiscoveryProcessor); for (const signal of ["SIGTERM", "SIGINT"] as const) { process.once(signal, () => { diff --git a/apps/worker/src/research-worker.ts b/apps/worker/src/research-worker.ts index 058c918..51c56ac 100644 --- a/apps/worker/src/research-worker.ts +++ b/apps/worker/src/research-worker.ts @@ -50,6 +50,7 @@ export class ResearchWorker { private readonly knowledgeExpirationProcessor?: { process(job: LeasedJob): Promise }, private readonly evaluationRunProcessor?: { process(job: LeasedJob): Promise }, private readonly prospectDecisionProcessor?: { process(job: LeasedJob): Promise }, + private readonly contentIdeaDiscoveryProcessor?: { process(job: LeasedJob): Promise }, ) {} stop(): void { @@ -90,6 +91,7 @@ export class ResearchWorker { ...(this.knowledgeExpirationProcessor ? ["knowledge.source.expire"] : []), ...(this.evaluationRunProcessor ? ["ai.evaluation.execute"] : []), ...(this.prospectDecisionProcessor ? ["prospect.decision.execute"] : []), + ...(this.contentIdeaDiscoveryProcessor ? ["content.ideas.discover"] : []), ]; const allowed = this.options.jobTypes ? new Set(this.options.jobTypes) : null; const excluded = new Set(this.options.excludedJobTypes ?? []); @@ -153,6 +155,8 @@ export class ResearchWorker { await this.evaluationRunProcessor.process(job); } else if (job.type === "prospect.decision.execute" && this.prospectDecisionProcessor) { await this.prospectDecisionProcessor.process(job); + } else if (job.type === "content.ideas.discover" && this.contentIdeaDiscoveryProcessor) { + await this.contentIdeaDiscoveryProcessor.process(job); } else { await this.orchestrator.process(job); } diff --git a/packages/application/src/content/content-ideas.ts b/packages/application/src/content/content-ideas.ts new file mode 100644 index 0000000..e73a69d --- /dev/null +++ b/packages/application/src/content/content-ideas.ts @@ -0,0 +1,160 @@ +import type { JobQueue, LeasedJob } from "@outbound/application/jobs/job-queue"; +import type { EditorialStrategySnapshot } from "@outbound/domain/content/editorial-strategy"; +import type { ContentIdeaCandidate, ContentIdeaSourceType, ContentIdeaStatus } from "@outbound/domain/content/content-idea"; +import { assertGroundedIdeaCandidate } from "@outbound/domain/content/content-idea"; + +export const CONTENT_IDEA_DISCOVERY_JOB_TYPE = "content.ideas.discover"; + +export interface ContentIdeaEvidence { + readonly key: string; + readonly type: ContentIdeaSourceType; + readonly sourceRef: string; + readonly canonicalUrl: string | null; + readonly title: string; + readonly excerpt: string; + readonly contentHash: string; + readonly collectedAt: Date; +} + +export interface ContentIdeaView { + readonly id: string; + readonly workspaceId: string; + readonly strategyVersionId: string; + readonly status: ContentIdeaStatus; + readonly angle: string; + readonly rationale: string; + readonly audience: string; + readonly pillar: string; + readonly priority: number; + readonly freshnessUntil: Date; + readonly firstSeenAt: Date; + readonly lastSeenAt: Date; + readonly sources: readonly ContentIdeaEvidence[]; +} + +export interface ContentIdeaDiscoveryRunView { + readonly id: string; + readonly workspaceId: string; + readonly strategyVersionId: string; + readonly status: "queued" | "running" | "completed" | "partial" | "failed"; + readonly trigger: "manual" | "daily"; + readonly cursor: number; + readonly queryCount: number; + readonly sourceCount: number; + readonly ideaCount: number; + readonly queryLimit: number; + readonly sourceLimit: number; + readonly deadlineAt: Date; + readonly lastErrorCode: string | null; + readonly lastErrorMessage: string | null; + readonly createdAt: Date; + readonly completedAt: Date | null; +} + +export interface ContentIdeaDiscoveryContext { + readonly run: ContentIdeaDiscoveryRunView; + readonly strategy: EditorialStrategySnapshot; + readonly queries: readonly string[]; + readonly internalEvidence: readonly ContentIdeaEvidence[]; +} + +export interface ContentIdeaRepository { + findRequest(input: { workspaceId: string; requestKey: string }): Promise; + createDiscovery(input: { workspaceId: string; userId: string | null; requestKey: string; trigger: "manual" | "daily"; now: Date }): Promise; + list(input: { workspaceId: string; status?: ContentIdeaStatus; cursor?: string; limit: number }): Promise<{ data: readonly ContentIdeaView[]; nextCursor: string | null }>; + findRun(input: { workspaceId: string; runId: string }): Promise; + loadDiscoveryContext(input: { workspaceId: string; runId: string }): Promise; + startRun(input: { workspaceId: string; runId: string; now: Date }): Promise; + saveStep(input: { + workspaceId: string; + runId: string; + cursor: number; + evidence: readonly ContentIdeaEvidence[]; + candidates: readonly ContentIdeaCandidate[]; + discoveredSourceCount: number; + now: Date; + }): Promise; + completeRun(input: { workspaceId: string; runId: string; partial: boolean; now: Date }): Promise; + failRun(input: { workspaceId: string; runId: string; code: string; message: string; now: Date }): Promise; +} + +export interface ContentIdeaSourceDiscovery { + search(input: { query: string; limit: number; correlationId: string }): Promise; +} + +export interface ContentIdeaCandidateGenerator { + generate(input: { + workspaceId: string; + strategy: EditorialStrategySnapshot; + query: string; + evidence: readonly ContentIdeaEvidence[]; + }): Promise; +} + +export class ContentIdeaApplication { + constructor(private readonly repository: ContentIdeaRepository) {} + + list(input: Parameters[0]) { return this.repository.list(input); } + findRun(input: Parameters[0]) { return this.repository.findRun(input); } + + async discover(input: { workspaceId: string; userId: string; requestKey: string; now?: Date }) { + const replay = await this.repository.findRequest({ workspaceId: input.workspaceId, requestKey: input.requestKey }); + if (replay) return replay; + return this.repository.createDiscovery({ ...input, trigger: "manual", now: input.now ?? new Date() }); + } +} + +export class ContentIdeaDiscoveryJobProcessor { + constructor( + private readonly repository: ContentIdeaRepository, + private readonly sourceDiscovery: ContentIdeaSourceDiscovery, + private readonly generator: ContentIdeaCandidateGenerator, + private readonly queue: JobQueue, + private readonly now: () => Date = () => new Date(), + ) {} + + async process(job: LeasedJob): Promise { + const payload = job.payload as { runId?: unknown }; + if (typeof payload.runId !== "string") throw new Error("CONTENT_IDEA_JOB_INVALID"); + try { + const context = await this.repository.loadDiscoveryContext({ workspaceId: job.workspaceId, runId: payload.runId }); + await this.repository.startRun({ workspaceId: job.workspaceId, runId: payload.runId, now: this.now() }); + let partial = false; + let sourceCount = context.run.sourceCount; + for (let cursor = context.run.cursor; cursor < context.queries.length; cursor += 1) { + const current = this.now(); + if (current >= context.run.deadlineAt || sourceCount >= context.run.sourceLimit) { + partial = true; + break; + } + const query = context.queries[cursor]!; + const remaining = Math.max(0, context.run.sourceLimit - sourceCount); + const publicEvidence = await this.sourceDiscovery.search({ + query, + limit: Math.min(8, remaining), + correlationId: `${job.correlationId}:query:${cursor}`, + }); + const evidence = [...context.internalEvidence, ...publicEvidence]; + const candidates = await this.generator.generate({ workspaceId: job.workspaceId, strategy: context.strategy, query, evidence }); + for (const candidate of candidates) assertGroundedIdeaCandidate(candidate, evidence.map((item) => item.key)); + await this.repository.saveStep({ + workspaceId: job.workspaceId, + runId: payload.runId, + cursor: cursor + 1, + evidence, + candidates, + discoveredSourceCount: publicEvidence.length, + now: this.now(), + }); + sourceCount += publicEvidence.length; + } + await this.repository.completeRun({ workspaceId: job.workspaceId, runId: payload.runId, partial, now: this.now() }); + await this.queue.acknowledge(job.id, job.lockedBy, this.now()); + } catch (error) { + if (job.attempts >= job.maxAttempts) { + await this.repository.failRun({ workspaceId: job.workspaceId, runId: payload.runId, code: "CONTENT_IDEA_DISCOVERY_FAILED", message: error instanceof Error ? error.message : String(error), now: this.now() }); + } + throw error; + } + } +} diff --git a/packages/contracts/openapi/product-research-v1.json b/packages/contracts/openapi/product-research-v1.json index 292f093..ef7f870 100644 --- a/packages/contracts/openapi/product-research-v1.json +++ b/packages/contracts/openapi/product-research-v1.json @@ -1485,6 +1485,18 @@ "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], "post": { "operationId": "publishEditorialStrategy", "summary": "Publish an immutable editorial strategy version", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentRequestKey" } } } }, "responses": { "201": { "description": "Immutable strategy version", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorialStrategyVersion" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "404": { "$ref": "#/components/responses/RunNotFound" } } } }, + "/api/v1/content/ideas": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "get": { "operationId": "listContentIdeas", "summary": "List sourced and deduplicated content ideas", "parameters": [{ "name": "cursor", "in": "query", "schema": { "type": "string" } }, { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["discovered", "shortlisted", "briefed", "discarded", "expired"] } }, { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 } }], "responses": { "200": { "description": "Cursor-paginated ideas with resolvable sources" }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } + }, + "/api/v1/content/ideas/discover": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], + "post": { "operationId": "discoverContentIdeas", "summary": "Schedule a bounded, resumable and idempotent idea research run", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentRequestKey" } } } }, "responses": { "202": { "description": "Durable discovery run", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentIdeaDiscoveryRun" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "409": { "$ref": "#/components/responses/Conflict" }, "422": { "$ref": "#/components/responses/InvalidRequest" } } } + }, + "/api/v1/content/idea-discovery-runs/{runId}": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }, { "name": "runId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }], + "get": { "operationId": "getContentIdeaDiscoveryRun", "summary": "Read durable idea research progress without affecting the worker", "responses": { "200": { "description": "Discovery run status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentIdeaDiscoveryRun" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" }, "404": { "$ref": "#/components/responses/RunNotFound" } } } + }, "/api/v1/workspace/operational-summary": { "parameters": [{ "$ref": "#/components/parameters/WorkspaceSlug" }], "get": { "operationId": "getWorkspaceOperationalSummary", "summary": "Read the tenant-scoped À traiter projection", "parameters": [{ "name": "attentionCursor", "in": "query", "schema": { "type": "string" } }, { "name": "attentionLimit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } }], "responses": { "200": { "description": "Operational counters, engines, outcomes, exceptions, jobs and account health", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceOperationalSummary" } } } }, "403": { "$ref": "#/components/responses/WorkspaceForbidden" } } } @@ -2983,6 +2995,7 @@ "properties": { "data": { "type": "array", "items": { "type": "object", "required": ["id", "contactId", "channel", "status", "unreadCount", "lastMessageAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "contactId": { "type": "string", "format": "uuid" }, "firstName": { "type": "string" }, "lastName": { "type": "string" }, "campaignId": { "type": ["string", "null"], "format": "uuid" }, "campaignName": { "type": ["string", "null"] }, "channel": { "type": "string", "enum": ["linkedin", "email", "whatsapp"] }, "status": { "type": "string" }, "unreadCount": { "type": "integer" }, "lastMessage": { "type": ["object", "null"] }, "lastMessageAt": { "type": "string", "format": "date-time" } } } }, "pagination": { "type": "object", "required": ["page", "pageSize", "total", "hasNext"], "properties": { "page": { "type": "integer" }, "pageSize": { "type": "integer" }, "total": { "type": "integer" }, "hasNext": { "type": "boolean" } } } } }, "ContentRequestKey": { "type": "object", "additionalProperties": false, "required": ["requestKey"], "properties": { "requestKey": { "type": "string", "minLength": 8, "maxLength": 300 } } }, + "ContentIdeaDiscoveryRun": { "type": "object", "required": ["id", "workspaceId", "strategyVersionId", "status", "trigger", "cursor", "queryCount", "sourceCount", "ideaCount", "queryLimit", "sourceLimit", "deadlineAt", "createdAt"], "properties": { "id": { "type": "string", "format": "uuid" }, "workspaceId": { "type": "string", "format": "uuid" }, "strategyVersionId": { "type": "string", "format": "uuid" }, "status": { "type": "string", "enum": ["queued", "running", "completed", "partial", "failed"] }, "trigger": { "type": "string", "enum": ["manual", "daily"] }, "cursor": { "type": "integer", "minimum": 0 }, "queryCount": { "type": "integer", "minimum": 0 }, "sourceCount": { "type": "integer", "minimum": 0 }, "ideaCount": { "type": "integer", "minimum": 0 }, "queryLimit": { "type": "integer", "minimum": 1 }, "sourceLimit": { "type": "integer", "minimum": 1 }, "deadlineAt": { "type": "string", "format": "date-time" }, "createdAt": { "type": "string", "format": "date-time" }, "completedAt": { "type": ["string", "null"], "format": "date-time" } } }, "EditorialStrategySnapshot": { "type": "object", "additionalProperties": false, "required": ["audience", "pillars", "voice", "formats", "cadence", "callsToAction", "allowedClaimIds", "forbiddenTopics"], "properties": { diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 39768dd..3f6f730 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { EditorialStrategySnapshot } from "@outbound/domain/content/editorial-strategy"; +import type { ContentIdeaCandidate } from "@outbound/domain/content/content-idea"; export const editorialStrategySnapshotSchema: z.ZodType = z.object({ audience: z.object({ @@ -26,3 +27,22 @@ export const editorialStrategySnapshotSchema: z.ZodType = z.object({ + angle: z.string().trim().min(10).max(500), + rationale: z.string().trim().min(10).max(2_000), + audience: z.string().trim().min(2).max(500), + pillar: z.string().trim().min(2).max(300), + priority: z.number().int().min(0).max(100), + freshnessDays: z.number().int().min(1).max(365), + sourceKeys: z.array(z.string().trim().min(1).max(500)).min(1).max(12), + conceptKey: z.string().trim().min(3).max(500), +}).strict(); + +export const contentIdeaBatchSchema = z.object({ + ideas: z.array(contentIdeaCandidateSchema).max(12), +}).strict(); + +export const contentIdeaDiscoveryRequestSchema = z.object({ + requestKey: z.string().trim().min(8).max(300), +}).strict(); diff --git a/packages/domain/src/content/content-idea.ts b/packages/domain/src/content/content-idea.ts new file mode 100644 index 0000000..6bf4121 --- /dev/null +++ b/packages/domain/src/content/content-idea.ts @@ -0,0 +1,44 @@ +export const contentIdeaStatuses = ["discovered", "shortlisted", "briefed", "discarded", "expired"] as const; +export type ContentIdeaStatus = (typeof contentIdeaStatuses)[number]; + +export const contentIdeaSourceTypes = ["offer_claim", "knowledge_claim", "conversation_message", "public_web"] as const; +export type ContentIdeaSourceType = (typeof contentIdeaSourceTypes)[number]; + +export interface ContentIdeaCandidate { + readonly angle: string; + readonly rationale: string; + readonly audience: string; + readonly pillar: string; + readonly priority: number; + readonly freshnessDays: number; + readonly sourceKeys: readonly string[]; + readonly conceptKey: string; +} + +export function assertGroundedIdeaCandidate(candidate: ContentIdeaCandidate, availableSourceKeys: readonly string[]): void { + const available = new Set(availableSourceKeys); + if (candidate.sourceKeys.length === 0 || candidate.sourceKeys.some((key) => !available.has(key))) { + throw new Error("CONTENT_IDEA_UNRESOLVED_SOURCE"); + } + if (!candidate.angle.trim() || !candidate.rationale.trim() || !candidate.audience.trim() || !candidate.pillar.trim()) { + throw new Error("CONTENT_IDEA_INCOMPLETE"); + } + if (!Number.isInteger(candidate.priority) || candidate.priority < 0 || candidate.priority > 100) { + throw new Error("CONTENT_IDEA_PRIORITY_INVALID"); + } + if (!Number.isInteger(candidate.freshnessDays) || candidate.freshnessDays < 1 || candidate.freshnessDays > 365) { + throw new Error("CONTENT_IDEA_FRESHNESS_INVALID"); + } +} + +export function normalizeIdeaConcept(value: string): string { + return value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase("fr") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .replace(/\s+/g, " ") + .replace(/\b(?:[a-z]\s+){2,}[a-z]\b/g, (initialism) => initialism.replace(/\s/g, "")) + .slice(0, 500); +} diff --git a/packages/infrastructure/migrations/0069_noosphere_content_ideas.sql b/packages/infrastructure/migrations/0069_noosphere_content_ideas.sql new file mode 100644 index 0000000..1a91158 --- /dev/null +++ b/packages/infrastructure/migrations/0069_noosphere_content_ideas.sql @@ -0,0 +1,83 @@ +CREATE TABLE "content_idea_discovery_runs" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL REFERENCES "workspaces" ("id") ON DELETE cascade, + "strategy_version_id" uuid NOT NULL, + "trigger" varchar(20) NOT NULL, + "status" varchar(40) DEFAULT 'queued' NOT NULL, + "query_plan" jsonb NOT NULL, + "cursor" integer DEFAULT 0 NOT NULL, + "query_count" integer DEFAULT 0 NOT NULL, + "source_count" integer DEFAULT 0 NOT NULL, + "idea_count" integer DEFAULT 0 NOT NULL, + "query_limit" integer NOT NULL, + "source_limit" integer NOT NULL, + "deadline_at" timestamp with time zone NOT NULL, + "last_error_code" varchar(160), + "last_error_message" text, + "created_by" uuid REFERENCES "auth_users" ("id") ON DELETE set null, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "content_idea_runs_workspace_id_uq" UNIQUE ("workspace_id", "id"), + CONSTRAINT "content_idea_runs_workspace_strategy_version_fk" FOREIGN KEY ("workspace_id", "strategy_version_id") REFERENCES "editorial_strategy_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "content_idea_runs_trigger_ck" CHECK ("trigger" in ('manual', 'daily')), + CONSTRAINT "content_idea_runs_status_ck" CHECK ("status" in ('queued', 'running', 'completed', 'partial', 'failed')), + CONSTRAINT "content_idea_runs_budget_ck" CHECK ("query_limit" > 0 and "source_limit" > 0) +); + +CREATE TABLE "content_ideas" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL REFERENCES "workspaces" ("id") ON DELETE cascade, + "strategy_version_id" uuid NOT NULL, + "status" varchar(40) DEFAULT 'discovered' NOT NULL, + "angle" varchar(500) NOT NULL, + "rationale" text NOT NULL, + "audience" varchar(500) NOT NULL, + "pillar" varchar(300) NOT NULL, + "priority" integer NOT NULL, + "fingerprint" varchar(64) NOT NULL, + "freshness_until" timestamp with time zone NOT NULL, + "first_seen_at" timestamp with time zone NOT NULL, + "last_seen_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "content_ideas_workspace_id_uq" UNIQUE ("workspace_id", "id"), + CONSTRAINT "content_ideas_workspace_strategy_version_fk" FOREIGN KEY ("workspace_id", "strategy_version_id") REFERENCES "editorial_strategy_versions" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "content_ideas_status_ck" CHECK ("status" in ('discovered', 'shortlisted', 'briefed', 'discarded', 'expired')), + CONSTRAINT "content_ideas_priority_ck" CHECK ("priority" between 0 and 100) +); + +CREATE UNIQUE INDEX "content_ideas_workspace_fingerprint_uq" ON "content_ideas" ("workspace_id", "fingerprint"); + +CREATE TABLE "content_idea_sources" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL REFERENCES "workspaces" ("id") ON DELETE cascade, + "idea_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "type" varchar(40) NOT NULL, + "source_ref" varchar(500) NOT NULL, + "canonical_url" text, + "title" varchar(500) NOT NULL, + "excerpt" text NOT NULL, + "content_hash" varchar(128) NOT NULL, + "collected_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "content_idea_sources_workspace_idea_fk" FOREIGN KEY ("workspace_id", "idea_id") REFERENCES "content_ideas" ("workspace_id", "id") ON DELETE cascade, + CONSTRAINT "content_idea_sources_workspace_run_fk" FOREIGN KEY ("workspace_id", "run_id") REFERENCES "content_idea_discovery_runs" ("workspace_id", "id") ON DELETE restrict, + CONSTRAINT "content_idea_sources_type_ck" CHECK ("type" in ('offer_claim', 'knowledge_claim', 'conversation_message', 'public_web')) +); + +CREATE UNIQUE INDEX "content_idea_sources_idea_hash_uq" ON "content_idea_sources" ("workspace_id", "idea_id", "content_hash"); + +CREATE TABLE "content_idea_schedules" ( + "workspace_id" uuid PRIMARY KEY REFERENCES "workspaces" ("id") ON DELETE cascade, + "enabled" boolean DEFAULT true NOT NULL, + "local_time" varchar(5) DEFAULT '06:00' NOT NULL, + "timezone" varchar(120) DEFAULT 'Europe/Paris' NOT NULL, + "last_run_at" timestamp with time zone, + "next_run_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "content_idea_schedules_local_time_ck" CHECK ("local_time" ~ '^(?:[01][0-9]|2[0-3]):[0-5][0-9]$') +); diff --git a/packages/infrastructure/migrations/meta/_journal.json b/packages/infrastructure/migrations/meta/_journal.json index 4ce7d3c..f08f6f0 100644 --- a/packages/infrastructure/migrations/meta/_journal.json +++ b/packages/infrastructure/migrations/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1797775260000, "tag": "0068_editorial_strategy_immutability", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1797775320000, + "tag": "0069_noosphere_content_ideas", + "breakpoints": true } ] } diff --git a/packages/infrastructure/src/content/crawler-content-idea-source.ts b/packages/infrastructure/src/content/crawler-content-idea-source.ts new file mode 100644 index 0000000..f3f595a --- /dev/null +++ b/packages/infrastructure/src/content/crawler-content-idea-source.ts @@ -0,0 +1,28 @@ +import type { ContentIdeaEvidence, ContentIdeaSourceDiscovery } from "@outbound/application/content/content-ideas"; +import type { CrawlerClient } from "@outbound/infrastructure/ai/crawler-client"; + +export class CrawlerContentIdeaSource implements ContentIdeaSourceDiscovery { + constructor(private readonly crawler: CrawlerClient) {} + + async search(input: { query: string; limit: number; correlationId: string }): Promise { + if (input.limit < 1) return []; + const results = await this.crawler.search({ query: input.query, limit: input.limit, correlationId: input.correlationId, searchDepth: "advanced" }); + return results.map((result) => { + const canonicalUrl = result.canonicalUrl ?? result.url; + const excerpt = (result.description || result.markdown || result.title).slice(0, 2_000); + const contentHash = result.contentHash ?? hash(`${canonicalUrl}|${result.title}|${excerpt}`); + return { + key: `public_web:${contentHash}`, + type: "public_web" as const, + sourceRef: canonicalUrl.slice(0, 500), + canonicalUrl, + title: result.title.slice(0, 500), + excerpt, + contentHash, + collectedAt: result.collectedAt ? new Date(result.collectedAt) : new Date(), + }; + }); + } +} + +function hash(value: string): string { return new Bun.CryptoHasher("sha256").update(value).digest("hex"); } diff --git a/packages/infrastructure/src/content/daily-content-idea-scheduler.ts b/packages/infrastructure/src/content/daily-content-idea-scheduler.ts new file mode 100644 index 0000000..9f481c5 --- /dev/null +++ b/packages/infrastructure/src/content/daily-content-idea-scheduler.ts @@ -0,0 +1,69 @@ +import { and, desc, eq, lte, sql } from "drizzle-orm"; +import type { Clock } from "@outbound/application/shared/ports"; +import type { ContentIdeaRepository } from "@outbound/application/content/content-ideas"; +import type { Database } from "@outbound/infrastructure/database/client"; +import { contentIdeaSchedules, editorialStrategies } from "@outbound/infrastructure/database/schema"; +import { firstDailyOccurrence, nextDailyOccurrence } from "@outbound/infrastructure/campaigns/daily-prospecting-scheduler"; + +export class DailyContentIdeaScheduler { + constructor( + private readonly database: Database, + private readonly repository: ContentIdeaRepository, + private readonly clock: Clock, + private readonly defaults: { localTime: string; timezone: string } = { localTime: "06:00", timezone: "Europe/Paris" }, + ) {} + + async reconcile(limit = 25): Promise { + const now = this.clock.now(); + await this.#ensureSchedules(now); + const due = await this.database.select().from(contentIdeaSchedules).where(and( + eq(contentIdeaSchedules.enabled, true), + lte(contentIdeaSchedules.nextRunAt, now), + )).limit(limit); + let scheduled = 0; + for (const schedule of due) { + const date = zonedDateKey(now, schedule.timezone); + await this.repository.createDiscovery({ + workspaceId: schedule.workspaceId, + userId: null, + requestKey: `daily:${date}`, + trigger: "daily", + now, + }); + await this.database.update(contentIdeaSchedules).set({ + lastRunAt: now, + nextRunAt: nextDailyOccurrence(now, schedule.localTime, schedule.timezone), + updatedAt: now, + }).where(and( + eq(contentIdeaSchedules.workspaceId, schedule.workspaceId), + lte(contentIdeaSchedules.nextRunAt, now), + )); + scheduled += 1; + } + return scheduled; + } + + async #ensureSchedules(now: Date): Promise { + const active = await this.database.select({ workspaceId: editorialStrategies.workspaceId }).from(editorialStrategies).where(and( + eq(editorialStrategies.status, "active"), + sql`${editorialStrategies.currentVersion} > 0`, + sql`${editorialStrategies.deletedAt} is null`, + )).orderBy(desc(editorialStrategies.updatedAt)); + for (const row of active) { + await this.database.insert(contentIdeaSchedules).values({ + workspaceId: row.workspaceId, + enabled: true, + localTime: this.defaults.localTime, + timezone: this.defaults.timezone, + nextRunAt: firstDailyOccurrence(now, this.defaults.localTime, this.defaults.timezone), + createdAt: now, + updatedAt: now, + }).onConflictDoNothing(); + } + } +} + +function zonedDateKey(date: Date, timezone: string): string { + const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; +} diff --git a/packages/infrastructure/src/content/langchain-content-idea-generator.ts b/packages/infrastructure/src/content/langchain-content-idea-generator.ts new file mode 100644 index 0000000..7b63029 --- /dev/null +++ b/packages/infrastructure/src/content/langchain-content-idea-generator.ts @@ -0,0 +1,79 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { tool } from "@langchain/core/tools"; +import type { ContentIdeaCandidateGenerator } from "@outbound/application/content/content-ideas"; +import type { AiRunRecorder } from "@outbound/application/ai/ai-run-recorder"; +import type { WorkspaceAiModelPolicyReader } from "@outbound/application/workspaces/workspace-ai-settings"; +import { contentIdeaBatchSchema } from "@outbound/contracts/content"; +import { buildChatModelFields, resolveResearchModelConfigurationFromEnvironment } from "@outbound/infrastructure/ai/langchain-research-agent-executor"; + +type IdeasModelInvoker = (input: { + readonly fields: ConstructorParameters[0]; + readonly strategy: Parameters[0]["strategy"]; + readonly query: string; + readonly evidence: Parameters[0]["evidence"]; +}) => Promise; + +export class LangChainContentIdeaGenerator implements ContentIdeaCandidateGenerator { + readonly #configuration: ReturnType; + constructor( + environment: Readonly> = process.env, + private readonly modelPolicyReader?: WorkspaceAiModelPolicyReader, + private readonly aiRunRecorder?: AiRunRecorder, + private readonly invokeModel: IdeasModelInvoker = invokeIdeasModel, + ) { this.#configuration = resolveResearchModelConfigurationFromEnvironment(environment); } + + async generate(input: Parameters[0]) { + if (input.evidence.length === 0) return []; + const startedAt = performance.now(); + const workspacePolicy = await this.modelPolicyReader?.find(input.workspaceId); + const model = workspacePolicy?.synthesisModels[0] ?? this.#configuration.synthesisModels[0]!; + const parsed = contentIdeaBatchSchema.parse(await this.invokeModel({ + fields: buildChatModelFields(this.#configuration, model, "low"), + strategy: input.strategy, + query: input.query, + evidence: input.evidence, + })); + await this.aiRunRecorder?.record({ + workspaceId: input.workspaceId, + purpose: "content_idea_discovery", + provider: this.#configuration.provider, + model, + promptVersion: "noosphere-content-ideas-v1", + shadow: false, + inputHash: new Bun.CryptoHasher("sha256").update(JSON.stringify({ query: input.query, evidence: input.evidence.map((item) => item.contentHash) })).digest("hex"), + output: parsed, + status: "completed", + cost: null, + latencyMs: Math.max(0, Math.round(performance.now() - startedAt)), + }); + return parsed.ideas; + } +} + +async function invokeIdeasModel(input: Parameters[0]) { + const submit = tool(async (value) => value, { + name: "submit_content_ideas", + description: "Submit grounded and deduplicable LinkedIn content ideas.", + schema: contentIdeaBatchSchema, + }); + const response = await new ChatOpenAI(input.fields).bindTools([submit], { tool_choice: "auto" }).invoke([ + { + role: "system", + content: [ + "You are Noosphere's bounded LinkedIn idea researcher, not a post writer or publisher.", + "Return at most three precise ideas for this research query.", + "Every idea must cite one or more exact evidence keys supplied in evidence. Never invent or transform a fact beyond its excerpt.", + "Questions and objections from real conversations are valid sources for an angle, but do not identify the person.", + "Use the strategy audience, pillar, voice and allowed claims. Reject generic advice that could fit any company.", + "conceptKey is a stable factual concept, not a hook, date or stylistic variation; it is used for deduplication.", + "freshnessDays reflects how quickly the underlying source becomes stale. priority is 0 to 100.", + "Do not create a draft, CTA, publication time or provider action.", + "Call submit_content_ideas exactly once.", + ].join("\n"), + }, + { role: "user", content: JSON.stringify({ strategy: input.strategy, query: input.query, evidence: input.evidence }) }, + ]); + const call = response.tool_calls?.find((candidate) => candidate.name === "submit_content_ideas"); + if (!call) throw new Error("CONTENT_IDEA_TOOL_CALL_MISSING"); + return call.args; +} diff --git a/packages/infrastructure/src/content/postgres-content-idea-repository.ts b/packages/infrastructure/src/content/postgres-content-idea-repository.ts new file mode 100644 index 0000000..285cd8a --- /dev/null +++ b/packages/infrastructure/src/content/postgres-content-idea-repository.ts @@ -0,0 +1,323 @@ +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import type { + ContentIdeaDiscoveryContext, + ContentIdeaDiscoveryRunView, + ContentIdeaEvidence, + ContentIdeaRepository, + ContentIdeaView, +} from "@outbound/application/content/content-ideas"; +import { CONTENT_IDEA_DISCOVERY_JOB_TYPE } from "@outbound/application/content/content-ideas"; +import type { ContentIdeaStatus } from "@outbound/domain/content/content-idea"; +import { normalizeIdeaConcept } from "@outbound/domain/content/content-idea"; +import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; +import type { Database } from "@outbound/infrastructure/database/client"; +import { + auditLogs, + contentIdeaDiscoveryRuns, + contentIdeaSources, + contentIdeas, + contentOperationRequests, + conversations, + editorialStrategies, + editorialStrategyVersions, + jobs, + knowledgeClaims, + knowledgeClaimSources, + knowledgeSources, + messages, + offerClaims, + outboxEvents, +} from "@outbound/infrastructure/database/schema"; + +const DEFAULT_QUERY_LIMIT = 6; +const DEFAULT_SOURCE_LIMIT = 40; +const DEFAULT_DURATION_MS = 5 * 60_000; + +export class PostgresContentIdeaRepository implements ContentIdeaRepository { + constructor(private readonly database: Database) {} + + async findRequest(input: { workspaceId: string; requestKey: string }): Promise { + const requests = await this.database.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, "ideas.discover"), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + return requests[0] ? this.findRun({ workspaceId: input.workspaceId, runId: requests[0].resourceId }) : null; + } + + async createDiscovery(input: Parameters[0]): Promise { + return this.database.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${input.workspaceId}:content-ideas`}, 0))`); + const replay = await tx.select().from(contentOperationRequests).where(and( + eq(contentOperationRequests.workspaceId, input.workspaceId), + eq(contentOperationRequests.operation, "ideas.discover"), + eq(contentOperationRequests.requestKey, input.requestKey), + )).limit(1); + if (replay[0]) { + const retained = await tx.select().from(contentIdeaDiscoveryRuns).where(and( + eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), + eq(contentIdeaDiscoveryRuns.id, replay[0].resourceId), + )).limit(1); + if (retained[0]) return toRun(retained[0]); + } + const existing = await tx.select().from(contentIdeaDiscoveryRuns).where(and( + eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), + sql`${contentIdeaDiscoveryRuns.status} in ('queued', 'running')`, + )).orderBy(desc(contentIdeaDiscoveryRuns.createdAt)).limit(1); + if (existing[0]) { + await tx.insert(contentOperationRequests).values({ + workspaceId: input.workspaceId, + operation: "ideas.discover", + requestKey: input.requestKey, + resourceType: "ContentIdeaDiscoveryRun", + resourceId: existing[0].id, + response: { runId: existing[0].id, replayedActiveRun: true }, + }); + return toRun(existing[0]); + } + const strategies = await tx.select({ + id: editorialStrategies.id, + currentVersion: editorialStrategies.currentVersion, + }).from(editorialStrategies).where(and( + eq(editorialStrategies.workspaceId, input.workspaceId), + eq(editorialStrategies.status, "active"), + isNull(editorialStrategies.deletedAt), + )).orderBy(desc(editorialStrategies.updatedAt)).limit(1); + const strategy = strategies[0]; + if (!strategy || strategy.currentVersion < 1) throw new Error("CONTENT_IDEA_ACTIVE_STRATEGY_REQUIRED"); + const versions = await tx.select().from(editorialStrategyVersions).where(and( + eq(editorialStrategyVersions.workspaceId, input.workspaceId), + eq(editorialStrategyVersions.strategyId, strategy.id), + eq(editorialStrategyVersions.version, strategy.currentVersion), + )).limit(1); + const version = versions[0]; + if (!version) throw new Error("CONTENT_IDEA_ACTIVE_STRATEGY_REQUIRED"); + const snapshot = editorialStrategySnapshotSchema.parse(version.snapshot); + const queryPlan = buildQueryPlan(snapshot).slice(0, DEFAULT_QUERY_LIMIT); + if (queryPlan.length === 0) throw new Error("CONTENT_IDEA_QUERY_PLAN_EMPTY"); + const runId = crypto.randomUUID(); + const run = (await tx.insert(contentIdeaDiscoveryRuns).values({ + id: runId, + workspaceId: input.workspaceId, + strategyVersionId: version.id, + trigger: input.trigger, + status: "queued", + queryPlan, + queryLimit: queryPlan.length, + sourceLimit: DEFAULT_SOURCE_LIMIT, + deadlineAt: new Date(input.now.getTime() + DEFAULT_DURATION_MS), + createdBy: input.userId, + createdAt: input.now, + updatedAt: input.now, + }).returning())[0]!; + await tx.insert(contentOperationRequests).values({ workspaceId: input.workspaceId, operation: "ideas.discover", requestKey: input.requestKey, resourceType: "ContentIdeaDiscoveryRun", resourceId: runId, response: { runId } }); + await tx.insert(jobs).values({ + id: crypto.randomUUID(), + workspaceId: input.workspaceId, + type: CONTENT_IDEA_DISCOVERY_JOB_TYPE, + payload: { runId }, + idempotencyKey: `ideas:${runId}:v1`, + correlationId: `content-ideas:${runId}`, + maxAttempts: 5, + priority: 10, + availableAt: input.now, + createdAt: input.now, + updatedAt: input.now, + }); + await appendEvent(tx, { workspaceId: input.workspaceId, userId: input.userId, runId, eventType: "ContentIdeaDiscoveryScheduled", changes: { trigger: input.trigger, queryCount: queryPlan.length } }); + return toRun(run); + }); + } + + async list(input: Parameters[0]) { + const cursor = decodeCursor(input.cursor); + const conditions = [eq(contentIdeas.workspaceId, input.workspaceId)]; + if (input.status) conditions.push(eq(contentIdeas.status, input.status)); + if (cursor) conditions.push(or( + lt(contentIdeas.lastSeenAt, cursor.at), + and(eq(contentIdeas.lastSeenAt, cursor.at), lt(contentIdeas.id, cursor.id)), + )!); + const rows = await this.database.select().from(contentIdeas).where(and(...conditions)).orderBy(desc(contentIdeas.lastSeenAt), desc(contentIdeas.id)).limit(input.limit + 1); + const page = rows.slice(0, input.limit); + const sources = page.length ? await this.database.select().from(contentIdeaSources).where(and( + eq(contentIdeaSources.workspaceId, input.workspaceId), + inArray(contentIdeaSources.ideaId, page.map((row) => row.id)), + )).orderBy(desc(contentIdeaSources.collectedAt)) : []; + const byIdea = new Map(); + for (const source of sources) { + const current = byIdea.get(source.ideaId) ?? []; + current.push(toEvidence(source)); + byIdea.set(source.ideaId, current); + } + return { + data: page.map((row) => toIdea(row, byIdea.get(row.id) ?? [])), + nextCursor: rows.length > input.limit && page.at(-1) ? encodeCursor(page.at(-1)!.lastSeenAt, page.at(-1)!.id) : null, + }; + } + + async findRun(input: { workspaceId: string; runId: string }): Promise { + const rows = await this.database.select().from(contentIdeaDiscoveryRuns).where(and( + eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), + eq(contentIdeaDiscoveryRuns.id, input.runId), + )).limit(1); + return rows[0] ? toRun(rows[0]) : null; + } + + async loadDiscoveryContext(input: { workspaceId: string; runId: string }): Promise { + const rows = await this.database.select({ run: contentIdeaDiscoveryRuns, snapshot: editorialStrategyVersions.snapshot }).from(contentIdeaDiscoveryRuns) + .innerJoin(editorialStrategyVersions, and( + eq(editorialStrategyVersions.workspaceId, contentIdeaDiscoveryRuns.workspaceId), + eq(editorialStrategyVersions.id, contentIdeaDiscoveryRuns.strategyVersionId), + )).where(and(eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId))).limit(1); + const current = rows[0]; + if (!current) throw new Error("CONTENT_IDEA_RUN_NOT_FOUND"); + const strategy = editorialStrategySnapshotSchema.parse(current.snapshot); + const [claimRows, knowledgeRows, conversationRows] = await Promise.all([ + strategy.allowedClaimIds.length ? this.database.select().from(offerClaims).where(and( + eq(offerClaims.workspaceId, input.workspaceId), + inArray(offerClaims.id, [...strategy.allowedClaimIds]), + sql`${offerClaims.validationStatus} in ('sourced', 'validated')`, + )).limit(50) : Promise.resolve([]), + this.database.select({ claim: knowledgeClaims, source: knowledgeSources }).from(knowledgeClaims) + .innerJoin(knowledgeClaimSources, and(eq(knowledgeClaimSources.workspaceId, knowledgeClaims.workspaceId), eq(knowledgeClaimSources.claimId, knowledgeClaims.id))) + .innerJoin(knowledgeSources, and(eq(knowledgeSources.workspaceId, knowledgeClaimSources.workspaceId), eq(knowledgeSources.id, knowledgeClaimSources.sourceId))) + .where(and(eq(knowledgeClaims.workspaceId, input.workspaceId), eq(knowledgeClaims.status, "validated"), eq(knowledgeSources.status, "validated"))) + .orderBy(desc(knowledgeSources.publishedAt)).limit(30), + this.database.select({ message: messages, conversation: conversations }).from(messages) + .innerJoin(conversations, and(eq(conversations.workspaceId, messages.workspaceId), eq(conversations.id, messages.conversationId))) + .where(and(eq(messages.workspaceId, input.workspaceId), eq(messages.direction, "inbound"))) + .orderBy(desc(messages.createdAt)).limit(30), + ]); + const internalEvidence: ContentIdeaEvidence[] = [ + ...claimRows.map((claim) => ({ key: `offer_claim:${claim.id}`, type: "offer_claim" as const, sourceRef: claim.id, canonicalUrl: claim.evidenceUri, title: "Claim d’offre autorisé", excerpt: claim.claim, contentHash: hash(`offer:${claim.id}:${claim.claim}`), collectedAt: current.run.createdAt })), + ...knowledgeRows.map(({ claim, source }) => ({ key: `knowledge_claim:${claim.id}`, type: "knowledge_claim" as const, sourceRef: claim.id, canonicalUrl: null, title: source.title, excerpt: claim.claim, contentHash: hash(`knowledge:${claim.id}:${claim.claim}`), collectedAt: source.publishedAt })), + ...conversationRows.map(({ message, conversation }) => ({ key: `conversation_message:${message.id}`, type: "conversation_message" as const, sourceRef: message.id, canonicalUrl: null, title: `Question ou objection ${conversation.channel}`, excerpt: redactConversationEvidence(message.body).slice(0, 2_000), contentHash: hash(`message:${message.id}:${message.body}`), collectedAt: message.receivedAt ?? message.createdAt })), + ]; + return { + run: toRun(current.run), + strategy, + queries: zodStringArray(current.run.queryPlan), + internalEvidence, + }; + } + + async startRun(input: { workspaceId: string; runId: string; now: Date }): Promise { + await this.database.update(contentIdeaDiscoveryRuns).set({ status: "running", startedAt: sql`coalesce(${contentIdeaDiscoveryRuns.startedAt}, ${input.now.toISOString()}::timestamptz)`, updatedAt: input.now }).where(and( + eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId), sql`${contentIdeaDiscoveryRuns.status} in ('queued', 'running')`, + )); + } + + async saveStep(input: Parameters[0]): Promise { + await this.database.transaction(async (tx) => { + const rows = await tx.select().from(contentIdeaDiscoveryRuns).where(and(eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId))).limit(1).for("update"); + const run = rows[0]; + if (!run) throw new Error("CONTENT_IDEA_RUN_NOT_FOUND"); + if (run.cursor >= input.cursor) return; + let insertedIdeas = 0; + const evidenceByKey = new Map(input.evidence.map((source) => [source.key, source])); + for (const candidate of input.candidates) { + const fingerprint = hash(`${normalizeIdeaConcept(candidate.pillar)}|${normalizeIdeaConcept(candidate.conceptKey)}`); + const freshnessUntil = new Date(input.now.getTime() + candidate.freshnessDays * 86_400_000); + const inserted = await tx.insert(contentIdeas).values({ + id: crypto.randomUUID(), workspaceId: input.workspaceId, strategyVersionId: run.strategyVersionId, status: "discovered", + angle: candidate.angle, rationale: candidate.rationale, audience: candidate.audience, pillar: candidate.pillar, + priority: candidate.priority, fingerprint, freshnessUntil, firstSeenAt: input.now, lastSeenAt: input.now, createdAt: input.now, updatedAt: input.now, + }).onConflictDoNothing({ target: [contentIdeas.workspaceId, contentIdeas.fingerprint] }).returning(); + let idea = inserted[0]; + if (idea) insertedIdeas += 1; + if (!idea) { + const existing = await tx.select().from(contentIdeas).where(and(eq(contentIdeas.workspaceId, input.workspaceId), eq(contentIdeas.fingerprint, fingerprint))).limit(1); + idea = existing[0]; + if (!idea) throw new Error("CONTENT_IDEA_DEDUPLICATION_FAILED"); + await tx.update(contentIdeas).set({ + lastSeenAt: input.now, + priority: sql`greatest(${contentIdeas.priority}, ${candidate.priority})`, + freshnessUntil: sql`greatest(${contentIdeas.freshnessUntil}, ${freshnessUntil.toISOString()}::timestamptz)`, + updatedAt: input.now, + }).where(and(eq(contentIdeas.workspaceId, input.workspaceId), eq(contentIdeas.id, idea.id))); + } + for (const sourceKey of candidate.sourceKeys) { + const source = evidenceByKey.get(sourceKey); + if (!source) throw new Error("CONTENT_IDEA_UNRESOLVED_SOURCE"); + await tx.insert(contentIdeaSources).values({ + id: crypto.randomUUID(), workspaceId: input.workspaceId, ideaId: idea.id, runId: input.runId, + type: source.type, sourceRef: source.sourceRef, canonicalUrl: source.canonicalUrl, title: source.title, + excerpt: source.excerpt, contentHash: source.contentHash, collectedAt: source.collectedAt, + }).onConflictDoNothing({ target: [contentIdeaSources.workspaceId, contentIdeaSources.ideaId, contentIdeaSources.contentHash] }); + } + } + await tx.update(contentIdeaDiscoveryRuns).set({ + cursor: input.cursor, + queryCount: sql`${contentIdeaDiscoveryRuns.queryCount} + 1`, + sourceCount: sql`${contentIdeaDiscoveryRuns.sourceCount} + ${input.discoveredSourceCount}`, + ideaCount: sql`${contentIdeaDiscoveryRuns.ideaCount} + ${insertedIdeas}`, + updatedAt: input.now, + }).where(and(eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId))); + await appendEvent(tx, { workspaceId: input.workspaceId, userId: null, runId: input.runId, eventType: "ContentIdeaDiscoveryStepCompleted", changes: { cursor: input.cursor, insertedIdeas, discoveredSources: input.discoveredSourceCount } }); + }); + } + + async completeRun(input: { workspaceId: string; runId: string; partial: boolean; now: Date }): Promise { + await this.database.transaction(async (tx) => { + const rows = await tx.update(contentIdeaDiscoveryRuns).set({ status: input.partial ? "partial" : "completed", completedAt: input.now, updatedAt: input.now }).where(and( + eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId), sql`${contentIdeaDiscoveryRuns.status} in ('queued', 'running')`, + )).returning(); + if (rows[0]) await appendEvent(tx, { workspaceId: input.workspaceId, userId: null, runId: input.runId, eventType: input.partial ? "ContentIdeaDiscoveryPartiallyCompleted" : "ContentIdeaDiscoveryCompleted", changes: { ideaCount: rows[0].ideaCount, sourceCount: rows[0].sourceCount } }); + }); + } + + async failRun(input: { workspaceId: string; runId: string; code: string; message: string; now: Date }): Promise { + await this.database.update(contentIdeaDiscoveryRuns).set({ status: "failed", lastErrorCode: input.code, lastErrorMessage: input.message.slice(0, 4_000), completedAt: input.now, updatedAt: input.now }).where(and(eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), eq(contentIdeaDiscoveryRuns.id, input.runId))); + } +} + +function buildQueryPlan(snapshot: ReturnType): readonly string[] { + return [...new Set(snapshot.pillars.map((pillar) => `${snapshot.audience.name} ${pillar.name} ${pillar.promise}`.replace(/\s+/g, " ").trim()))]; +} + +function zodStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) throw new Error("CONTENT_IDEA_QUERY_PLAN_INVALID"); + return value as string[]; +} + +function hash(value: string): string { return new Bun.CryptoHasher("sha256").update(value).digest("hex"); } + +function redactConversationEvidence(value: string): string { + return value + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]") + .replace(/(?:\+|00)?\d(?:[\s().-]*\d){7,}/g, "[telephone]"); +} + +function toRun(row: typeof contentIdeaDiscoveryRuns.$inferSelect): ContentIdeaDiscoveryRunView { + return { + id: row.id, workspaceId: row.workspaceId, strategyVersionId: row.strategyVersionId, + status: row.status as ContentIdeaDiscoveryRunView["status"], trigger: row.trigger as ContentIdeaDiscoveryRunView["trigger"], + cursor: row.cursor, queryCount: row.queryCount, sourceCount: row.sourceCount, ideaCount: row.ideaCount, + queryLimit: row.queryLimit, sourceLimit: row.sourceLimit, deadlineAt: row.deadlineAt, + lastErrorCode: row.lastErrorCode, lastErrorMessage: row.lastErrorMessage, createdAt: row.createdAt, completedAt: row.completedAt, + }; +} + +function toIdea(row: typeof contentIdeas.$inferSelect, sources: readonly ContentIdeaEvidence[]): ContentIdeaView { + return { id: row.id, workspaceId: row.workspaceId, strategyVersionId: row.strategyVersionId, status: row.status as ContentIdeaStatus, angle: row.angle, rationale: row.rationale, audience: row.audience, pillar: row.pillar, priority: row.priority, freshnessUntil: row.freshnessUntil, firstSeenAt: row.firstSeenAt, lastSeenAt: row.lastSeenAt, sources }; +} + +function toEvidence(row: typeof contentIdeaSources.$inferSelect): ContentIdeaEvidence { + return { key: `${row.type}:${row.sourceRef}`, type: row.type as ContentIdeaEvidence["type"], sourceRef: row.sourceRef, canonicalUrl: row.canonicalUrl, title: row.title, excerpt: row.excerpt, contentHash: row.contentHash, collectedAt: row.collectedAt }; +} + +function encodeCursor(at: Date, id: string): string { return Buffer.from(JSON.stringify([at.toISOString(), id])).toString("base64url"); } +function decodeCursor(value?: string): { at: Date; id: string } | null { + if (!value) return null; + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") return null; + const at = new Date(parsed[0]); + return Number.isNaN(at.getTime()) ? null : { at, id: parsed[1] }; + } catch { return null; } +} + +async function appendEvent(tx: any, input: { workspaceId: string; userId: string | null; runId: string; eventType: string; changes: unknown }) { + const events = await tx.insert(outboxEvents).values({ workspaceId: input.workspaceId, aggregateType: "ContentIdeaDiscoveryRun", aggregateId: input.runId, eventType: input.eventType, payload: { type: input.eventType, runId: input.runId, workspaceId: input.workspaceId, ...input.changes as object } }).returning({ id: outboxEvents.id }); + if (events[0]) await tx.insert(auditLogs).values({ workspaceId: input.workspaceId, actorUserId: input.userId, action: input.eventType, subjectType: "ContentIdeaDiscoveryRun", subjectId: input.runId, changes: input.changes, sourceEventId: events[0].id }); +} diff --git a/packages/infrastructure/src/database/schema.ts b/packages/infrastructure/src/database/schema.ts index 61cf487..985f8ad 100644 --- a/packages/infrastructure/src/database/schema.ts +++ b/packages/infrastructure/src/database/schema.ts @@ -1439,6 +1439,106 @@ export const contentOperationRequests = pgTable( ], ); +export const contentIdeaDiscoveryRuns = pgTable( + "content_idea_discovery_runs", + { + id: uuid("id").primaryKey(), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }), + strategyVersionId: uuid("strategy_version_id").notNull(), + trigger: varchar("trigger", { length: 20 }).notNull(), + status: varchar("status", { length: 40 }).notNull().default("queued"), + queryPlan: jsonb("query_plan").notNull(), + cursor: integer("cursor").notNull().default(0), + queryCount: integer("query_count").notNull().default(0), + sourceCount: integer("source_count").notNull().default(0), + ideaCount: integer("idea_count").notNull().default(0), + queryLimit: integer("query_limit").notNull(), + sourceLimit: integer("source_limit").notNull(), + deadlineAt: timestamp("deadline_at", { withTimezone: true }).notNull(), + lastErrorCode: varchar("last_error_code", { length: 160 }), + lastErrorMessage: text("last_error_message"), + createdBy: uuid("created_by").references(() => authUsers.id, { onDelete: "set null" }), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.workspaceId, table.strategyVersionId], foreignColumns: [editorialStrategyVersions.workspaceId, editorialStrategyVersions.id], name: "content_idea_runs_workspace_strategy_version_fk" }).onDelete("restrict"), + unique("content_idea_runs_workspace_id_uq").on(table.workspaceId, table.id), + check("content_idea_runs_trigger_ck", sql`${table.trigger} in ('manual', 'daily')`), + check("content_idea_runs_status_ck", sql`${table.status} in ('queued', 'running', 'completed', 'partial', 'failed')`), + check("content_idea_runs_budget_ck", sql`${table.queryLimit} > 0 and ${table.sourceLimit} > 0`), + ], +); + +export const contentIdeas = pgTable( + "content_ideas", + { + id: uuid("id").primaryKey(), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }), + strategyVersionId: uuid("strategy_version_id").notNull(), + status: varchar("status", { length: 40 }).notNull().default("discovered"), + angle: varchar("angle", { length: 500 }).notNull(), + rationale: text("rationale").notNull(), + audience: varchar("audience", { length: 500 }).notNull(), + pillar: varchar("pillar", { length: 300 }).notNull(), + priority: integer("priority").notNull(), + fingerprint: varchar("fingerprint", { length: 64 }).notNull(), + freshnessUntil: timestamp("freshness_until", { withTimezone: true }).notNull(), + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).notNull(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.workspaceId, table.strategyVersionId], foreignColumns: [editorialStrategyVersions.workspaceId, editorialStrategyVersions.id], name: "content_ideas_workspace_strategy_version_fk" }).onDelete("restrict"), + unique("content_ideas_workspace_id_uq").on(table.workspaceId, table.id), + uniqueIndex("content_ideas_workspace_fingerprint_uq").on(table.workspaceId, table.fingerprint), + check("content_ideas_status_ck", sql`${table.status} in ('discovered', 'shortlisted', 'briefed', 'discarded', 'expired')`), + check("content_ideas_priority_ck", sql`${table.priority} between 0 and 100`), + ], +); + +export const contentIdeaSources = pgTable( + "content_idea_sources", + { + id: uuid("id").primaryKey(), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }), + ideaId: uuid("idea_id").notNull(), + runId: uuid("run_id").notNull(), + type: varchar("type", { length: 40 }).notNull(), + sourceRef: varchar("source_ref", { length: 500 }).notNull(), + canonicalUrl: text("canonical_url"), + title: varchar("title", { length: 500 }).notNull(), + excerpt: text("excerpt").notNull(), + contentHash: varchar("content_hash", { length: 128 }).notNull(), + collectedAt: timestamp("collected_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.workspaceId, table.ideaId], foreignColumns: [contentIdeas.workspaceId, contentIdeas.id], name: "content_idea_sources_workspace_idea_fk" }).onDelete("cascade"), + foreignKey({ columns: [table.workspaceId, table.runId], foreignColumns: [contentIdeaDiscoveryRuns.workspaceId, contentIdeaDiscoveryRuns.id], name: "content_idea_sources_workspace_run_fk" }).onDelete("restrict"), + uniqueIndex("content_idea_sources_idea_hash_uq").on(table.workspaceId, table.ideaId, table.contentHash), + check("content_idea_sources_type_ck", sql`${table.type} in ('offer_claim', 'knowledge_claim', 'conversation_message', 'public_web')`), + ], +); + +export const contentIdeaSchedules = pgTable( + "content_idea_schedules", + { + workspaceId: uuid("workspace_id").primaryKey().references(() => workspaces.id, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + localTime: varchar("local_time", { length: 5 }).notNull().default("06:00"), + timezone: varchar("timezone", { length: 120 }).notNull().default("Europe/Paris"), + lastRunAt: timestamp("last_run_at", { withTimezone: true }), + nextRunAt: timestamp("next_run_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [check("content_idea_schedules_local_time_ck", sql`${table.localTime} ~ '^(?:[01][0-9]|2[0-3]):[0-5][0-9]$'`)], +); + export const knowledgeSources = pgTable( "knowledge_sources", { diff --git a/packages/infrastructure/src/workspaces/postgres-operational-views.ts b/packages/infrastructure/src/workspaces/postgres-operational-views.ts index 60827a7..eebacb4 100644 --- a/packages/infrastructure/src/workspaces/postgres-operational-views.ts +++ b/packages/infrastructure/src/workspaces/postgres-operational-views.ts @@ -20,6 +20,9 @@ import { connectedAccounts, contacts, conversations, + contentIdeaDiscoveryRuns, + contentIdeas, + contentIdeaSchedules, dailyProspectingSchedules, editorialStrategies, editorialStrategyVersions, @@ -203,7 +206,7 @@ export class PostgresOperationalViews { const offset = input.offset ?? 0; const limit = input.limit ?? 25; if (input.lens === "inbound") { - const [strategies, versions] = await Promise.all([ + const [strategies, versions, ideaCount, ideas, activeRuns, schedule] = await Promise.all([ this.database.select({ id: editorialStrategies.id, name: editorialStrategies.name, @@ -216,9 +219,36 @@ export class PostgresOperationalViews { sql`${editorialStrategies.deletedAt} is null`, )).orderBy(desc(editorialStrategies.updatedAt)).limit(limit + 1).offset(offset), this.database.select({ value: count() }).from(editorialStrategyVersions).where(eq(editorialStrategyVersions.workspaceId, input.workspaceId)), + this.database.select({ value: count() }).from(contentIdeas).where(and(eq(contentIdeas.workspaceId, input.workspaceId), sql`${contentIdeas.status} not in ('discarded', 'expired')`)), + this.database.select({ id: contentIdeas.id, angle: contentIdeas.angle, pillar: contentIdeas.pillar, priority: contentIdeas.priority, updatedAt: contentIdeas.updatedAt }).from(contentIdeas).where(eq(contentIdeas.workspaceId, input.workspaceId)).orderBy(desc(contentIdeas.lastSeenAt)).limit(limit + 1).offset(offset), + this.database.select({ id: contentIdeaDiscoveryRuns.id, status: contentIdeaDiscoveryRuns.status, cursor: contentIdeaDiscoveryRuns.cursor, queryLimit: contentIdeaDiscoveryRuns.queryLimit, updatedAt: contentIdeaDiscoveryRuns.updatedAt }).from(contentIdeaDiscoveryRuns).where(and(eq(contentIdeaDiscoveryRuns.workspaceId, input.workspaceId), sql`${contentIdeaDiscoveryRuns.status} in ('queued', 'running', 'failed')`)).orderBy(desc(contentIdeaDiscoveryRuns.updatedAt)).limit(5), + this.database.select({ nextRunAt: contentIdeaSchedules.nextRunAt }).from(contentIdeaSchedules).where(and(eq(contentIdeaSchedules.workspaceId, input.workspaceId), eq(contentIdeaSchedules.enabled, true))).limit(1), ]); - const hasNext = strategies.length > limit; - const items = strategies.slice(0, limit).map((strategy) => ({ + const hasNext = ideas.length > limit; + const items = [ + ...activeRuns.map((run) => ({ + id: `idea-run:${run.id}`, + kind: "job" as const, + source: "inbound" as const, + status: run.status === "failed" ? "attention" as const : "running" as const, + title: run.status === "failed" ? "Recherche d’idées en erreur" : "Recherche d’idées en cours", + detail: `${run.cursor}/${run.queryLimit} requêtes traitées · reprise automatique durable`, + occurredAt: run.updatedAt, + href: "/content/ideas", + correlationId: `content-ideas:${run.id}`, + })), + ...ideas.slice(0, limit).map((idea) => ({ + id: `idea:${idea.id}`, + kind: "publication" as const, + source: "inbound" as const, + status: "completed" as const, + title: idea.angle, + detail: `${idea.pillar} · priorité ${idea.priority}/100 · preuves résolubles`, + occurredAt: idea.updatedAt, + href: "/content/ideas", + correlationId: null, + })), + ...strategies.slice(0, 1).map((strategy) => ({ id: `strategy:${strategy.id}`, kind: "publication" as const, source: "inbound" as const, @@ -228,16 +258,19 @@ export class PostgresOperationalViews { occurredAt: strategy.updatedAt, href: "/content/strategy", correlationId: null, - })); + })), + ].slice(0, limit); + const failed = activeRuns.some((run) => run.status === "failed"); + const running = activeRuns.some((run) => run.status === "running" || run.status === "queued"); return { lens: input.lens, asOf, - state: strategies.length ? (strategies[0]!.status === "active" ? "idle" : "attention") : "not_configured", - headline: strategies.length ? "La stratégie éditoriale LinkedIn est durable et ancrée dans l’offre et l’ICP publiés." : "Publiez une offre et un ICP pour dériver la stratégie Inbound.", + state: strategies.length ? (failed ? "attention" : running ? "active" : strategies[0]!.status === "active" ? "idle" : "attention") : "not_configured", + headline: strategies.length ? (running ? "Le radar quotidien recherche et déduplique des angles sourcés." : schedule[0] ? `Le radar est prêt pour son prochain passage automatique.` : "La stratégie éditoriale LinkedIn est durable et ancrée dans l’offre et l’ICP publiés.") : "Publiez une offre et un ICP pour dériver la stratégie Inbound.", counters: [ { key: "strategies", label: "Stratégies", value: strategies.length }, { key: "versions", label: "Versions publiées", value: valueOf(versions) }, - { key: "ideas", label: "Idées sourcées", value: 0 }, + { key: "ideas", label: "Idées sourcées", value: valueOf(ideaCount) }, { key: "planned", label: "Contenus planifiés", value: 0 }, ], items, diff --git a/packages/interface/src/http/content-idea-handler.ts b/packages/interface/src/http/content-idea-handler.ts new file mode 100644 index 0000000..8fb6b62 --- /dev/null +++ b/packages/interface/src/http/content-idea-handler.ts @@ -0,0 +1,65 @@ +import { ZodError, z } from "zod"; +import type { ContentIdeaApplication } from "@outbound/application/content/content-ideas"; +import { contentIdeaStatuses } from "@outbound/domain/content/content-idea"; +import { contentIdeaDiscoveryRequestSchema } from "@outbound/contracts/content"; +import type { RequestContextResolver } from "@outbound/interface/http/request-context"; +import { RequestAuthenticationError, WorkspaceAccessDeniedError, WorkspaceContextRequiredError } from "@outbound/interface/http/request-context"; + +const listQuerySchema = z.object({ + cursor: z.string().trim().min(1).max(1_000).optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), + status: z.enum(contentIdeaStatuses).optional(), +}).strict(); + +export function isContentIdeaRoute(pathname: string): boolean { + return pathname === "/api/v1/content/ideas" + || pathname === "/api/v1/content/ideas/discover" + || /^\/api\/v1\/content\/idea-discovery-runs\/[^/]+$/.test(pathname); +} + +export function createContentIdeaHttpHandler(input: { application: ContentIdeaApplication; contextResolver: RequestContextResolver }) { + return async function handle(request: Request): Promise { + try { + const context = await input.contextResolver.resolve(request); + const url = new URL(request.url); + if (url.pathname === "/api/v1/content/ideas" && request.method === "GET") { + requireViewer(context.role); + const query = listQuerySchema.parse(Object.fromEntries(url.searchParams)); + return json(normalize(await input.application.list({ + workspaceId: context.workspaceId, + limit: query.limit, + ...(query.cursor ? { cursor: query.cursor } : {}), + ...(query.status ? { status: query.status } : {}), + }))); + } + if (url.pathname === "/api/v1/content/ideas/discover" && request.method === "POST") { + requireOperator(context.role); + const body = contentIdeaDiscoveryRequestSchema.parse(await request.json()); + return json(normalize(await input.application.discover({ workspaceId: context.workspaceId, userId: context.userId, requestKey: body.requestKey })), 202); + } + const run = url.pathname.match(/^\/api\/v1\/content\/idea-discovery-runs\/([^/]+)$/); + if (run && request.method === "GET") { + requireViewer(context.role); + const result = await input.application.findRun({ workspaceId: context.workspaceId, runId: z.string().uuid().parse(run[1]) }); + return result ? json(normalize(result)) : problem(404, "CONTENT_IDEA_RUN_NOT_FOUND", "The discovery run does not exist in this workspace"); + } + return problem(405, "METHOD_NOT_ALLOWED", "The HTTP method is not allowed"); + } catch (error) { + if (error instanceof ZodError || error instanceof SyntaxError) return problem(422, "VALIDATION_FAILED", "The request is invalid"); + if (error instanceof RequestAuthenticationError) return problem(401, "AUTHENTICATION_REQUIRED", error.message); + if (error instanceof WorkspaceContextRequiredError) return problem(400, "WORKSPACE_CONTEXT_REQUIRED", error.message); + if (error instanceof WorkspaceAccessDeniedError || error instanceof PermissionError) return problem(403, "WORKSPACE_FORBIDDEN", error.message); + const code = error instanceof Error ? error.message : ""; + if (code === "CONTENT_IDEA_ACTIVE_STRATEGY_REQUIRED") return problem(409, code, "Publish the editorial strategy before researching ideas"); + if (code === "CONTENT_IDEA_RUN_NOT_FOUND") return problem(404, code, "The discovery run does not exist in this workspace"); + return problem(500, "INTERNAL_ERROR", "An unexpected error occurred"); + } + }; +} + +function normalize(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +class PermissionError extends Error {} +function requireViewer(role: string) { if (!["viewer", "operator", "reviewer", "admin", "owner"].includes(role)) throw new PermissionError("Workspace access is required"); } +function requireOperator(role: string) { if (!["operator", "admin", "owner"].includes(role)) throw new PermissionError("Operator access is required"); } +function json(body: unknown, status = 200) { return Response.json(body, { status }); } +function problem(status: number, code: string, detail: string) { return Response.json({ type: `https://api.noosphere.local/problems/${code.toLowerCase()}`, title: code, status, detail, code }, { status, headers: { "content-type": "application/problem+json; charset=utf-8" } }); } diff --git a/tests/e2e/noosphere-axis.spec.ts b/tests/e2e/noosphere-axis.spec.ts index c24700a..beaf78b 100644 --- a/tests/e2e/noosphere-axis.spec.ts +++ b/tests/e2e/noosphere-axis.spec.ts @@ -59,6 +59,13 @@ test("Inbound exposes its grounded editorial strategy without a provider mutatio await expect(page.getByRole("heading", { name: "Aucune stratégie dérivée" })).toBeVisible(); }); +test("the Inbound idea radar is explicit, durable and never presented as a publisher", async ({ page }) => { + await page.goto(`/w/${workspaceSlug}/content/ideas`); + await expect(page.getByRole("heading", { name: "Idées sourcées" })).toBeVisible(); + await expect(page.getByText("Ce radar ne rédige et ne publie rien.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Relancer la recherche" })).toBeVisible(); +}); + test("Outbound surfaces preserve prospect and conversation filters in the URL", async ({ page }) => { const navigation = page.viewportSize()?.width === 390 ? page.getByRole("navigation", { name: "Navigation mobile" }) diff --git a/tests/http/content-idea-http.test.ts b/tests/http/content-idea-http.test.ts new file mode 100644 index 0000000..7809d38 --- /dev/null +++ b/tests/http/content-idea-http.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { createContentIdeaHttpHandler } from "@outbound/interface/http/content-idea-handler"; + +const workspaceId = "30000000-0000-4000-8000-000000000001"; +const userId = "30000000-0000-4000-8000-000000000002"; + +describe("Noosphere content idea HTTP", () => { + test("derives tenant and user from the session and rejects body impersonation", async () => { + const calls: unknown[] = []; + const handler = createContentIdeaHttpHandler({ contextResolver: context("operator"), application: { async discover(input: unknown) { calls.push(input); return run(); } } as never }); + expect((await handler(request("/api/v1/content/ideas/discover", "POST", { requestKey: "idea-request-1", workspaceId: crypto.randomUUID() }))).status).toBe(422); + expect((await handler(request("/api/v1/content/ideas/discover", "POST", { requestKey: "idea-request-2" }))).status).toBe(202); + expect(calls).toEqual([{ workspaceId, userId, requestKey: "idea-request-2" }]); + }); + + test("lets viewers read but never launch research", async () => { + const handler = createContentIdeaHttpHandler({ contextResolver: context("viewer"), application: { async list() { return { data: [], nextCursor: null }; } } as never }); + expect((await handler(request("/api/v1/content/ideas?limit=20"))).status).toBe(200); + expect((await handler(request("/api/v1/content/ideas/discover", "POST", { requestKey: "idea-request-3" }))).status).toBe(403); + }); + + test("reports a missing active strategy as a recoverable conflict", async () => { + const handler = createContentIdeaHttpHandler({ contextResolver: context("owner"), application: { async discover() { throw new Error("CONTENT_IDEA_ACTIVE_STRATEGY_REQUIRED"); } } as never }); + const response = await handler(request("/api/v1/content/ideas/discover", "POST", { requestKey: "idea-request-4" })); + expect(response.status).toBe(409); + expect((await response.json()).code).toBe("CONTENT_IDEA_ACTIVE_STRATEGY_REQUIRED"); + }); +}); + +function context(role: "viewer" | "operator" | "owner") { return { async resolve() { return { workspaceId, userId, role }; } }; } +function request(path: string, method = "GET", body?: unknown) { return new Request(`http://localhost${path}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); } +function run() { return { id: crypto.randomUUID(), workspaceId, strategyVersionId: crypto.randomUUID(), status: "queued", trigger: "manual", cursor: 0, queryCount: 0, sourceCount: 0, ideaCount: 0, queryLimit: 3, sourceLimit: 40, deadlineAt: new Date(), lastErrorCode: null, lastErrorMessage: null, createdAt: new Date(), completedAt: null }; } diff --git a/tests/integration/content-idea-discovery.test.ts b/tests/integration/content-idea-discovery.test.ts new file mode 100644 index 0000000..4b67a3e --- /dev/null +++ b/tests/integration/content-idea-discovery.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { createDatabase } from "@outbound/infrastructure/database/client"; +import { + authUsers, + editorialStrategies, + editorialStrategyVersions, + icps, + icpVersions, + offerClaims, + offers, + offerVersions, + workspaces, +} from "@outbound/infrastructure/database/schema"; +import { PostgresContentIdeaRepository } from "@outbound/infrastructure/content/postgres-content-idea-repository"; +import { DailyContentIdeaScheduler } from "@outbound/infrastructure/content/daily-content-idea-scheduler"; + +const databaseUrl = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL; +const databaseDescribe = databaseUrl ? describe : describe.skip; + +databaseDescribe("IDE-101 durable content idea discovery", () => { + if (!databaseUrl) return; + const database = createDatabase(databaseUrl); + const repository = new PostgresContentIdeaRepository(database.db); + const workspaceId = crypto.randomUUID(); + const otherWorkspaceId = crypto.randomUUID(); + const userId = crypto.randomUUID(); + const offerId = crypto.randomUUID(); + const offerVersionId = crypto.randomUUID(); + const claimId = crypto.randomUUID(); + const icpId = crypto.randomUUID(); + const icpVersionId = crypto.randomUUID(); + const strategyId = crypto.randomUUID(); + const strategyVersionId = crypto.randomUUID(); + const now = new Date("2026-08-20T06:00:00.000Z"); + + beforeAll(async () => { + await migrate(database.db, { migrationsFolder: resolve(import.meta.dir, "../../packages/infrastructure/migrations") }); + await database.db.insert(workspaces).values([ + { id: workspaceId, slug: `ideas-a-${workspaceId}`, name: "Ideas A" }, + { id: otherWorkspaceId, slug: `ideas-b-${otherWorkspaceId}`, name: "Ideas B" }, + ]); + await database.db.insert(authUsers).values({ id: userId, name: "Idea Owner", email: `ideas-${userId}@example.com` }); + await database.db.insert(offers).values({ id: offerId, workspaceId, name: "Noosphere", status: "draft", currentVersion: 1, category: "saas", valueProposition: "Relier contenu et revenu", targetAudience: "Équipes B2B", createdBy: userId }); + await database.db.insert(offerVersions).values({ id: offerVersionId, workspaceId, offerId, version: 1, name: "Noosphere", category: "saas", valueProposition: "Relier contenu et revenu", targetAudience: "Équipes B2B", publishedBy: userId, publishedAt: now }); + await database.db.insert(offerClaims).values({ id: claimId, workspaceId, offerVersionId, claim: "Noosphere relie le contenu aux conversations", validationStatus: "validated", evidenceUri: "https://example.com/proof" }); + await database.db.insert(icps).values({ id: icpId, workspaceId, name: "Équipes juridiques", currentVersion: 1 }); + await database.db.insert(icpVersions).values({ id: icpVersionId, workspaceId, icpId, version: 1, name: "Équipes juridiques", confidence: "0.9000", criteria: {}, buyingCommittee: {}, problems: ["Recherche documentaire"], signals: [], exclusions: [], unknowns: [], unresolvedContradictions: [], blockedFindings: [], publishedBy: userId, publishedAt: now }); + const snapshot = strategySnapshot(claimId); + await database.db.insert(editorialStrategies).values({ id: strategyId, workspaceId, name: "Noosphere Legal", offerId, offerVersionId, icpId, icpVersionId, status: "active", currentVersion: 1, draft: snapshot, provider: "kimi-code", model: "k3", promptVersion: "test", createdBy: userId }); + await database.db.insert(editorialStrategyVersions).values({ id: strategyVersionId, workspaceId, strategyId, version: 1, offerVersionId, icpVersionId, snapshot, provider: "kimi-code", model: "k3", promptVersion: "test", publishedBy: userId, publishedAt: now }); + }, 30_000); + + afterAll(async () => { + await database.client`drop trigger if exists audit_logs_immutable_trg on audit_logs`; + await database.client`delete from audit_logs where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from outbox_events where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from content_operation_requests where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from content_idea_sources where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from content_ideas where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from jobs where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from content_idea_discovery_runs where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`delete from content_idea_schedules where workspace_id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`alter table editorial_strategy_versions disable trigger editorial_strategy_versions_immutable_trg`; + await database.client`delete from editorial_strategy_versions where workspace_id = ${workspaceId}`; + await database.client`alter table editorial_strategy_versions enable trigger editorial_strategy_versions_immutable_trg`; + await database.client`delete from editorial_strategies where workspace_id = ${workspaceId}`; + await database.client`alter table offer_claims disable trigger offer_claims_immutable_trg`; + await database.client`delete from offer_claims where workspace_id = ${workspaceId}`; + await database.client`alter table offer_claims enable trigger offer_claims_immutable_trg`; + await database.client`alter table offer_versions disable trigger offer_versions_immutable_trg`; + await database.client`delete from offer_versions where workspace_id = ${workspaceId}`; + await database.client`alter table offer_versions enable trigger offer_versions_immutable_trg`; + await database.client`delete from offers where workspace_id = ${workspaceId}`; + await database.client`alter table icp_versions disable trigger icp_versions_immutable_trg`; + await database.client`delete from icp_versions where workspace_id = ${workspaceId}`; + await database.client`alter table icp_versions enable trigger icp_versions_immutable_trg`; + await database.client`delete from icps where workspace_id = ${workspaceId}`; + await database.client`delete from auth_users where id = ${userId}`; + await database.client`delete from workspaces where id in (${workspaceId}, ${otherWorkspaceId})`; + await database.client`create trigger audit_logs_immutable_trg before update or delete on audit_logs for each row execute function reject_audit_log_mutation()`; + await database.close(); + }, 30_000); + + test("schedules once, resumes by cursor, deduplicates and isolates workspaces", async () => { + const first = await repository.createDiscovery({ workspaceId, userId, requestKey: "ideas:integration:1", trigger: "manual", now }); + const replay = await repository.createDiscovery({ workspaceId, userId, requestKey: "ideas:integration:1", trigger: "manual", now }); + expect(replay.id).toBe(first.id); + const queued = await database.client<{ count: number }[]>`select count(*)::int as count from jobs where workspace_id = ${workspaceId} and type = 'content.ideas.discover'`; + expect(queued[0]?.count).toBe(1); + + const context = await repository.loadDiscoveryContext({ workspaceId, runId: first.id }); + expect(context.strategy.allowedClaimIds).toEqual([claimId]); + expect(context.internalEvidence.map((item) => item.key)).toContain(`offer_claim:${claimId}`); + const source = context.internalEvidence[0]!; + const candidate = { angle: "Pourquoi la recherche documentaire ralentit les équipes juridiques", rationale: "Le claim produit permet de traiter un problème explicite sans inventer de résultat.", audience: "Équipes juridiques", pillar: "Recherche documentaire", priority: 88, freshnessDays: 60, sourceKeys: [source.key], conceptKey: "temps recherche documentaire" }; + await repository.startRun({ workspaceId, runId: first.id, now }); + await repository.saveStep({ workspaceId, runId: first.id, cursor: 1, evidence: [source], candidates: [candidate], discoveredSourceCount: 0, now }); + await repository.saveStep({ workspaceId, runId: first.id, cursor: 1, evidence: [source], candidates: [candidate], discoveredSourceCount: 0, now }); + await repository.saveStep({ workspaceId, runId: first.id, cursor: 2, evidence: [source], candidates: [{ ...candidate, angle: "Un autre hook, le même concept", priority: 92 }], discoveredSourceCount: 0, now: new Date(now.getTime() + 1_000) }); + await repository.completeRun({ workspaceId, runId: first.id, partial: false, now: new Date(now.getTime() + 2_000) }); + + const own = await repository.list({ workspaceId, limit: 20 }); + const other = await repository.list({ workspaceId: otherWorkspaceId, limit: 20 }); + expect(own.data).toHaveLength(1); + expect(own.data[0]?.priority).toBe(92); + expect(own.data[0]?.sources).toHaveLength(1); + expect(other.data).toHaveLength(0); + expect((await repository.findRun({ workspaceId, runId: first.id }))?.status).toBe("completed"); + + const dailyNow = new Date("2026-08-21T04:00:00.000Z"); + const scheduler = new DailyContentIdeaScheduler(database.db, repository, { now: () => dailyNow }); + expect(await scheduler.reconcile()).toBe(1); + expect(await scheduler.reconcile()).toBe(0); + const dailyRuns = await database.client<{ trigger: string }[]>`select trigger from content_idea_discovery_runs where workspace_id = ${workspaceId} and trigger = 'daily'`; + expect(dailyRuns).toHaveLength(1); + }); +}); + +function strategySnapshot(claimId: string) { return { audience: { name: "Équipes juridiques", summary: "Juristes avec des documents dispersés", awareness: "problem_aware" as const }, pillars: [{ name: "Recherche documentaire", promise: "Retrouver les preuves", proofTypes: ["claim validé"] }, { name: "Sécurité", promise: "Garder le contrôle", proofTypes: ["audit"] }, { name: "Adoption", promise: "Déployer avec les équipes", proofTypes: ["chronologie"] }], voice: { traits: ["direct", "précis"], avoid: ["générique"] }, formats: ["linkedin_text" as const], cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, callsToAction: ["Échanger"], allowedClaimIds: [claimId], forbiddenTopics: [] }; } diff --git a/tests/unit/content-idea.test.ts b/tests/unit/content-idea.test.ts new file mode 100644 index 0000000..b45ae9c --- /dev/null +++ b/tests/unit/content-idea.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { assertGroundedIdeaCandidate, normalizeIdeaConcept } from "@outbound/domain/content/content-idea"; +import { ContentIdeaDiscoveryJobProcessor, type ContentIdeaRepository } from "@outbound/application/content/content-ideas"; +import type { JobQueue, LeasedJob } from "@outbound/application/jobs/job-queue"; + +describe("Noosphere content idea radar", () => { + test("normalizes concept keys so stylistic variations deduplicate", () => { + expect(normalizeIdeaConcept(" RGPD : équipes juridiques ")).toBe("rgpd equipes juridiques"); + expect(normalizeIdeaConcept("R.G.P.D — équipes juridiques")).toBe("rgpd equipes juridiques"); + }); + + test("rejects any idea whose proof cannot be resolved", () => { + expect(() => assertGroundedIdeaCandidate(candidate(["missing"]), ["public_web:proof"])).toThrow("CONTENT_IDEA_UNRESOLVED_SOURCE"); + }); + + test("resumes from the durable cursor and acknowledges only after completion", async () => { + const saved: number[] = []; + let completed = false; + let acknowledged = false; + const repository = { + async loadDiscoveryContext() { return { run: { ...run(), cursor: 1 }, strategy: strategy(), queries: ["q0", "q1", "q2"], internalEvidence: [] }; }, + async startRun() {}, + async saveStep(input: { cursor: number }) { saved.push(input.cursor); }, + async completeRun() { completed = true; }, + async failRun() {}, + } as unknown as ContentIdeaRepository; + const queue = { async acknowledge() { acknowledged = true; } } as unknown as JobQueue; + const processor = new ContentIdeaDiscoveryJobProcessor( + repository, + { async search(input) { return [evidence(`proof:${input.query}`)]; } }, + { async generate(input) { return [candidate([input.evidence[0]!.key])]; } }, + queue, + ); + await processor.process(job()); + expect(saved).toEqual([2, 3]); + expect(completed).toBe(true); + expect(acknowledged).toBe(true); + }); +}); + +function candidate(sourceKeys: string[]) { return { angle: "Ce que les équipes juridiques perdent dans leurs dossiers", rationale: "L’angle part d’une preuve résoluble et d’un problème précis.", audience: "Équipes juridiques", pillar: "Recherche documentaire", priority: 82, freshnessDays: 30, sourceKeys, conceptKey: "temps perdu recherche documentaire" }; } +function evidence(key: string) { return { key, type: "public_web" as const, sourceRef: "https://example.com", canonicalUrl: "https://example.com", title: "Source", excerpt: "Preuve précise", contentHash: key, collectedAt: new Date() }; } +function run() { return { id: crypto.randomUUID(), workspaceId: crypto.randomUUID(), strategyVersionId: crypto.randomUUID(), status: "running" as const, trigger: "manual" as const, cursor: 0, queryCount: 0, sourceCount: 0, ideaCount: 0, queryLimit: 3, sourceLimit: 40, deadlineAt: new Date(Date.now() + 60_000), lastErrorCode: null, lastErrorMessage: null, createdAt: new Date(), completedAt: null }; } +function strategy() { return { audience: { name: "Legal", summary: "Legal teams", awareness: "problem_aware" as const }, pillars: [{ name: "Recherche", promise: "Retrouver les preuves", proofTypes: ["étude"] }, { name: "Sécurité", promise: "Garder le contrôle", proofTypes: ["audit"] }, { name: "Déploiement", promise: "Livrer vite", proofTypes: ["chronologie"] }], voice: { traits: ["direct", "précis"], avoid: ["générique"] }, formats: ["linkedin_text" as const], cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, callsToAction: ["Répondre"], allowedClaimIds: [], forbiddenTopics: [] }; } +function job(): LeasedJob { const now = new Date(); return { id: crypto.randomUUID(), workspaceId: crypto.randomUUID(), type: "content.ideas.discover", payload: { runId: crypto.randomUUID() }, idempotencyKey: "ideas", correlationId: "ideas:test", attempts: 1, maxAttempts: 5, availableAt: now, lockedBy: "worker", lockedUntil: new Date(now.getTime() + 60_000) }; } diff --git a/tests/unit/product-research-openapi.test.ts b/tests/unit/product-research-openapi.test.ts index 6994726..8502e2d 100644 --- a/tests/unit/product-research-openapi.test.ts +++ b/tests/unit/product-research-openapi.test.ts @@ -126,6 +126,9 @@ test("the OpenAPI contract declares every F-009 HTTP route", () => { "/api/v1/content/strategy", "/api/v1/content/strategy/derive", "/api/v1/content/strategy/publish", + "/api/v1/content/ideas", + "/api/v1/content/ideas/discover", + "/api/v1/content/idea-discovery-runs/{runId}", "/api/v1/conversations", "/api/v1/opportunities", "/api/v1/opportunities/{opportunityId}", From 54be021ea1d73f404aaae0f36adf51ff4e051de6 Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Thu, 20 Aug 2026 23:22:02 +0200 Subject: [PATCH 132/169] feat(content): add grounded LinkedIn writing pipeline --- apps/api/src/index.ts | 8 + .../content/ideas/[ideaId]/actions.ts | 11 + .../ideas/[ideaId]/content-controls.tsx | 28 ++ .../content/ideas/[ideaId]/page.tsx | 51 ++++ .../w/[workspaceSlug]/content/ideas/page.tsx | 4 +- apps/web/lib/api.ts | 72 +++++ apps/worker/src/index.ts | 11 +- apps/worker/src/research-worker.ts | 4 + .../application/src/ai/ai-run-recorder.ts | 1 + .../src/content/content-generation.ts | 169 +++++++++++ .../openapi/product-research-v1.json | 16 + packages/contracts/src/content.ts | 58 ++++ packages/domain/src/content/content-asset.ts | 124 ++++++++ .../0070_noosphere_content_generation.sql | 104 +++++++ .../migrations/meta/_journal.json | 7 + .../src/ai/postgres-ai-run-recorder.ts | 1 + .../langchain-content-pipeline-agent.ts | 171 +++++++++++ .../postgres-content-generation-repository.ts | 280 ++++++++++++++++++ .../infrastructure/src/database/schema.ts | 105 +++++++ .../workspaces/postgres-operational-views.ts | 128 ++++++-- .../src/http/content-generation-handler.ts | 65 ++++ tests/http/content-generation-http.test.ts | 36 +++ tests/integration/content-generation.test.ts | 157 ++++++++++ tests/unit/content-generation.test.ts | 59 ++++ .../langchain-content-pipeline-agent.test.ts | 55 ++++ tests/unit/product-research-openapi.test.ts | 4 + 26 files changed, 1707 insertions(+), 22 deletions(-) create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/actions.ts create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/content-controls.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/page.tsx create mode 100644 packages/application/src/content/content-generation.ts create mode 100644 packages/domain/src/content/content-asset.ts create mode 100644 packages/infrastructure/migrations/0070_noosphere_content_generation.sql create mode 100644 packages/infrastructure/src/content/langchain-content-pipeline-agent.ts create mode 100644 packages/infrastructure/src/content/postgres-content-generation-repository.ts create mode 100644 packages/interface/src/http/content-generation-handler.ts create mode 100644 tests/http/content-generation-http.test.ts create mode 100644 tests/integration/content-generation.test.ts create mode 100644 tests/unit/content-generation.test.ts create mode 100644 tests/unit/langchain-content-pipeline-agent.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d4fd942..0246662 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -65,6 +65,9 @@ import { createContentStrategyHttpHandler, isContentStrategyRoute } from "@outbo import { ContentIdeaApplication } from "@outbound/application/content/content-ideas"; import { PostgresContentIdeaRepository } from "@outbound/infrastructure/content/postgres-content-idea-repository"; import { createContentIdeaHttpHandler, isContentIdeaRoute } from "@outbound/interface/http/content-idea-handler"; +import { ContentGenerationApplication } from "@outbound/application/content/content-generation"; +import { PostgresContentGenerationRepository } from "@outbound/infrastructure/content/postgres-content-generation-repository"; +import { createContentGenerationHttpHandler, isContentGenerationRoute } from "@outbound/interface/http/content-generation-handler"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -279,6 +282,10 @@ const contentIdeas = createContentIdeaHttpHandler({ contextResolver: auth.contextResolver, application: new ContentIdeaApplication(new PostgresContentIdeaRepository(database.db)), }); +const contentGeneration = createContentGenerationHttpHandler({ + contextResolver: auth.contextResolver, + application: new ContentGenerationApplication(new PostgresContentGenerationRepository(database.db)), +}); const port = positiveIntegerEnvironment("PORT", 3000); const server = Bun.serve({ port, @@ -308,6 +315,7 @@ const server = Bun.serve({ if (isWorkspaceOnboardingRoute(pathname)) return workspaceOnboarding(request); if (isWorkspaceDataRoute(pathname, request.method)) return workspaceData(request); if (isContentStrategyRoute(pathname)) return contentStrategy(request); + if (isContentGenerationRoute(pathname)) return contentGeneration(request); if (isContentIdeaRoute(pathname)) return contentIdeas(request); if ( pathname === "/api/v1/workspace/operational-summary" diff --git a/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/actions.ts b/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/actions.ts new file mode 100644 index 0000000..a5abae1 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { generateContentFromIdea, improveContentAsset } from "@/lib/api"; + +export async function generateContentAction(workspaceSlug: string, ideaId: string) { + return await generateContentFromIdea(workspaceSlug, ideaId, `content:generate:${crypto.randomUUID()}`); +} + +export async function improveContentAction(workspaceSlug: string, assetId: string, instruction: string) { + return await improveContentAsset(workspaceSlug, assetId, `content:improve:${crypto.randomUUID()}`, instruction); +} diff --git a/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/content-controls.tsx b/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/content-controls.tsx new file mode 100644 index 0000000..cdb4165 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/content/ideas/[ideaId]/content-controls.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { RefreshCw, Sparkles } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { generateContentAction, improveContentAction } from "./actions"; + +export function ContentControls({ workspaceSlug, ideaId, assetId, runId, running }: { workspaceSlug: string; ideaId: string; assetId?: string; runId?: string; running: boolean }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [instruction, setInstruction] = useState(""); + const [error, setError] = useState(null); + useEffect(() => { + if (!running) return; + const timer = setInterval(() => router.refresh(), 2_500); + return () => clearInterval(timer); + }, [router, running, runId]); + async function launch() { + setPending(true); setError(null); + try { + const run = assetId ? await improveContentAction(workspaceSlug, assetId, instruction) : await generateContentAction(workspaceSlug, ideaId); + router.replace(`/w/${workspaceSlug}/content/ideas/${ideaId}?run=${run.id}`); + router.refresh(); + } catch (cause) { setError(cause instanceof Error ? cause.message : "La génération n’a pas pu démarrer"); } + finally { setPending(false); } + } + return
{assetId ?
-
État :
+
État :
diff --git a/design/noosphere/screen-today.html b/design/noosphere/screen-today.html index 9211e58..d2c6e64 100644 --- a/design/noosphere/screen-today.html +++ b/design/noosphere/screen-today.html @@ -1,9 +1,9 @@ @@ -11,7 +11,7 @@ - Aujourd'hui — Noosphere + Accueil — Noosphere + + +
+
+
Noosphere
+
+
+
Noosphere Signal · 01

Une intelligence qui transforme les signaux en croissance.

Créer la demande. Capter les signaux. Remplir l’agenda. La mécanique reste en arrière-plan, les résultats restent visibles.

+
Void#050F2F
Phosphor#C8F169
Inbound#57D9CE
Outbound#4E6BFF
Canvassemantic
Surfacesemantic
+

Surfaces de référence

Le même langage visuel sur les trois moments utiles du produit.

+
+
AccueilRESULTATS
Noosphere travaille

Recherche, contenus et conversations avancent sans action requise.

Prochain résultat

Publication LinkedIn aujourd’hui à 17:30.

1 appel confirmé
Contenus12
Prospects248
Conversations18
Appels4
+
MessagesLINKEDIN · EMAIL · WHATSAPP
Est-ce surtout la recherche ou le contrôle des accès qui vous ralentit ?
Les deux, mais surtout le contrôle des accès.
Écrire, améliorer avec l’IA ou laisser le Setter poursuivre…
+
Inbound LinkedInAUTOPILOTE ACTIF
Idées sourcées18
Briefs prouvés5
Brouillons3
Planifiés4
Publiés12
Prochaine publication

Pourquoi les copilotes RAG échouent après le POC · aujourd’hui 17:30.

+
+
+ + + diff --git a/design/noosphere/index.html b/design/noosphere/index.html index 7f862a3..66a43ad 100644 --- a/design/noosphere/index.html +++ b/design/noosphere/index.html @@ -96,6 +96,7 @@

Votre acquisition,
en pilote automatique.

Parcours P0

Ouvrir chaque écran et tester les quatre états depuis la barre de revue.

+ 00 · IDENTITÉ

Noosphere Signal

Palette, typographies, marque et trois surfaces de référence en clair et sombre.

lightdarkresponsive
01 · ACCUEIL

Résultats et état

Une phrase de statut, quatre résultats et uniquement les rares problèmes bloquants.

desktopmobile5 états
02 · MESSAGES

Inbox multicanale

Tous les comptes LinkedIn, email et WhatsApp, avec réponse manuelle ou amélioration IA.

Setterreconnect
03 · APPELS

Agenda commercial

Les rendez-vous obtenus, leur contact et la prochaine action.

agenda4 états
diff --git a/package.json b/package.json index 877475b..ec8b87a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1095.0", "@aws-sdk/s3-request-presigner": "^3.1095.0", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@langchain/core": "^1.2.3", "@langchain/openai": "^1.5.5", "better-auth": "^1.6.25", From f659e593ad767b400f38f1f4d1dcf5e2fd5830db Mon Sep 17 00:00:00 2001 From: salimlaimeche Date: Sun, 23 Aug 2026 20:01:35 +0200 Subject: [PATCH 164/169] feat: add durable Prospect 360 context --- .env.example | 5 +- Dockerfile.backend | 2 +- apps/api/src/index.ts | 52 +- .../w/[workspaceSlug]/appointments/page.tsx | 25 +- .../[workspaceSlug]/content/calendar/page.tsx | 4 +- apps/web/app/w/[workspaceSlug]/inbox/page.tsx | 24 +- .../w/[workspaceSlug]/prospects/actions.ts | 7 + .../app/w/[workspaceSlug]/prospects/page.tsx | 33 +- .../[workspaceSlug]/research/[runId]/page.tsx | 26 +- .../[runId]/research-progress-state.ts | 16 + .../app/w/[workspaceSlug]/settings/actions.ts | 3 + .../settings/channels/actions.ts | 12 +- .../settings/channels/page.tsx | 248 ++++-- .../settings/console/actions.ts | 2 +- .../[workspaceSlug]/settings/console/page.tsx | 10 +- .../app/w/[workspaceSlug]/settings/page.tsx | 2 +- .../strategy/product-reading/error.tsx | 20 + .../strategy/product-reading/page.tsx | 18 +- .../product-reading/product-reading-state.ts | 16 + apps/web/components/conversation-composer.tsx | 65 +- .../components/prospect-activity-drawer.tsx | 14 +- apps/web/components/prospect-memory-panel.tsx | 117 +++ .../prospect-memory-refresh-watcher.tsx | 18 + .../workspace-conversation-drawer.tsx | 33 +- apps/web/lib/api.ts | 178 +++- apps/worker/src/index.ts | 106 ++- apps/worker/src/research-worker.ts | 49 +- compose.benchmark.yml | 2 +- compose.production.yml | 86 +- ...prospect-360-memory-context-engineering.md | 410 +++++++++ ...prospect-360-memory-implementation-plan.md | 305 +++++++ .../ADR-012-provider-neutral-ai-runtime.md | 22 + ...3-prospect-360-memory-capacity-protocol.md | 183 ++++ ...3-prospect-360-memory-validation-report.md | 249 ++++++ ...-prospect-memory-backup-restore-local.json | 41 + ...ospect-memory-benchmark-fixture-local.json | 32 + ...y-capacity-local-memory-focused-clean.json | 162 ++++ ...-memory-capacity-local-memory-focused.json | 420 ++++++++++ ...city-local-warm-optimized-unintrusive.json | 420 ++++++++++ ...-memory-capacity-local-warm-optimized.json | 419 ++++++++++ ...3-prospect-memory-capacity-local-warm.json | 163 ++++ ...-prospect-memory-purge-restored-local.json | 58 ++ ...26-08-23-prospect-memory-shadow-local.json | 29 + ...ect-memory-operator-responses.example.json | 12 + ...-memory-setter-quality-labels.example.json | 13 + package.json | 10 +- packages/application/src/ai/model-gateway.ts | 20 +- packages/application/src/ai/model-router.ts | 12 +- .../campaigns/campaign-content-generator.ts | 6 + .../campaigns/conversation-draft-improver.ts | 5 + .../src/campaigns/inbound-reply-agent.ts | 20 +- .../src/campaigns/prospect-decision.ts | 13 + .../src/gtm/research-orchestrator.ts | 34 +- .../prospect-context-assembler.ts | 380 +++++++++ .../prospect-memory-operations.ts | 413 +++++++++ .../prospect-memory-operator-evaluation.ts | 89 ++ .../prospect-memory-projector.ts | 206 +++++ ...ospect-memory-setter-quality-evaluation.ts | 135 +++ .../prospect-memory-shadow-comparator.ts | 169 ++++ .../prospect-memory-shadow-evaluation.ts | 117 +++ .../src/prospect-memory/prospect-memory.ts | 416 ++++++++++ .../refresh-prospect-memory.ts | 264 ++++++ .../src/workspaces/operational-views.ts | 3 + .../openapi/product-research-v1.json | 132 ++- packages/domain/src/gtm/product-research.ts | 6 +- .../domain/src/operations/operator-console.ts | 26 + .../src/prospect-memory/prospect-memory.ts | 288 +++++++ .../src/workspaces/workspace-data-policy.ts | 15 +- ...85_operator_requeue_aggregate_recovery.sql | 101 +++ .../0086_recover_codex_tls_assessments.sql | 92 +++ ...0087_resume_incomplete_icp_budget_runs.sql | 129 +++ .../0088_retry_invalid_market_outputs.sql | 92 +++ .../0089_prospect_360_memory_foundation.sql | 116 +++ .../0090_prospect_memory_retention.sql | 9 + .../0091_conversation_command_dry_run.sql | 9 + ..._conversation_command_generation_audit.sql | 2 + .../migrations/meta/_journal.json | 56 ++ .../src/ai/codex-cli-model-gateway.ts | 7 +- .../src/ai/kimi-model-gateway.ts | 20 +- .../ai/langchain-research-agent-executor.ts | 219 ++++- .../infrastructure/src/ai/research-tools.ts | 15 +- .../src/ai/v3-sourcing-validator.ts | 39 +- .../src/ai/workspace-structured-model.ts | 17 +- .../approvals/postgres-approval-repository.ts | 18 +- .../postgres-attribution-repository.ts | 18 + .../src/calendar/calendar-signing-key.ts | 15 + .../calendar/postgres-calendar-integration.ts | 122 ++- .../campaigns/automated-reply-send-runner.ts | 25 +- .../campaigns/campaign-automation-runner.ts | 78 +- .../campaigns/campaign-composition-runner.ts | 69 +- .../campaigns/channel-assessment-runner.ts | 21 +- .../campaigns/conversation-command-runner.ts | 281 ++++++- .../src/campaigns/inbound-reply-runner.ts | 108 ++- .../langchain-campaign-content-generator.ts | 103 ++- .../langchain-conversation-draft-improver.ts | 58 +- .../langchain-inbound-reply-agent.ts | 25 +- .../langchain-prospect-decision-agent.ts | 12 +- .../src/campaigns/outreach-dispatch-runner.ts | 1 + ...postgres-campaign-population-repository.ts | 41 + ...ostgres-conversation-command-repository.ts | 28 +- .../postgres-prospect-decision-scheduler.ts | 6 +- .../src/campaigns/prospect-decision-runner.ts | 113 ++- .../src/campaigns/unipile-webhook-ingestor.ts | 12 +- .../content/unipile-social-content-reader.ts | 23 +- .../src/crm/postgres-crm-repository.ts | 117 ++- .../src/crm/postgres-discovery-repository.ts | 25 +- .../src/crm/postgres-merge-service.ts | 55 +- .../crm/postgres-prospect-view-repository.ts | 11 +- .../infrastructure/src/database/schema.ts | 185 +++++ .../unipile-account-inbox-synchronizer.ts | 73 +- .../operations/postgres-operator-console.ts | 169 +++- .../capture-prospect-decision-mutation.ts | 68 ++ .../capture-prospect-memory-mutation.ts | 149 ++++ .../langchain-prospect-memory-synthesizer.ts | 133 +++ ...tgres-prospect-memory-operations-reader.ts | 109 +++ .../postgres-prospect-memory-repository.ts | 619 ++++++++++++++ .../postgres-prospect-memory-state-reader.ts | 217 +++++ .../prospect-memory-backfill.ts | 290 +++++++ .../prospect-memory-refresh-job-processor.ts | 81 ++ .../src/shared/sha256-content-hasher.ts | 10 +- .../workspaces/postgres-operational-views.ts | 8 +- .../postgres-workspace-data-lifecycle.ts | 76 +- .../postgres-workspace-onboarding.ts | 12 +- .../workspaces/workspace-campaign-policy.ts | 9 +- .../src/workspaces/workspace-data-export.ts | 73 +- .../interface/src/http/campaign-handler.ts | 9 +- .../src/http/channel-connection-handler.ts | 23 +- .../src/http/product-research-handler.ts | 5 +- .../src/http/prospect-memory-handler.ts | 171 ++++ .../src/http/workspace-data-handler.ts | 9 +- scripts/benchmark-local-capacity.ts | 171 +++- scripts/evaluate-prospect-memory-operator.ts | 60 ++ ...evaluate-prospect-memory-setter-quality.ts | 101 +++ scripts/evaluate-prospect-memory-shadow.ts | 82 ++ scripts/prepare-prospect-memory-benchmark.ts | 239 ++++++ scripts/start-development.ts | 32 +- scripts/verify-architecture.ts | 39 + .../verify-prospect-memory-backup-restore.ts | 159 ++++ .../verify-prospect-memory-purge-restored.ts | 187 +++++ tests/e2e/noosphere-axis.spec.ts | 5 +- tests/http/channel-connection-http.test.ts | 33 +- tests/http/conversation-command-http.test.ts | 96 +++ tests/http/product-research-http.test.ts | 60 +- tests/http/prospect-memory-http.test.ts | 173 ++++ tests/http/workspace-data-http.test.ts | 8 +- .../content-autopilot-repair.test.ts | 44 + .../conversation-command-dry-run.test.ts | 314 +++++++ .../durable-prospect-decisions.test.ts | 23 +- tests/integration/merges.test.ts | 47 +- tests/integration/operator-console.test.ts | 70 +- tests/integration/prospect-memory.test.ts | 475 +++++++++++ .../workspace-data-lifecycle.test.ts | 183 +++- tests/unit/calendar-signing-key.test.ts | 27 + tests/unit/channel-assessment-runner.test.ts | 25 + tests/unit/codex-cli-model-gateway.test.ts | 62 ++ tests/unit/development-launcher.test.ts | 34 +- tests/unit/inbound-reply-memory-audit.test.ts | 105 +++ .../unit/interactive-worker-topology.test.ts | 59 ++ tests/unit/kimi-model-gateway.test.ts | 26 +- ...ngchain-campaign-content-generator.test.ts | 124 +++ ...gchain-prospect-memory-synthesizer.test.ts | 84 ++ tests/unit/model-router.test.ts | 36 + tests/unit/operator-console.test.ts | 9 +- tests/unit/product-reading-page-state.test.ts | 26 + tests/unit/product-research-domain.test.ts | 13 +- tests/unit/product-research-openapi.test.ts | 25 + .../prospect-decision-memory-audit.test.ts | 72 ++ ...rospect-memory-operator-evaluation.test.ts | 38 + tests/unit/prospect-memory-projection.test.ts | 782 ++++++++++++++++++ ...t-memory-setter-quality-evaluation.test.ts | 43 + tests/unit/prospect-memory-settings.test.ts | 111 +++ .../prospect-memory-shadow-comparator.test.ts | 108 +++ .../prospect-memory-shadow-evaluation.test.ts | 63 ++ tests/unit/prospect-memory-worker.test.ts | 126 +++ tests/unit/prospect-memory.test.ts | 153 ++++ tests/unit/research-agent-provider.test.ts | 157 +++- tests/unit/research-ai-tools.test.ts | 37 + tests/unit/research-orchestrator.test.ts | 63 +- tests/unit/research-progress-state.test.ts | 43 + tests/unit/research-worker.test.ts | 113 +++ .../unipile-social-content-reader.test.ts | 32 + tests/unit/v3-sourcing-validator.test.ts | 25 + tests/unit/workspace-data-policy.test.ts | 2 +- 183 files changed, 16453 insertions(+), 487 deletions(-) create mode 100644 apps/web/app/w/[workspaceSlug]/research/[runId]/research-progress-state.ts create mode 100644 apps/web/app/w/[workspaceSlug]/strategy/product-reading/error.tsx create mode 100644 apps/web/app/w/[workspaceSlug]/strategy/product-reading/product-reading-state.ts create mode 100644 apps/web/components/prospect-memory-panel.tsx create mode 100644 apps/web/components/prospect-memory-refresh-watcher.tsx create mode 100644 docs/architecture/2026-08-23-prospect-360-memory-context-engineering.md create mode 100644 docs/architecture/2026-08-23-prospect-360-memory-implementation-plan.md create mode 100644 docs/performance/2026-08-23-prospect-360-memory-capacity-protocol.md create mode 100644 docs/performance/2026-08-23-prospect-360-memory-validation-report.md create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-backup-restore-local.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-benchmark-fixture-local.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-capacity-local-memory-focused-clean.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-capacity-local-memory-focused.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-capacity-local-warm-optimized-unintrusive.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-capacity-local-warm-optimized.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-capacity-local-warm.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-purge-restored-local.json create mode 100644 docs/performance/evidence/2026-08-23-prospect-memory-shadow-local.json create mode 100644 docs/performance/prospect-memory-operator-responses.example.json create mode 100644 docs/performance/prospect-memory-setter-quality-labels.example.json create mode 100644 packages/application/src/prospect-memory/prospect-context-assembler.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-operations.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-operator-evaluation.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-projector.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-setter-quality-evaluation.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-shadow-comparator.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory-shadow-evaluation.ts create mode 100644 packages/application/src/prospect-memory/prospect-memory.ts create mode 100644 packages/application/src/prospect-memory/refresh-prospect-memory.ts create mode 100644 packages/domain/src/prospect-memory/prospect-memory.ts create mode 100644 packages/infrastructure/migrations/0085_operator_requeue_aggregate_recovery.sql create mode 100644 packages/infrastructure/migrations/0086_recover_codex_tls_assessments.sql create mode 100644 packages/infrastructure/migrations/0087_resume_incomplete_icp_budget_runs.sql create mode 100644 packages/infrastructure/migrations/0088_retry_invalid_market_outputs.sql create mode 100644 packages/infrastructure/migrations/0089_prospect_360_memory_foundation.sql create mode 100644 packages/infrastructure/migrations/0090_prospect_memory_retention.sql create mode 100644 packages/infrastructure/migrations/0091_conversation_command_dry_run.sql create mode 100644 packages/infrastructure/migrations/0092_conversation_command_generation_audit.sql create mode 100644 packages/infrastructure/src/calendar/calendar-signing-key.ts create mode 100644 packages/infrastructure/src/prospect-memory/capture-prospect-decision-mutation.ts create mode 100644 packages/infrastructure/src/prospect-memory/capture-prospect-memory-mutation.ts create mode 100644 packages/infrastructure/src/prospect-memory/langchain-prospect-memory-synthesizer.ts create mode 100644 packages/infrastructure/src/prospect-memory/postgres-prospect-memory-operations-reader.ts create mode 100644 packages/infrastructure/src/prospect-memory/postgres-prospect-memory-repository.ts create mode 100644 packages/infrastructure/src/prospect-memory/postgres-prospect-memory-state-reader.ts create mode 100644 packages/infrastructure/src/prospect-memory/prospect-memory-backfill.ts create mode 100644 packages/infrastructure/src/prospect-memory/prospect-memory-refresh-job-processor.ts create mode 100644 packages/interface/src/http/prospect-memory-handler.ts create mode 100644 scripts/evaluate-prospect-memory-operator.ts create mode 100644 scripts/evaluate-prospect-memory-setter-quality.ts create mode 100644 scripts/evaluate-prospect-memory-shadow.ts create mode 100644 scripts/prepare-prospect-memory-benchmark.ts create mode 100644 scripts/verify-prospect-memory-backup-restore.ts create mode 100644 scripts/verify-prospect-memory-purge-restored.ts create mode 100644 tests/http/conversation-command-http.test.ts create mode 100644 tests/http/prospect-memory-http.test.ts create mode 100644 tests/integration/conversation-command-dry-run.test.ts create mode 100644 tests/integration/prospect-memory.test.ts create mode 100644 tests/unit/calendar-signing-key.test.ts create mode 100644 tests/unit/channel-assessment-runner.test.ts create mode 100644 tests/unit/inbound-reply-memory-audit.test.ts create mode 100644 tests/unit/interactive-worker-topology.test.ts create mode 100644 tests/unit/langchain-prospect-memory-synthesizer.test.ts create mode 100644 tests/unit/product-reading-page-state.test.ts create mode 100644 tests/unit/prospect-decision-memory-audit.test.ts create mode 100644 tests/unit/prospect-memory-operator-evaluation.test.ts create mode 100644 tests/unit/prospect-memory-projection.test.ts create mode 100644 tests/unit/prospect-memory-setter-quality-evaluation.test.ts create mode 100644 tests/unit/prospect-memory-settings.test.ts create mode 100644 tests/unit/prospect-memory-shadow-comparator.test.ts create mode 100644 tests/unit/prospect-memory-shadow-evaluation.test.ts create mode 100644 tests/unit/prospect-memory-worker.test.ts create mode 100644 tests/unit/prospect-memory.test.ts create mode 100644 tests/unit/research-progress-state.test.ts diff --git a/.env.example b/.env.example index b3f6094..4aa4222 100644 --- a/.env.example +++ b/.env.example @@ -3,16 +3,18 @@ OPENAI_API_KEY= OPENAI_RESEARCH_MODEL= OPENAI_SYNTHESIS_MODEL= OPENAI_EMBEDDING_MODEL=text-embedding-3-small -AI_PROVIDER=kimi-code +AI_PROVIDER=codex-cli KIMI_CODE_API_KEY= KIMI_CODE_BASE_URL=https://api.kimi.com/coding/v1 KIMI_RESEARCH_MODELS=k3,k3-256k PROSPECT_DECISION_MODEL=k3 KIMI_SYNTHESIS_MODELS=k3-256k,k3 +KIMI_FALLBACK_MODELS=kimi-for-coding-highspeed # Use AI_PROVIDER=codex-cli for a Codex-only deployment. Authentication is # stored in the private codex-service-home Docker volume. CODEX_DEFAULT_MODEL=gpt-5.6-luna CODEX_DEFAULT_REASONING_EFFORT=xhigh +CODEX_FALLBACK_MODELS=gpt-5.4-mini CODEX_BINARY_PATH=codex SEARXNG_URL=http://searxng:8080 SEARXNG_SECRET=replace-with-a-random-internal-secret @@ -58,5 +60,6 @@ WORKER_ID=research-worker-1 DAILY_PROSPECTING_TIME=06:00 DAILY_PROSPECTING_TIMEZONE=Europe/Paris JOB_LEASE_MS=60000 +JOB_HEARTBEAT_MS=20000 JOB_BATCH_SIZE=4 JOB_POLL_INTERVAL_MS=1000 diff --git a/Dockerfile.backend b/Dockerfile.backend index 6475a0a..471394b 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -23,7 +23,7 @@ WORKDIR /app ENV NODE_ENV=production RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core nodejs poppler-utils \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg fonts-dejavu-core nodejs poppler-utils \ && rm -rf /var/lib/apt/lists/* COPY --from=codex-cli /usr/local/lib/node_modules/@openai /usr/local/lib/node_modules/@openai diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0d1bae2..065d9a0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { CrawlerProspectEnricher } from "@outbound/infrastructure/crm/crawler-pr 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 { resolveCalendarSigningKey } from "@outbound/infrastructure/calendar/calendar-signing-key"; import { createCalendarConnectionHttpHandler } from "@outbound/interface/http/calendar-connection-handler"; import { createCalendarWebhookHttpHandler } from "@outbound/interface/http/calendar-webhook-handler"; import { createCalendarBookingHttpHandler } from "@outbound/interface/http/calendar-booking-handler"; @@ -103,6 +104,21 @@ import { KimiModelCatalog } from "@outbound/infrastructure/ai/kimi-model-gateway import { CodexModelCatalog } from "@outbound/infrastructure/ai/codex-cli-model-gateway"; import { createModelCatalogHttpHandler } from "@outbound/interface/http/model-catalog-handler"; import { createWorkspaceStructuredModelFromEnvironment } from "@outbound/infrastructure/ai/model-runtime-from-environment"; +import { ProspectMemoryOperationsApplication } from "@outbound/application/prospect-memory/prospect-memory-operations"; +import { DefaultProspectContextAssembler } from "@outbound/application/prospect-memory/prospect-context-assembler"; +import { + PostgresContextReceiptRecorder, + PostgresProspectMemoryEventRepository, + PostgresProspectMemoryPolicyReader, + PostgresProspectMemorySnapshotRepository, +} from "@outbound/infrastructure/prospect-memory/postgres-prospect-memory-repository"; +import { + PostgresProspectMemoryAuthoritativeStateReader, + PostgresProspectMemorySourceMaterialReader, +} from "@outbound/infrastructure/prospect-memory/postgres-prospect-memory-state-reader"; +import { PostgresProspectMemoryOperationsReader } from "@outbound/infrastructure/prospect-memory/postgres-prospect-memory-operations-reader"; +import { Sha256ContentHasher } from "@outbound/infrastructure/shared/sha256-content-hasher"; +import { createProspectMemoryHttpHandler, isProspectMemoryRoute } from "@outbound/interface/http/prospect-memory-handler"; const databaseUrl = requiredEnvironment("DATABASE_URL"); const database = createDatabase(databaseUrl); @@ -197,6 +213,36 @@ const documents = createResearchDocumentHttpHandler({ service: documentService, contextResolver: auth.contextResolver, }); +const prospectMemoryEvents = new PostgresProspectMemoryEventRepository(database.client); +const prospectMemorySnapshots = new PostgresProspectMemorySnapshotRepository(database.client); +const prospectMemoryAuthoritativeState = new PostgresProspectMemoryAuthoritativeStateReader(database.db); +const prospectMemoryPolicies = new PostgresProspectMemoryPolicyReader(database.client); +const prospectMemoryOperationsReader = new PostgresProspectMemoryOperationsReader(database.client); +const prospectMemoryHasher = new Sha256ContentHasher(); +const prospectMemoryAssembler = new DefaultProspectContextAssembler( + prospectMemoryEvents, + prospectMemorySnapshots, + prospectMemoryAuthoritativeState, + new PostgresProspectMemorySourceMaterialReader(database.db, prospectMemoryHasher), + prospectMemoryPolicies, + new PostgresContextReceiptRecorder(database.client), + ids, + prospectMemoryHasher, +); +const prospectMemory = createProspectMemoryHttpHandler({ + contextResolver: auth.contextResolver, + application: new ProspectMemoryOperationsApplication( + prospectMemoryEvents, + prospectMemorySnapshots, + prospectMemoryAuthoritativeState, + prospectMemoryPolicies, + prospectMemoryOperationsReader, + prospectMemoryAssembler, + queue, + ids, + clock, + ), +}); const crm = createCrmHttpHandler({ database: database.db, contextResolver: auth.contextResolver, @@ -283,6 +329,8 @@ const campaignHandler = createCampaignHttpHandler({ undefined, contentBrandKitRepository, workspaceStructuredModel, + prospectMemoryAssembler, + prospectMemoryPolicies, ), }); const messagingStrategyHandler = createMessagingStrategyHttpHandler({ @@ -303,8 +351,7 @@ const connectedAccounts = createConnectedAccountHttpHandler({ webhookSecret: process.env.UNIPILE_WEBHOOK_SECRET ?? "", publicAppBaseUrl: requiredEnvironment("BETTER_AUTH_URL"), }); -const calendarSigningKey = process.env.CALENDAR_WEBHOOK_SIGNING_KEY - ?? requiredSecretEnvironment("BETTER_AUTH_SECRET"); +const calendarSigningKey = resolveCalendarSigningKey(process.env); const calendarIntegration = new PostgresCalendarIntegration(database.db, calendarSigningKey); const calendarConnection = createCalendarConnectionHttpHandler({ integration: calendarIntegration, @@ -463,6 +510,7 @@ const server = Bun.serve({ if (pathname === "/api/v1/workspaces" || pathname.startsWith("/api/v1/workspaces/") || pathname.startsWith("/api/v1/invitations/")) return workspace(request); if (pathname === "/api/v1/workspace-ai-settings") return workspaceAiSettings(request); if (pathname === "/api/v1/ai/models") return modelCatalog(request); + if (isProspectMemoryRoute(pathname)) return prospectMemory(request); if (pathname.startsWith("/api/v1/messaging-strategies") || pathname.startsWith("/api/v1/ai-policies")) { return messagingStrategyHandler(request); } diff --git a/apps/web/app/w/[workspaceSlug]/appointments/page.tsx b/apps/web/app/w/[workspaceSlug]/appointments/page.tsx index 44710fa..7e524f2 100644 --- a/apps/web/app/w/[workspaceSlug]/appointments/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/appointments/page.tsx @@ -2,7 +2,17 @@ import { CalendarCheck2, CalendarClock, ExternalLink, Settings2, UserRound, Vide import Link from "next/link"; import { BookingSourceAttribution } from "@/components/booking-source-attribution"; import { CrmPermissionState } from "@/components/crm-states"; -import { getCalendarConnection, listCalendarBookings, OutboundApiError, type CalendarBooking } from "@/lib/api"; +import { ProspectMemoryPanel } from "@/components/prospect-memory-panel"; +import { + getCalendarConnection, + getProspectMemoryStatus, + getProspectMemoryView, + listCalendarBookings, + OutboundApiError, + type CalendarBooking, + type ProspectMemoryStatus, + type ProspectMemoryView, +} from "@/lib/api"; export const metadata = { title: "Appels" }; export const dynamic = "force-dynamic"; @@ -40,6 +50,18 @@ export default async function AppointmentsPage({ const byView = view === "upcoming" ? upcoming : view === "past" ? past : [...upcoming, ...past]; const visible = byView.filter((booking) => (source === "all" || booking.source === source) && (!query.booking || booking.id === query.booking)); const next = upcoming.find((booking) => (source === "all" || booking.source === source) && (!query.booking || booking.id === query.booking)) ?? null; + let callMemory: { readonly status: ProspectMemoryStatus; readonly view: ProspectMemoryView | null } | null = null; + if (next?.contactId) { + try { + const status = await getProspectMemoryStatus(workspaceSlug, next.contactId); + const memoryView = status.enabled + ? await getProspectMemoryView(workspaceSlug, next.contactId, "call_preparation").catch(() => null) + : null; + callMemory = { status, view: memoryView }; + } catch { + // Calls remain usable when the optional memory projection is unavailable. + } + } return ( <> @@ -75,6 +97,7 @@ export default async function AppointmentsPage({
{next.meetingUrl ? : null} + {callMemory ?
: null}
) : null} diff --git a/apps/web/app/w/[workspaceSlug]/content/calendar/page.tsx b/apps/web/app/w/[workspaceSlug]/content/calendar/page.tsx index 8d627aa..9064eac 100644 --- a/apps/web/app/w/[workspaceSlug]/content/calendar/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/content/calendar/page.tsx @@ -40,7 +40,7 @@ export default async function ContentCalendarPage({ params, searchParams }: { pa function PublicationRow({ publication, workspaceSlug }: { readonly publication: ContentPublication; readonly workspaceSlug: string }) { const editable = publication.status === "scheduled" || publication.status === "retry"; - return
{statusLabel(publication.status)}{publication.accountSnapshot.displayName}

{publication.contentSnapshot.body}

Prévue {formatDate(publication.scheduledFor)}Tentatives {publication.attempts}/{publication.maxAttempts}{publication.contentSnapshot.contentHash.slice(0, 10)}
{publication.reconciliation ? : publication.lastErrorCode ?
{publication.lastErrorCode}{publication.lastErrorMessage ? ` · ${publication.lastErrorMessage}` : ""}
: null}{editable ? : null}
{publication.providerUrl ? Voir le post : null}
; + return
{statusLabel(publication.status)}{publication.accountSnapshot.displayName}

{publication.contentSnapshot.body}

Prévue {formatDate(publication.scheduledFor)}Tentatives {publication.attempts}/{publication.maxAttempts}{publication.contentSnapshot.contentHash.slice(0, 10)}
{publication.reconciliation ? : publication.lastErrorCode ?
{publication.lastErrorCode}{publication.lastErrorMessage ? ` · ${publication.lastErrorMessage}` : ""}
: null}{editable ? : null}
{publication.providerUrl ? Voir le post : null}
; } function ReconciliationStatus({ publication }: { readonly publication: ContentPublication }) { @@ -53,7 +53,7 @@ function ReconciliationStatus({ publication }: { readonly publication: ContentPu } function ObservedPostRow({ item }: { readonly item: SocialContentItem }) { - return
{item.origin === "internal" ? <> Noosphere : "Externe"}{item.publishedAt ? formatDate(item.publishedAt) : "Date LinkedIn indisponible"}

{item.text}

} label="Impressions" value={item.impressions} />} label="Réactions" value={item.reactions} />} label="Commentaires" value={item.comments} />} label="Reposts" value={item.reposts} />
{item.metricsObservedAt ?

Métriques actualisées {relativeDate(item.metricsObservedAt)}

:

Métriques encore indisponibles

}
{item.url ? Voir sur LinkedIn : null}
; + return
{item.origin === "internal" ? <> Noosphere : "Externe"}{item.publishedAt ? formatDate(item.publishedAt) : "Date LinkedIn indisponible"}

{item.text}

} label="Impressions" value={item.impressions} />} label="Réactions" value={item.reactions} />} label="Commentaires" value={item.comments} />} label="Reposts" value={item.reposts} />
{item.metricsObservedAt ?

Métriques actualisées {relativeDate(item.metricsObservedAt)}

:

Métriques encore indisponibles

}
{item.url ? Voir sur LinkedIn : null}
; } function SyncStatusCard({ sync }: { readonly sync: SocialContentSyncStatus }) { diff --git a/apps/web/app/w/[workspaceSlug]/inbox/page.tsx b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx index 6e17606..0721ac5 100644 --- a/apps/web/app/w/[workspaceSlug]/inbox/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/inbox/page.tsx @@ -2,8 +2,12 @@ import { AlertTriangle, AtSign, Inbox, LoaderCircle, Mail, MessageCircle, Search import Link from "next/link"; import { WorkspaceConversationDrawer } from "@/components/workspace-conversation-drawer"; import { + getProspectMemoryStatus, + getProspectMemoryView, getWorkspaceConversation, listWorkspaceConversations, + type ProspectMemoryStatus, + type ProspectMemoryView, type WorkspaceConversationView, } from "@/lib/api"; @@ -50,6 +54,16 @@ export default async function InboxPage({ const selectedId = query.conversation ?? (query.prospect ? result.data.find((item) => item.contactId === query.prospect)?.id : undefined); const selected = selectedId ? await getWorkspaceConversation(workspaceSlug, selectedId) : null; + let memoryStatus: ProspectMemoryStatus | null = null; + let memoryView: ProspectMemoryView | null = null; + if (selected) { + try { memoryStatus = await getProspectMemoryStatus(workspaceSlug, selected.contactId); } + catch { memoryStatus = null; } + if (memoryStatus?.enabled) { + try { memoryView = await getProspectMemoryView(workspaceSlug, selected.contactId, "call_preparation"); } + catch { memoryView = null; } + } + } const closeHref = inboxHref(workspaceSlug, query, { conversation: null, prospect: null }); const unread = result.data.reduce((total, item) => total + item.unreadCount, 0); const campaign = result.data.filter((item) => item.origin === "campaign").length; @@ -161,7 +175,15 @@ export default async function InboxPage({ ) : null} - {selected ? : null} + {selected ? ( + + ) : null} ); } diff --git a/apps/web/app/w/[workspaceSlug]/prospects/actions.ts b/apps/web/app/w/[workspaceSlug]/prospects/actions.ts index 7ca684d..fd3349e 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/prospects/actions.ts @@ -67,10 +67,17 @@ export async function sendProspectMessageAction( ) { const mode = String(formData.get("mode") ?? "manual"); if (mode !== "manual" && mode !== "setter") throw new Error("Mode d’envoi invalide."); + const executionMode = String(formData.get("executionMode") ?? "live"); + if (executionMode !== "live" && executionMode !== "dry_run") throw new Error("Mode d’exécution invalide."); + if (mode === "manual" && executionMode === "dry_run") throw new Error("Le dry-run est réservé au Setter."); + const idempotencyKey = String(formData.get("idempotencyKey") ?? "").trim(); + if (!idempotencyKey) throw new Error("La clé de reprise de la commande est obligatoire."); const body = String(formData.get("body") ?? "").trim(); if (mode === "manual" && !body) throw new Error("Le message est obligatoire."); await sendConversationCommand(workspaceSlug, conversationId, { mode, + executionMode, + idempotencyKey, ...(body ? { body } : {}), }); revalidatePath(`/w/${workspaceSlug}/prospects`); diff --git a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx index 05d8035..cd35178 100644 --- a/apps/web/app/w/[workspaceSlug]/prospects/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/prospects/page.tsx @@ -1,6 +1,17 @@ import { AtSign, Filter, Mail, MessageCircle, Search, UserRound } from "lucide-react"; import Link from "next/link"; -import { getProspectView, listProspectViews, listSignals, OutboundApiError, type IntentSignal, type SignalType } from "@/lib/api"; +import { + getProspectMemoryStatus, + getProspectMemoryView, + getProspectView, + listProspectViews, + listSignals, + OutboundApiError, + type IntentSignal, + type ProspectMemoryStatus, + type ProspectMemoryView, + type SignalType, +} from "@/lib/api"; import { ProspectActivityDrawer } from "@/components/prospect-activity-drawer"; export const metadata = { title: "Prospects" }; @@ -43,6 +54,16 @@ export default async function ProspectsPage({ const signalsByContact = new Map(); for (const signal of signals) signalsByContact.set(signal.entityId, [...(signalsByContact.get(signal.entityId) ?? []), signal]); const selected = query.prospect ? await getProspectView(workspaceSlug, query.prospect) : null; + let memoryStatus: ProspectMemoryStatus | null = null; + let memoryView: ProspectMemoryView | null = null; + if (selected) { + try { memoryStatus = await getProspectMemoryStatus(workspaceSlug, selected.id); } + catch (error) { if (!(error instanceof OutboundApiError)) throw error; } + if (memoryStatus?.enabled) { + try { memoryView = await getProspectMemoryView(workspaceSlug, selected.id, "call_preparation"); } + catch (error) { if (!(error instanceof OutboundApiError)) throw error; } + } + } const listHref = prospectListHref(workspaceSlug, query); return ( @@ -156,7 +177,15 @@ export default async function ProspectsPage({ )} - {selected ? : null} + {selected ? ( + + ) : null} ); diff --git a/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx b/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx index 9fcfd60..9fd3e96 100644 --- a/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/research/[runId]/page.tsx @@ -14,6 +14,7 @@ import { notFound } from "next/navigation"; import { getResearchRun, OutboundApiError } from "@/lib/api"; import { pauseResearch, resumeResearch, startResearch } from "./actions"; import { ProgressRefresh } from "./progress-refresh"; +import { canResumeIncompleteResearch, isResearchReportReady } from "./research-progress-state"; const stageLabels: Record = { product_analysis: "Comprendre le produit", @@ -23,6 +24,15 @@ const stageLabels: Record = { segment_synthesis: "Identifier les segments", icp_synthesis: "Synthétiser les ICP", evidence_review: "Auditer les preuves", + product_truth: "Établir la vérité produit", + problem_mapping: "Cartographier les problèmes", + organization_discovery: "Découvrir les organisations acheteuses", + market_investigation: "Vérifier le marché", + buying_context: "Comprendre le contexte d’achat", + sourcing_validation: "Valider la prospectabilité", + icp_composition: "Composer les ICP", + adversarial_review: "Contester les conclusions", + objective_ranking: "Classer les ICP", }; const statusLabels: Record = { @@ -58,6 +68,8 @@ export default async function ResearchProgressPage({ 100, ); const isActive = run.status === "queued" || run.status === "running"; + const reportReady = isResearchReportReady(run); + const resumableIncomplete = canResumeIncompleteResearch(run); const failedStage = run.stages.find((stage) => stage.stage === run.activeStage) ?? [...run.stages].reverse().find((stage) => stage.lastErrorCode); @@ -82,8 +94,10 @@ export default async function ResearchProgressPage({

{run.status === "failed" ? "Étude ICP interrompue" - : run.status === "partial" + : run.status === "partial" && reportReady ? "Rapport ICP partiel disponible" + : resumableIncomplete + ? "Étude ICP à reprendre" : ["completed", "ready_for_review"].includes(run.status) ? "Étude ICP terminée" : "Étude ICP en cours"} @@ -107,11 +121,11 @@ export default async function ResearchProgressPage({ Reprendre - ) : run.status === "failed" ? ( + ) : resumableIncomplete ? (
) : isActive ? ( @@ -199,7 +213,7 @@ export default async function ResearchProgressPage({
- {run.status === "failed" ? ( + {resumableIncomplete ? (

- {quotaExhausted ? "Quota Kimi épuisé" : "La recherche est interrompue"} + {quotaExhausted ? "Quota Kimi épuisé" : "La recherche doit reprendre"}

{quotaExhausted @@ -274,7 +288,7 @@ export default async function ResearchProgressPage({ vous pourrez relancer une nouvelle étude.

- {["ready_for_review", "completed", "partial"].includes(run.status) ? ( + {reportReady ? ( Ouvrir le rapport diff --git a/apps/web/app/w/[workspaceSlug]/research/[runId]/research-progress-state.ts b/apps/web/app/w/[workspaceSlug]/research/[runId]/research-progress-state.ts new file mode 100644 index 0000000..f164a0e --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/research/[runId]/research-progress-state.ts @@ -0,0 +1,16 @@ +import type { ResearchRun } from "../../../../../lib/api"; + +type ResearchProgress = Pick; + +export function isResearchReportReady(run: ResearchProgress): boolean { + if (!["ready_for_review", "completed", "partial"].includes(run.status)) return false; + if (run.brief.researchVersion === 3) { + return run.completedStages.includes("objective_ranking"); + } + return run.completedStages.includes("evidence_review"); +} + +export function canResumeIncompleteResearch(run: ResearchProgress): boolean { + if (run.status === "failed" || run.status === "interrupted") return true; + return run.status === "partial" && !isResearchReportReady(run); +} diff --git a/apps/web/app/w/[workspaceSlug]/settings/actions.ts b/apps/web/app/w/[workspaceSlug]/settings/actions.ts index ceba36f..1b8d0d2 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/settings/actions.ts @@ -39,6 +39,9 @@ export async function updateRetentionAction(workspaceSlug: string, workspaceId: invitationsDays: Number(formData.get("invitationsDays")), jobsDays: Number(formData.get("jobsDays")), auditDays: Number(formData.get("auditDays")), + memoryEventsDays: Number(formData.get("memoryEventsDays")), + memorySnapshotsDays: Number(formData.get("memorySnapshotsDays")), + memoryReceiptsDays: Number(formData.get("memoryReceiptsDays")), }, String(formData.get("confirmation") ?? ""))); } diff --git a/apps/web/app/w/[workspaceSlug]/settings/channels/actions.ts b/apps/web/app/w/[workspaceSlug]/settings/channels/actions.ts index 6f0635e..cc97aa0 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/channels/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/settings/channels/actions.ts @@ -1,14 +1,18 @@ "use server"; import { revalidatePath } from "next/cache"; -import { selectWhatsAppChannelAccount } from "@/lib/api"; +import { + selectChannelAccount, + type ChannelConnectionChannel, +} from "@/lib/api"; -export async function saveWhatsAppAccount( +export async function saveChannelAccount( workspaceSlug: string, + channel: ChannelConnectionChannel, formData: FormData, ): Promise { const providerAccountId = String(formData.get("providerAccountId") ?? "").trim(); - if (!providerAccountId) throw new Error("Sélectionnez un compte WhatsApp"); - await selectWhatsAppChannelAccount(workspaceSlug, providerAccountId); + if (!providerAccountId) throw new Error("Sélectionnez un compte"); + await selectChannelAccount(workspaceSlug, channel, providerAccountId); revalidatePath(`/w/${workspaceSlug}/settings/channels`); } diff --git a/apps/web/app/w/[workspaceSlug]/settings/channels/page.tsx b/apps/web/app/w/[workspaceSlug]/settings/channels/page.tsx index 0212385..dbe324a 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/channels/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/settings/channels/page.tsx @@ -1,16 +1,35 @@ -import { CheckCircle2, MessageCircle, Radio, ShieldCheck, Smartphone } from "lucide-react"; +import Link from "next/link"; +import { + AtSign, + CheckCircle2, + ExternalLink, + Mail, + MessageCircle, + Radio, + ShieldCheck, + TriangleAlert, +} from "lucide-react"; import { notFound } from "next/navigation"; import { - getWhatsAppChannelConnection, + getChannelConnection, listWorkspaces, OutboundApiError, - type WhatsAppChannelConnection, + type ChannelConnection, + type ChannelConnectionChannel, } from "@/lib/api"; -import { saveWhatsAppAccount } from "./actions"; +import { saveChannelAccount } from "./actions"; export const metadata = { title: "Canaux" }; export const dynamic = "force-dynamic"; +const CHANNELS: readonly ChannelConnectionChannel[] = ["linkedin", "email", "whatsapp"]; + +interface ChannelState { + readonly connection: ChannelConnection; + readonly providerConfigured: boolean; + readonly temporarilyUnavailable: boolean; +} + export default async function ChannelSettingsPage({ params, }: { @@ -19,110 +38,183 @@ export default async function ChannelSettingsPage({ const { workspaceSlug } = await params; const workspace = (await listWorkspaces()).find((item) => item.slug === workspaceSlug); if (!workspace || !["admin", "owner"].includes(workspace.role)) notFound(); - const { connection, providerConfigured } = await loadWhatsAppConnection(workspaceSlug); - const save = saveWhatsAppAccount.bind(null, workspaceSlug); + const states = await Promise.all(CHANNELS.map((channel) => loadChannelConnection(workspaceSlug, channel))); + const readyCount = states.filter(({ connection }) => selectedHealthyAccount(connection)).length; + const providerConfigured = states.some((state) => state.providerConfigured); return ( -
+
-
Unipile WhatsApp
-

Compte d’envoi WhatsApp

+
Comptes d’envoi
+

Canaux connectés

- Choisissez le numéro utilisé par ce workspace pour vérifier les prospects, envoyer les campagnes et poursuivre les conversations. + Vérifiez en un coup d’œil les comptes utilisés par Noosphere. Les clés et sessions restent côté serveur.

- - {connection.selectedAccountId ? "Prêt à prospecter" : !providerConfigured ? "À configurer" : connection.connected ? "Compte à sélectionner" : "À reconnecter"} + + {readyCount}/{CHANNELS.length} canaux prêts
{!providerConfigured ? (
Unipile n’est pas configuré sur ce serveur. -

Ajoutez la connexion serveur depuis l’administration pour rendre les comptes LinkedIn et WhatsApp disponibles ici.

+

Ajoutez la connexion serveur depuis l’administration pour rendre LinkedIn, l’email et WhatsApp disponibles.

) : null} -
-
- -
-

Numéros WhatsApp disponibles

-

Seuls les comptes Unipile actuellement connectés peuvent être utilisés.

-
-
+
+ {states.map((state) => ( + + ))} +
-
- {connection.accounts.length ? connection.accounts.map((account) => ( - - )) : ( -
- Aucun compte WhatsApp n’est connecté à Unipile. -
- )} -
+
+ +

+ Noosphere utilise uniquement le compte sélectionné pour chaque canal. Une panne de synchronisation de contenu n’est jamais présentée comme une déconnexion du compte. +

+
+
+ ); +} + +function ChannelCard({ + state, + workspaceSlug, +}: { + readonly state: ChannelState; + readonly workspaceSlug: string; +}) { + const { connection } = state; + const selected = selectedHealthyAccount(connection); + const save = saveChannelAccount.bind(null, workspaceSlug, connection.channel); + const copy = channelCopy(connection.channel); + const Icon = copy.icon; + const status = selected + ? { label: "Opérationnel", className: "badge badge-success" } + : state.temporarilyUnavailable + ? { label: "Vérification indisponible", className: "badge badge-warning" } + : connection.connected + ? { label: "Compte à sélectionner", className: "badge badge-warning" } + : { label: "À connecter", className: "badge badge-warning" }; -
-

La clé Unipile reste côté serveur. Confirmer le numéro relance automatiquement l’évaluation WhatsApp des ICP sans importer les conversations personnelles.

- + + ) : ( +
+

Aucun compte {copy.label} connecté.

+ + Connecter {copy.label} +
- - - {connection.selectedDisplayName ? ( -
-
- -

Canal WhatsApp opérationnel

Les prochaines campagnes utiliseront {connection.selectedDisplayName}.

-
-
+ )} + + {selected ? ( +

+ {selected.name} est utilisé par ce workspace +

) : null} -
+ ); } -async function loadWhatsAppConnection(workspaceSlug: string): Promise<{ - connection: WhatsAppChannelConnection; - providerConfigured: boolean; -}> { +function selectedHealthyAccount(connection: ChannelConnection) { + return connection.accounts.find((account) => account.selected && account.healthy) ?? null; +} + +function channelCopy(channel: ChannelConnectionChannel) { + if (channel === "linkedin") return { + label: "LinkedIn", + description: "Prospection, publications et conversations LinkedIn.", + icon: AtSign, + iconClassName: "bg-sky-100 text-sky-700", + selectedClassName: "cursor-pointer border-sky-400 bg-sky-50", + }; + if (channel === "email") return { + label: "Email", + description: "Séquences, relances et réponses depuis votre boîte professionnelle.", + icon: Mail, + iconClassName: "bg-violet-100 text-violet-700", + selectedClassName: "cursor-pointer border-violet-400 bg-violet-50", + }; + return { + label: "WhatsApp", + description: "Qualification et conversations WhatsApp professionnelles.", + icon: MessageCircle, + iconClassName: "bg-emerald-100 text-emerald-700", + selectedClassName: "cursor-pointer border-emerald-400 bg-emerald-50", + }; +} + +async function loadChannelConnection( + workspaceSlug: string, + channel: ChannelConnectionChannel, +): Promise { try { return { - connection: await getWhatsAppChannelConnection(workspaceSlug), + connection: await getChannelConnection(workspaceSlug, channel), providerConfigured: true, + temporarilyUnavailable: false, }; } catch (error) { - if (!(error instanceof OutboundApiError) || error.code !== "UNIPILE_NOT_CONFIGURED") throw error; + if (!(error instanceof OutboundApiError)) throw error; return { - connection: { - channel: "whatsapp", - connected: false, - selectedAccountId: null, - selectedDisplayName: null, - accounts: [], - }, - providerConfigured: false, + connection: emptyConnection(channel), + providerConfigured: error.code !== "UNIPILE_NOT_CONFIGURED", + temporarilyUnavailable: error.code !== "UNIPILE_NOT_CONFIGURED", }; } } + +function emptyConnection(channel: ChannelConnectionChannel): ChannelConnection { + return { + channel, + connected: false, + selectedAccountId: null, + selectedDisplayName: null, + accounts: [], + }; +} diff --git a/apps/web/app/w/[workspaceSlug]/settings/console/actions.ts b/apps/web/app/w/[workspaceSlug]/settings/console/actions.ts index 218ad3d..8b20bf6 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/console/actions.ts +++ b/apps/web/app/w/[workspaceSlug]/settings/console/actions.ts @@ -12,5 +12,5 @@ export async function requeueJobAction(workspaceSlug: string, jobId: string) { } catch (cause) { error = cause instanceof OutboundApiError ? cause.code : "UPSTREAM_ERROR"; } - redirect(`/w/${workspaceSlug}/settings/console?${error ? `error=${encodeURIComponent(error)}` : `notice=${encodeURIComponent("Job remis en file.")}`}`); + redirect(`/w/${workspaceSlug}/settings/console?${error ? `error=${encodeURIComponent(error)}` : `notice=${encodeURIComponent("État métier réparé et traitement remis en file.")}`}`); } diff --git a/apps/web/app/w/[workspaceSlug]/settings/console/page.tsx b/apps/web/app/w/[workspaceSlug]/settings/console/page.tsx index 25d0f2f..7f856a3 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/console/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/settings/console/page.tsx @@ -1,5 +1,6 @@ import { Activity, AlertTriangle, CheckCircle2, GitBranch, RefreshCcw, ShieldAlert, Webhook } from "lucide-react"; import { notFound } from "next/navigation"; +import { consoleJobRecoveryDisposition } from "@outbound/domain/operations/operator-console"; import { getSession, listConsoleDeadLetters, listConsoleJobs, listConsoleRejectedWebhooks, listWorkspaces, traceConsoleCorrelation, type ConsoleJob, type CorrelationTrace } from "@/lib/api"; import { requeueJobAction } from "./actions"; @@ -28,7 +29,7 @@ export default async function OperatorConsolePage({ params, searchParams }: { pa {query.notice ?

{query.notice}

: null} {query.error ?

{errorMessage(query.error)}

: null} -
} label="Jobs en anomalie" value={jobs.length} tone={jobs.length ? "warning" : "success"} />} label="Dead letters" value={deadLetters.length} tone={deadLetters.length ? "danger" : "success"} />} label="Webhooks rejetés" value={rejectedWebhooks.length} tone={rejectedWebhooks.length ? "warning" : "success"} />
+
} label="Reprises automatiques" value={jobs.filter((job) => consoleJobRecoveryDisposition(job) === "automatic").length} tone="success" />} label="Interventions nécessaires" value={jobs.filter((job) => consoleJobRecoveryDisposition(job) === "manual").length} tone={jobs.some((job) => consoleJobRecoveryDisposition(job) === "manual") ? "danger" : "success"} />} label="Webhooks rejetés" value={rejectedWebhooks.length} tone={rejectedWebhooks.length ? "warning" : "success"} />

Filtres de diagnostic

Les résultats restent strictement limités au workspace courant.

@@ -42,12 +43,13 @@ export default async function OperatorConsolePage({ params, searchParams }: { pa function JobRow({ job, canRequeue, workspaceSlug }: { job: ConsoleJob; canRequeue: boolean; workspaceSlug: string }) { const requeue = requeueJobAction.bind(null, workspaceSlug, job.id); - const actionable = job.status === "retry" || job.status === "dead_lettered"; - return {formatDate(job.updatedAt)}{job.type}{job.id.slice(0, 8)}{statusLabel(job.status)}{job.attempts}/{job.maxAttempts}{job.lastErrorCode ?? "—"}{job.lastErrorMessage ?? ""}{job.correlationId}
Voir
{JSON.stringify(job.payloadPreview, null, 2)}
{canRequeue && actionable ?
: {canRequeue ? "Non relançable" : "Lecture seule"}}; + const recovery = consoleJobRecoveryDisposition(job); + return {formatDate(job.updatedAt)}{job.type}{job.id.slice(0, 8)}{statusLabel(job.status)}{job.attempts}/{job.maxAttempts}{job.lastErrorCode ?? "—"}{job.lastErrorMessage ?? ""}{job.correlationId}
Voir
{JSON.stringify(job.payloadPreview, null, 2)}
{canRequeue && recovery === "manual" ?
: {canRequeue ? recoveryLabel(recovery) : "Lecture seule"}}; } function Trace({ trace }: { trace: CorrelationTrace }) { const entries = [...trace.jobs.map((entry) => ({ id: entry.id, at: entry.updatedAt, kind: "Job", label: `${entry.type} · ${statusLabel(entry.status)}` })), ...trace.events.map((entry) => ({ id: entry.id, at: entry.createdAt, kind: "Event", label: entry.eventType })), ...trace.audit.map((entry) => ({ id: entry.id, at: entry.createdAt, kind: "Audit", label: entry.action }))].sort((a,b) => new Date(a.at).getTime() - new Date(b.at).getTime()); return

{trace.correlationId}

{entries.length ?
    {entries.map((entry) =>
  1. {entry.kind}{entry.label}

    {formatDate(entry.at)}

  2. )}
:

Aucune occurrence dans ce workspace.

}
; } function Metric({ icon, label, value, tone }: { icon: React.ReactNode; label: string; value: number; tone: "success" | "warning" | "danger" }) { return
{icon}
{value}{label}
; } function HealthyEmpty() { return
Aucun incident correspondant.
; } function formatDate(value: string) { return new Intl.DateTimeFormat("fr-FR", { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Paris" }).format(new Date(value)); } function statusLabel(status: string) { return ({ pending: "En attente", running: "En cours", retry: "Retry", completed: "Terminé", dead_lettered: "Dead letter" } as Record)[status] ?? status; } -function errorMessage(code: string) { return ({ CONSOLE_JOB_ALREADY_QUEUED: "Ce job a déjà été remis en file ou est en cours.", CONSOLE_JOB_COMPLETED: "Un job terminé ne peut pas être relancé.", CONSOLE_JOB_NOT_FOUND: "Ce job n’existe pas dans ce workspace." } as Record)[code] ?? "L’action n’a pas pu être appliquée."; } +function recoveryLabel(recovery: ReturnType) { return ({ automatic: "Reprise automatique", blocked: "Bloqué par sécurité", none: "Non relançable", manual: "Intervention requise" } as const)[recovery]; } +function errorMessage(code: string) { return ({ CONSOLE_JOB_ALREADY_QUEUED: "Ce job a déjà été remis en file ou est en cours.", CONSOLE_JOB_COMPLETED: "Un job terminé ne peut pas être relancé.", CONSOLE_JOB_NOT_FOUND: "Ce job n’existe pas dans ce workspace.", CONSOLE_JOB_RETRY_SCHEDULED: "Ce traitement possède déjà une reprise automatique planifiée.", CONSOLE_JOB_MANUAL_RECOVERY_BLOCKED: "La relance manuelle est bloquée car un effet fournisseur ne peut pas être exclu.", CONSOLE_JOB_RECOVERY_STATE_MISMATCH: "L’état métier associé a changé. Rechargez la console avant de réessayer." } as Record)[code] ?? "L’action n’a pas pu être appliquée."; } diff --git a/apps/web/app/w/[workspaceSlug]/settings/page.tsx b/apps/web/app/w/[workspaceSlug]/settings/page.tsx index ec10990..da0a879 100644 --- a/apps/web/app/w/[workspaceSlug]/settings/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/settings/page.tsx @@ -84,7 +84,7 @@ export default async function WorkspaceSettingsPage({ params, searchParams }: { : null} {canAdminister && policy ? <> -

Rétention

Une réduction planifie une purge asynchrone, idempotente et auditée.

+

Rétention

Une réduction planifie une purge asynchrone, idempotente et auditée.

Export des données

Archive isolée au workspace, sans secrets techniques, disponible 72 heures.

{dataExport ?
Export {exportStatus(dataExport.status)}

Demandé le {formatDate(dataExport.createdAt)}{dataExport.expiresAt ? ` · expire le ${formatDate(dataExport.expiresAt)}` : ""}

{dataExport.downloadUrl ? Télécharger : {dataExport.status === "failed" ? dataExport.failureCode ?? "Échec" : "Préparation…"}}
: exportExpired ?

Le lien de cet export a expiré. Générez une nouvelle archive.

: null}
diff --git a/apps/web/app/w/[workspaceSlug]/strategy/product-reading/error.tsx b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/error.tsx new file mode 100644 index 0000000..879340d --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/error.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { CircleAlert, RotateCcw } from "lucide-react"; + +export default function ProductReadingError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ + + +

La lecture produit n’a pas pu se charger

+

+ Votre workspace est bien enregistré. Réessayez sans perdre votre configuration. +

+ +
+ ); +} diff --git a/apps/web/app/w/[workspaceSlug]/strategy/product-reading/page.tsx b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/page.tsx index 977ad82..ea6d046 100644 --- a/apps/web/app/w/[workspaceSlug]/strategy/product-reading/page.tsx +++ b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/page.tsx @@ -1,7 +1,8 @@ -import { ArrowRight, Clock3, FileSearch, PauseCircle } from "lucide-react"; +import { ArrowRight, CircleAlert, Clock3, FileSearch, PauseCircle } from "lucide-react"; import Link from "next/link"; import { listResearchRuns, type ResearchRunSummary } from "@/lib/api"; import { BriefForm } from "./brief-form"; +import { loadProductReadingPageState } from "./product-reading-state"; export const metadata = { title: "Trouver mon ICP" }; @@ -11,7 +12,9 @@ export default async function ProductReadingPage({ params: Promise<{ workspaceSlug: string }>; }) { const { workspaceSlug } = await params; - const runs = await listResearchRuns(workspaceSlug); + const { runs, historyUnavailable } = await loadProductReadingPageState( + () => listResearchRuns(workspaceSlug), + ); const latestRun = runs[0] ?? null; const recoverableRun = runs.find((run) => ["draft", "queued", "running", "paused", "ready_for_review", "completed", "partial", "interrupted"].includes(run.status), @@ -47,6 +50,17 @@ export default async function ProductReadingPage({ prospecte automatiquement jusqu’aux rendez-vous.

+ {historyUnavailable ? ( +
+ +
+

L’historique ICP est temporairement indisponible

+

+ Le formulaire reste utilisable. Rechargez plus tard pour retrouver les analyses précédentes. +

+
+
+ ) : null} {recoverableRun ? ( ) : null} diff --git a/apps/web/app/w/[workspaceSlug]/strategy/product-reading/product-reading-state.ts b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/product-reading-state.ts new file mode 100644 index 0000000..1a81452 --- /dev/null +++ b/apps/web/app/w/[workspaceSlug]/strategy/product-reading/product-reading-state.ts @@ -0,0 +1,16 @@ +import type { ResearchRunSummary } from "../../../../../lib/api"; + +export interface ProductReadingPageState { + readonly runs: readonly ResearchRunSummary[]; + readonly historyUnavailable: boolean; +} + +export async function loadProductReadingPageState( + loadRuns: () => Promise, +): Promise { + try { + return { runs: await loadRuns(), historyUnavailable: false }; + } catch { + return { runs: [], historyUnavailable: true }; + } +} diff --git a/apps/web/components/conversation-composer.tsx b/apps/web/components/conversation-composer.tsx index 9191874..07e971c 100644 --- a/apps/web/components/conversation-composer.tsx +++ b/apps/web/components/conversation-composer.tsx @@ -1,25 +1,41 @@ "use client"; -import { type FormEvent, useState, useTransition } from "react"; -import { Bot, RotateCcw, Send, Sparkles } from "lucide-react"; +import { type FormEvent, useEffect, useState, useTransition } from "react"; +import { Bot, Eye, RotateCcw, Send, Sparkles } from "lucide-react"; import { useRouter } from "next/navigation"; import { improveProspectMessageAction } from "@/app/w/[workspaceSlug]/prospects/actions"; export function ConversationComposer({ workspaceSlug, conversationId, + commandStatus, + commandExecutionMode, + generatedBody, sendAction, }: { workspaceSlug: string; conversationId: string; + commandStatus: string | null; + commandExecutionMode: "live" | "dry_run" | null; + generatedBody: string | null; sendAction: (formData: FormData) => Promise; }) { const router = useRouter(); const [draft, setDraft] = useState(""); const [originalDraft, setOriginalDraft] = useState(null); const [feedback, setFeedback] = useState(null); + const [idempotencyKey, setIdempotencyKey] = useState(""); const [isImproving, startImprovement] = useTransition(); const [isSending, startSending] = useTransition(); + const commandInFlight = commandStatus === "scheduled" || commandStatus === "sending"; + + useEffect(() => setIdempotencyKey(crypto.randomUUID()), []); + + useEffect(() => { + if (!commandInFlight) return; + const poll = window.setInterval(() => router.refresh(), 2_000); + return () => window.clearInterval(poll); + }, [commandInFlight, router]); function improve() { const value = draft.trim(); @@ -43,19 +59,27 @@ export function ConversationComposer({ function submit(event: FormEvent) { event.preventDefault(); - if (isSending || isImproving) return; + if (isSending || isImproving || commandInFlight) return; const submitter = (event.nativeEvent as SubmitEvent).submitter; if (!(submitter instanceof HTMLButtonElement)) return; const form = event.currentTarget; const formData = new FormData(form); formData.set("mode", submitter.value); + const executionMode = submitter.dataset.executionMode === "dry_run" ? "dry_run" : "live"; + formData.set("executionMode", executionMode); + formData.set("idempotencyKey", idempotencyKey || crypto.randomUUID()); setFeedback(null); startSending(async () => { try { await sendAction(formData); - setDraft(""); - setOriginalDraft(null); - setFeedback("Envoi lancé — la conversation se met à jour automatiquement."); + if (executionMode === "dry_run") { + setFeedback("Prévisualisation lancée — aucun message ne sera envoyé. Vous pouvez fermer cette fenêtre."); + } else { + setDraft(""); + setOriginalDraft(null); + setFeedback("Envoi lancé — la conversation se met à jour automatiquement."); + } + setIdempotencyKey(crypto.randomUUID()); router.refresh(); window.setTimeout(() => router.refresh(), 1_500); window.setTimeout(() => router.refresh(), 4_000); @@ -67,6 +91,26 @@ export function ConversationComposer({ return (
+ {commandExecutionMode === "dry_run" && commandStatus === "generated" && generatedBody ? ( +
+
+ Prévisualisation du Setter + Aucun envoi +
+

{generatedBody}

+ +
+ ) : null}