diff --git a/.env.example b/.env.example index 12fac543c..4570aeb2f 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,26 @@ GOOGLE_CLIENT_SECRET="" # schedule. # AGENT_BRIDGE_SECRET="" +# Notifies VigieProcure (the tender-monitoring product this CRM is paired +# with) when a deal's lifecycle changes -- same events already queued for +# the agent worker (deal.created, deal.stage.changed, deal.opened, +# deal.closed). Best-effort, HMAC-SHA256 signed on the raw request body +# (header X-VigieProcure-Signature: sha256=). Leave either unset and +# no bridge exists -- not an open one, same rule as AGENT_BRIDGE_SECRET +# above. Decided 2026-09-03 (DEC-C-CRM-10). +# VIGIEPROCURE_WEBHOOK_URL="https://api.vigieproc.fr/api/v1/crm/webhooks" +# VIGIEPROCURE_WEBHOOK_SECRET="" + +# Lets a rep resolve a company's SIREN (the French business registry ID) +# against VigieProcure (GET /api/v1/companies/resolve), from the company +# sheet. Leave either unset and the sheet reports the lookup as not +# configured -- not an unauthenticated call, same rule as the pairs above. +# VIGIEPROCURE_API_JWT is a long-lived service token minted by VigieProcure +# (issue_jwt("service:crm-api", ...)) -- provisioned by Franck, not by this +# codebase. +# VIGIEPROCURE_API_URL="https://api.vigieproc.fr" +# VIGIEPROCURE_API_JWT="" + # PORT="3001" diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index e6da26c5a..6126213b0 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.15.3" + ".": "1.16.0" } diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 000000000..2a2654238 --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,70 @@ +name: Build & push CRM images + +on: + push: + branches: [release] + paths: + - "apps/**" + - "packages/**" + - "package.json" + - "bun.lock" + - "turbo.json" + - ".github/workflows/build-images.yml" + workflow_dispatch: {} + +concurrency: + group: build-images-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + # Domaine unique fige au build pour apps/app -- Next.js grave API_URL/ + # APP_URL dans le bundle client compile, pas au runtime (piege deja + # rencontre en prod le 29/08/2026, cf. docker-compose.yml sur vigiep1). + CRM_ORIGIN: https://crm.vigieproc.fr + +jobs: + build-push: + name: build-push (${{ matrix.service }}) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + service: [api, app, agent] + + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.service }} + tags: | + type=raw,value=latest + type=sha,format=short + + - uses: docker/build-push-action@v6 + with: + context: . + file: apps/${{ matrix.service }}/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + API_URL=${{ env.CRM_ORIGIN }} + APP_URL=${{ env.CRM_ORIGIN }} + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,mode=max,scope=${{ matrix.service }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 538b3a9c5..5dc9416b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## [1.16.0](https://github.com/franckh-stack/crm/compare/v1.15.3...v1.16.0) (2026-09-12) + + +### Features + +* **activities:** allow excluding an email thread from the CRM synthesis ([6a4892d](https://github.com/franckh-stack/crm/commit/6a4892d3f6f8d5c1f838487a1e3cab15c44db60a)) +* **activities:** let a rep exclude an email thread from the CRM synthesis ([044e350](https://github.com/franckh-stack/crm/commit/044e350103e955417df5e26cd2f0c348e27e4600)) +* **agent:** notify VigieProcure on CRM deal lifecycle events ([c1f4305](https://github.com/franckh-stack/crm/commit/c1f430510589ddb454bc906ad7821cfa75ba5e60)) +* **agent:** route the LLM through DeepSeek instead of Vercel AI Gateway, add Dockerfile ([595a6f6](https://github.com/franckh-stack/crm/commit/595a6f6bb1aba2c681094b5c938bc8d0ea818cd2)) +* **calendar:** add q param + searchByParticipant for contact history backfill ([68c8b34](https://github.com/franckh-stack/crm/commit/68c8b348170b735b68921d653a29f8647af93322)) +* **calendar:** CalendarSyncService gains public apply()/preresolved/backfillForParticipant() ([03a91cc](https://github.com/franckh-stack/crm/commit/03a91cc8a8f84705b7da1c6f6716a3905e10a24c)) +* **companies:** resolve and set SIREN via VigieProcure ([32b7f2b](https://github.com/franckh-stack/crm/commit/32b7f2bb4e9d0d67e68b79206aa41bf0a9aff716)) +* **companies:** resolve and set SIREN via VigieProcure ([cfdb641](https://github.com/franckh-stack/crm/commit/cfdb64141ff58d29eeadf5d48d57a75e353c29ed)) +* **contacts:** add ContactHistoryBackfillService orchestrator ([e97d9b2](https://github.com/franckh-stack/crm/commit/e97d9b2610ae1930ba5c5df0f4a3e7a015099ee8)) +* **contacts:** automatic Gmail/Calendar history backfill on contact creation ([4dd3000](https://github.com/franckh-stack/crm/commit/4dd300041f587e263966c1a9dc1710049d0ef91a)) +* **contacts:** wire the history backfill into ContactsService.create() ([6946d28](https://github.com/franckh-stack/crm/commit/6946d280b01aa7f87e177b19d22964d5bce3b42a)) +* **db:** add siren column to Company, unique scoped on active records ([3d8b5b8](https://github.com/franckh-stack/crm/commit/3d8b5b853b08f22eec8e356f09d450174a3e2418)) +* **db:** colonne siren sur Company, unicite scopee archivedAt ([9061103](https://github.com/franckh-stack/crm/commit/9061103e247fa8fd713fb686ac6e26f4589a604b)) +* **db:** migration SQL add_company_siren ([aec33d5](https://github.com/franckh-stack/crm/commit/aec33d59927dddf32855b8524ba0d548a937d49e)) +* **gmail:** add searchByParticipant for contact history backfill ([5438f58](https://github.com/franckh-stack/crm/commit/5438f58adac3bf8875e94701673575e29e684a4b)) +* **mailbox:** ThreadWriterService.store() accepts preresolved company/contact + relinks ([319f3ac](https://github.com/franckh-stack/crm/commit/319f3ac5d85e3fb3b4920617d4f151e24caa2a02)) +* release release ([aec4f9d](https://github.com/franckh-stack/crm/commit/aec4f9dcaa934b7147a7fd2ae527163c8aecf2a4)) + + +### Fixes + +* **activities:** Notes tab was showing every synced email and meeting ([cb6e6f2](https://github.com/franckh-stack/crm/commit/cb6e6f2473057ea65583bd68209ae7835b693b4e)) +* **activities:** scope the Notes tab to notes/calls, not every synced email/meeting ([2c3192e](https://github.com/franckh-stack/crm/commit/2c3192e036ce7d47322cbdd25745ebdd990b7094)) +* **anti-slop:** let stubCapturingUrl's return type infer instead of widening ([8d4bb3f](https://github.com/franckh-stack/crm/commit/8d4bb3f9ccfdf4c40108adc20cc9688c46cf45c3)) +* **anti-slop:** name captured-value types by their owner, drop dead import ([b645260](https://github.com/franckh-stack/crm/commit/b64526014419238cdd6d23ff882b50d70b379413)) +* **anti-slop:** rename error to cause in ContactHistoryBackfillService.failed ([e507553](https://github.com/franckh-stack/crm/commit/e507553f5eb809c9772794185e638d2b11e6d9b7)) +* **anti-slop:** type VigieProcureEvent.payload with Prisma.InputJsonValue ([1a99fc4](https://github.com/franckh-stack/crm/commit/1a99fc4c53739f58204e7c5b937f6b75d1862beb)) +* **anti-slop:** type VigieProcureEvent.payload with Prisma.InputJsonValue ([d22d613](https://github.com/franckh-stack/crm/commit/d22d6132665c2130d3cefb6953a2f44b5eee1b48)) +* **anti-slop:** use toBeFunction() instead of typeof x === "function" ([cba97d1](https://github.com/franckh-stack/crm/commit/cba97d13ea82c0154b0f3a5ebfcc42c00f3dd67e)) +* **companies:** validate SIREN digits, drop unused conflict fields ([5d16454](https://github.com/franckh-stack/crm/commit/5d164549f0ebaf9c489fbf36b6a99a377d870916)) +* **gmail:** filter mailing-list broadcasts out of the contact relationship view ([e8ca9c2](https://github.com/franckh-stack/crm/commit/e8ca9c2bf4e1a2d0994f82af9ff5f716b6f9ae83)) +* **gmail:** filter out mailing-list broadcasts from personal correspondence ([0ffb62c](https://github.com/franckh-stack/crm/commit/0ffb62cb8ff73a538cb012da4df9273798547e06)) +* **infra:** apps/app runtime image is node:22-slim directly, no bun ([00cebb9](https://github.com/franckh-stack/crm/commit/00cebb9a0e721d88d1f0a2a8b3b02ea7dcbf29e0)) +* **infra:** move @crm/typescript-config and typescript to agent's dependencies ([7a0a50a](https://github.com/franckh-stack/crm/commit/7a0a50a8981c6aaff2c378049b47e698a6268655)) +* **infra:** skip postinstall scripts on the production-only reinstall ([120df57](https://github.com/franckh-stack/crm/commit/120df57babc2bde867345e418767b4e00699fd36)) +* **infra:** slim api/agent runtime images to production deps only ([eef531c](https://github.com/franckh-stack/crm/commit/eef531c14d8d12678467c9d33b4755287ef77953)) +* **tracking:** utilise APP_URL au lieu de l'origine derivee de la requete ([314fbda](https://github.com/franckh-stack/crm/commit/314fbda4d0f3090a54f777b22d8ff5e4d608ad8f)) +* **tracking:** utilise APP_URL au lieu de l'origine derivee de la requete ([650d364](https://github.com/franckh-stack/crm/commit/650d3647b7790f8008a41cc17b9462caef4c9679)) + + +### Refactors + +* **gmail:** extract parseGmailMessage into a pure, reusable module ([573c89c](https://github.com/franckh-stack/crm/commit/573c89c74cf8549697c4d323090b26945a8c3ad9)) + ## [1.15.3](https://github.com/trycompai/crm/compare/v1.15.2...v1.15.3) (2026-08-21) diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 000000000..efa2a25f6 --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,53 @@ +# apps/agent/Dockerfile +FROM oven/bun:1.3.12 AS deps +WORKDIR /repo +COPY package.json bun.lock turbo.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/api/scripts/ apps/api/scripts/ +COPY apps/app/package.json apps/app/package.json +COPY apps/agent/package.json apps/agent/package.json +COPY packages/ packages/ +# @crm/db's postinstall runs `prisma generate`, and prisma.config.ts resolves +# DATABASE_URL eagerly (env(...) throws if unset) even though `generate` never +# opens a connection. A build-time placeholder satisfies that check; the real +# value is supplied at `docker run` time and overrides this. +ENV DATABASE_URL="postgresql://user:password@localhost:5432/db?schema=public" +RUN bun install --frozen-lockfile + +FROM deps AS build +COPY . . +RUN bunx turbo run build --filter=agent + +# Runtime image was copying the FULL monorepo node_modules from `build` +# (dev + prod deps for app/api/agent/packages/* all at once, ~4.5GB) -- +# reinstall production-only deps here instead. Discovered in prod +# (29/08/2026): this image alone, plus apps/api's identical pattern, +# repeatedly exhausted vigiep1's disk (72GB) across rebuild cycles. +# microsandbox/just-bash moved from devDependencies to dependencies in +# apps/agent/package.json first -- eve's sandbox backend needs at least +# one of them importable at runtime even when docker.sock (the preferred +# backend here) is mounted (cf. Task 5's carried-forward minor finding). +FROM build AS prod-deps +# --ignore-scripts : sans ca, la reinstallation redeclenche le postinstall +# de @crm/db ("prisma generate"), qui echoue (exit 127, prisma est une +# devDependency, exclue par --production). Sans objet ici : le Prisma +# Client a deja ete genere pendant `build` (packages/db/src/generated/), +# et ce find ne touche qu'aux node_modules, pas a ce dossier. +RUN find . -maxdepth 4 -type d -name node_modules -prune -exec rm -rf {} + \ + && bun install --production --frozen-lockfile --ignore-scripts + +FROM oven/bun:1.3.12-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=prod-deps /repo /repo +EXPOSE 2000 +# Not `bun apps/agent/scripts/start.ts` from /repo: start.ts spawns the `eve` +# CLI via child_process.spawn, which resolves against $PATH, and the plain +# system PATH in this image does not include node_modules/.bin (only "bun +# run" from inside the package augments PATH with its local +# node_modules/.bin -- confirmed empirically: the direct form failed with +# `Executable not found in $PATH: "eve"`, even though +# apps/agent/node_modules/.bin/eve exists on disk). `cd`-ing into the +# package and using `bun run start` mirrors apps/app/Dockerfile's runtime +# CMD pattern and gets the PATH augmentation start.ts's spawn() relies on. +CMD ["sh", "-c", "cd apps/agent && bun run start"] diff --git a/apps/agent/Dockerfile.dockerignore b/apps/agent/Dockerfile.dockerignore new file mode 100644 index 000000000..9c45b5dc5 --- /dev/null +++ b/apps/agent/Dockerfile.dockerignore @@ -0,0 +1,18 @@ +# apps/agent/Dockerfile.dockerignore +# +# Named to match Docker's Dockerfile-specific ignore-file convention +# (.dockerignore, resolved at the +# build context root) — same mechanism verified empirically in Task 3 +# (apps/api/Dockerfile.dockerignore): a plain apps/agent/.dockerignore is NOT +# picked up because this build's context is the repo root +# (`docker build -f apps/agent/Dockerfile ... .`), not apps/agent/. +node_modules +**/node_modules +.turbo +**/.turbo +dist +**/dist +.eve +**/.eve +.git +*.log diff --git a/apps/agent/agent/agent.ts b/apps/agent/agent/agent.ts index 56756af59..c056d36ec 100644 --- a/apps/agent/agent/agent.ts +++ b/apps/agent/agent/agent.ts @@ -1,10 +1,9 @@ import "@crm/env/load"; -import { DEFAULT_AGENT_MODEL } from "@crm/db/settings"; import { onTelemetryProblem, syncVersion } from "@crm/telemetry"; -import { defineAgent, defineDynamic } from "eve"; +import { defineAgent } from "eve"; import { logCapabilities } from "./lib/capabilities"; -import { selectedModel } from "./lib/model"; +import { deepseekModel } from "./lib/model"; void logCapabilities(); @@ -13,10 +12,7 @@ onTelemetryProblem((message) => console.debug(`[telemetry] ${message}`)); void syncVersion(); export default defineAgent({ - model: defineDynamic({ - fallback: DEFAULT_AGENT_MODEL.id, - events: { "session.started": () => selectedModel() }, - }), + model: deepseekModel(), limits: { maxInputTokensPerSession: 500_000, maxOutputTokensPerSession: 50_000, diff --git a/apps/agent/agent/lib/model.ts b/apps/agent/agent/lib/model.ts index 76aca94fc..47008e3a4 100644 --- a/apps/agent/agent/lib/model.ts +++ b/apps/agent/agent/lib/model.ts @@ -1,13 +1,52 @@ -import { db } from "@crm/db"; -import { readAgentModel } from "@crm/db/settings"; +import { createOpenAI } from "@ai-sdk/openai"; +import type { LanguageModelV4 } from "@ai-sdk/provider"; + +const deepseek = createOpenAI({ + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY, + // Overrides the `openai` provider id @ai-sdk/openai stamps onto the + // model by default -- documented for exactly this "3rd party provider + // behind the OpenAI-compatible API" case. + name: "deepseek", +}); + +/** + * DeepSeek instead of Vercel AI Gateway (VigieProcure fork adaptation, cf. + * scripts/SPEC-fork-trycompai-crm.md Task 5) -- avoids a Vercel account + * dependency for the main agent's LLM calls. `deepseek-v4-flash` is + * VigieProcure's pinned DeepSeek model id, matching what its other AI + * agents use (cf. .claude/secrets/deepseek.env in the vigieprocure repo). + * + * Uses `.chat(...)` explicitly, NOT calling the provider directly + * (`deepseek(modelId)`) -- @ai-sdk/openai 4.x defaults the callable-provider + * shorthand to the Responses API (`provider: "openai.responses"`, POSTs to + * `/responses`), which DeepSeek's OpenAI-compatible endpoint does not + * implement. `.chat(...)` targets `/chat/completions`, which DeepSeek does + * support. Found empirically: the shorthand form compiled fine and only + * failed the unit test's `provider` assertion, not at the type level. + */ +export function deepseekModel(): LanguageModelV4 { + return deepseek.chat("deepseek-v4-flash"); +} export interface ModelSelection { model: string; modelContextWindowTokens: number; } +/** + * `db` and `readAgentModel` are imported lazily here (not at module scope) + * so that this module can be imported -- e.g. for `deepseekModel()`, in + * model.test.ts -- without eagerly initializing the Prisma client, which + * throws at import time if DATABASE_URL/TEST_DATABASE_URL isn't set (see + * packages/db/src/client.ts). The try/catch below already treats any + * failure here as "no configured model", so this changes nothing about + * this function's observable behavior. + */ export async function selectedModel(): Promise { try { + const { db } = await import("@crm/db"); + const { readAgentModel } = await import("@crm/db/settings"); const setting = await readAgentModel(db); if (setting.isDefault) return null; diff --git a/apps/agent/package.json b/apps/agent/package.json index 2360a75ad..201c8c560 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -19,19 +19,21 @@ "clean": "rm -rf .turbo .eve node_modules" }, "dependencies": { + "@ai-sdk/openai": "^4.0.51", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", + "@crm/typescript-config": "workspace:*", "@crm/validation": "workspace:*", "context.dev": "2.10.0", "eve": "^0.29.4", + "just-bash": "^3.2.0", + "microsandbox": "^0.6.8", + "typescript": "^5.9.2", "zod": "^4.4.3" }, "devDependencies": { - "@crm/typescript-config": "workspace:*", - "@types/node": "^24.0.0", - "just-bash": "^3.2.0", - "microsandbox": "^0.6.8", - "typescript": "^5.9.2" + "@ai-sdk/provider": "^4.0.8", + "@types/node": "^24.0.0" } } diff --git a/apps/agent/test/model.test.ts b/apps/agent/test/model.test.ts new file mode 100644 index 000000000..2d3da5afa --- /dev/null +++ b/apps/agent/test/model.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { deepseekModel } from "../agent/lib/model"; + +describe("deepseekModel", () => { + it("returns a language model targeting DeepSeek's OpenAI-compatible endpoint", () => { + const model = deepseekModel(); + expect(model.provider).toContain("deepseek"); + expect(model.modelId).toBe("deepseek-v4-flash"); + expect(model.doGenerate).toBeFunction(); + expect(model.doStream).toBeFunction(); + }); +}); diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 000000000..5be91d42a --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,40 @@ +# apps/api/Dockerfile +FROM oven/bun:1.3.12 AS deps +WORKDIR /repo +COPY package.json bun.lock turbo.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/api/scripts/ apps/api/scripts/ +COPY apps/app/package.json apps/app/package.json +COPY apps/agent/package.json apps/agent/package.json +COPY packages/ packages/ +# @crm/db's postinstall runs `prisma generate`, and prisma.config.ts resolves +# DATABASE_URL eagerly (env(...) throws if unset) even though `generate` never +# opens a connection. A build-time placeholder satisfies that check; the real +# value is supplied at `docker run` time and overrides this. +ENV DATABASE_URL="postgresql://user:password@localhost:5432/db?schema=public" +RUN bun install --frozen-lockfile + +FROM deps AS build +COPY . . +RUN bunx turbo run build --filter=api + +# Runtime image was copying the FULL monorepo node_modules from `build` +# (dev + prod deps for app/agent/api/packages/* all at once, ~3.6GB) -- +# reinstall production-only deps here instead. Discovered in prod +# (29/08/2026): this image alone, plus apps/agent's identical pattern, +# repeatedly exhausted vigiep1's disk (72GB) across rebuild cycles. +FROM build AS prod-deps +# --ignore-scripts : sans ca, la reinstallation redeclenche le postinstall +# de @crm/db ("prisma generate"), qui echoue (exit 127, prisma est une +# devDependency, exclue par --production). Sans objet ici : le Prisma +# Client a deja ete genere pendant `build` (packages/db/src/generated/), +# et ce find ne touche qu'aux node_modules, pas a ce dossier. +RUN find . -maxdepth 4 -type d -name node_modules -prune -exec rm -rf {} + \ + && bun install --production --frozen-lockfile --ignore-scripts + +FROM oven/bun:1.3.12-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=prod-deps /repo /repo +EXPOSE 3001 +CMD ["bun", "apps/api/dist/main.js"] diff --git a/apps/api/Dockerfile.dockerignore b/apps/api/Dockerfile.dockerignore new file mode 100644 index 000000000..c9b611a34 --- /dev/null +++ b/apps/api/Dockerfile.dockerignore @@ -0,0 +1,15 @@ +# apps/api/Dockerfile.dockerignore +# +# Named to match Docker's Dockerfile-specific ignore-file convention +# (.dockerignore, resolved at the +# build context root) — verified empirically that a plain apps/api/.dockerignore +# is NOT picked up here, because Task 3's build command uses the repo root as +# build context (`docker build -f apps/api/Dockerfile ... .`), not apps/api/. +node_modules +**/node_modules +.turbo +**/.turbo +dist +**/dist +.git +*.log diff --git a/apps/api/src/activities/activities.contracts.ts b/apps/api/src/activities/activities.contracts.ts index ad984fded..8effb6347 100644 --- a/apps/api/src/activities/activities.contracts.ts +++ b/apps/api/src/activities/activities.contracts.ts @@ -82,6 +82,15 @@ export const completeInput = z.object({ completed: z.boolean().default(true), }); +export const emailThreadExclusionInput = z.object({ + threadId: z.string(), +}); + +export const emailThreadExclusionOutput = z.object({ + id: z.string(), + excludedAt: z.string().nullable(), +}); + export const myTasksInput = z.object({ window: z.enum(["overdue", "upcoming", "all"]).default("all"), limit: z.number().int().min(1).max(100).default(25), diff --git a/apps/api/src/activities/activities.router.ts b/apps/api/src/activities/activities.router.ts index 6b1fd81b7..e6feab0a5 100644 --- a/apps/api/src/activities/activities.router.ts +++ b/apps/api/src/activities/activities.router.ts @@ -16,6 +16,8 @@ import { activityCreateOutput, completeInput, completeOutput, + emailThreadExclusionInput, + emailThreadExclusionOutput, myTasksInput, myTasksOutput, timelineCountsInput, @@ -82,4 +84,26 @@ export class ActivitiesRouter { async complete(@Input() input: z.infer) { return this.activities.complete(input.id, input.completed); } + + @Mutation({ + input: emailThreadExclusionInput, + output: emailThreadExclusionOutput, + meta: restMeta("POST", "/activities/emails/exclude", ["Activities"]), + }) + async excludeEmail( + @Input() input: z.infer, + ) { + return this.activities.excludeEmailThread(input.threadId); + } + + @Mutation({ + input: emailThreadExclusionInput, + output: emailThreadExclusionOutput, + meta: restMeta("POST", "/activities/emails/restore", ["Activities"]), + }) + async restoreEmail( + @Input() input: z.infer, + ) { + return this.activities.restoreEmailThread(input.threadId); + } } diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 50439fcbc..b9b350d86 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -61,12 +61,28 @@ const ENTRY_SELECT = { }, } as const; -const NOTE_TYPES = [ - ActivityType.NOTE, - ActivityType.CALL, - ActivityType.EMAIL, - ActivityType.MEETING, -]; +/** + * The "Notes" tab is for what a rep writes down by hand -- not what syncs in + * automatically. Email and Meeting each already have their own dedicated + * tab; including them here duplicated every synced email/meeting into the + * Notes tab as well (found via the real contact-history backfill, WP + * crm-enrich 02/09/2026 -- a Gmail/Calendar sync produced 0 notes and 18 + * entries, all 18 of which nonetheless showed up under "Notes"). + */ +const NOTE_TYPES = [ActivityType.NOTE, ActivityType.CALL]; + +/** + * A rep can exclude an individual email thread from the CRM's synthesis + * (e.g. a mailing-list broadcast the automated bulk-mail filter didn't + * catch, cf. gmail-message-parser.ts). Excluding is soft -- the row stays + * so the live sync's rfcMessageId/rootMessageId dedup keys keep the thread + * from being re-imported -- so every read of the timeline has to respect + * it. Activities not tied to an email thread (notes, calls, meetings, + * tasks...) are untouched. + */ +const VISIBLE_EMAIL_THREAD: Prisma.ActivityWhereInput = { + OR: [{ emailThreadId: null }, { emailThread: { excludedAt: null } }], +}; @Injectable() export class ActivitiesService { @@ -79,7 +95,7 @@ export class ActivitiesService { async timeline(input: TimelineInput): Promise { const where = this.anchor(input); - Object.assign(where, filterClause(input.filter)); + Object.assign(where, filterClause(input.filter), VISIBLE_EMAIL_THREAD); const rows = await this.db.activity.findMany({ where, @@ -108,19 +124,29 @@ export class ActivitiesService { const anchor = this.anchor(input); const [all, notes, upcoming, done, email, meetings] = await Promise.all([ - this.db.activity.count({ where: anchor }), + this.db.activity.count({ where: { ...anchor, ...VISIBLE_EMAIL_THREAD } }), this.db.activity.count({ - where: { ...anchor, ...filterClause("notes") }, + where: { ...anchor, ...filterClause("notes"), ...VISIBLE_EMAIL_THREAD }, }), this.db.activity.count({ - where: { ...anchor, ...filterClause("upcoming") }, + where: { + ...anchor, + ...filterClause("upcoming"), + ...VISIBLE_EMAIL_THREAD, + }, }), - this.db.activity.count({ where: { ...anchor, ...filterClause("done") } }), this.db.activity.count({ - where: { ...anchor, ...filterClause("email") }, + where: { ...anchor, ...filterClause("done"), ...VISIBLE_EMAIL_THREAD }, }), this.db.activity.count({ - where: { ...anchor, ...filterClause("meetings") }, + where: { ...anchor, ...filterClause("email"), ...VISIBLE_EMAIL_THREAD }, + }), + this.db.activity.count({ + where: { + ...anchor, + ...filterClause("meetings"), + ...VISIBLE_EMAIL_THREAD, + }, }), ]); @@ -187,6 +213,62 @@ export class ActivitiesService { return serializeEntry(updated); } + async excludeEmailThread( + threadId: string, + ): Promise<{ id: string; excludedAt: string | null }> { + const thread = await this.db.emailThread.findUnique({ + where: { id: threadId }, + select: { id: true }, + }); + if (!thread) { + throw new NotFoundException(`No email thread with id ${threadId}.`); + } + + const updated = await this.db.emailThread.update({ + where: { id: threadId }, + data: { excludedAt: new Date() }, + select: { id: true, excludedAt: true }, + }); + + this.logger.log({ + message: "Email thread excluded from synthesis", + threadId, + }); + + return { + id: updated.id, + excludedAt: updated.excludedAt?.toISOString() ?? null, + }; + } + + async restoreEmailThread( + threadId: string, + ): Promise<{ id: string; excludedAt: string | null }> { + const thread = await this.db.emailThread.findUnique({ + where: { id: threadId }, + select: { id: true }, + }); + if (!thread) { + throw new NotFoundException(`No email thread with id ${threadId}.`); + } + + const updated = await this.db.emailThread.update({ + where: { id: threadId }, + data: { excludedAt: null }, + select: { id: true, excludedAt: true }, + }); + + this.logger.log({ + message: "Email thread restored to synthesis", + threadId, + }); + + return { + id: updated.id, + excludedAt: updated.excludedAt?.toISOString() ?? null, + }; + } + async myTasks( input: MyTasksInput, actingUserId: string, diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 3b2143302..54c80c869 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -8,6 +8,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { AGENT_DISPATCH } from "./agent-dispatch.config"; import { bridge } from "./bridge"; +import { envoyerEvenementVigieProcure } from "./vigieprocure-bridge"; export type CrmEventInput = { [Type in CrmEventType]: { @@ -197,15 +198,15 @@ export class AgentTriggerService { emit: (input: CrmEventInput) => Promise, ) => Promise, ): Promise { - const queued: CrmEventInput[] = []; + const queued: Array<{ taskId: string; input: CrmEventInput }> = []; const result = await this.db.$transaction((tx) => work(tx, async (input) => { - await this.createEventTask(tx, input); - queued.push(input); + const taskId = await this.createEventTask(tx, input); + queued.push({ taskId, input }); }), ); - for (const input of queued) { + for (const { input } of queued) { this.logger.log({ message: "Agent event queued", type: input.type, @@ -213,11 +214,38 @@ export class AgentTriggerService { recordId: input.record.id, }); } - if (queued.length > 0) this.poke(); + if (queued.length > 0) { + this.poke(); + for (const { taskId, input } of queued) { + void this.notifyVigieProcure(taskId, input); + } + } return result; } + /** + * DEC-C-CRM-10 (Franck, 03/09/2026) : sens entrant F.24 active. Notifie + * VigieProcure des memes evenements CRM que ceux dejа mis en file pour + * l'agent Eve (`createEventTask`) -- best-effort, echec silencieux + * journalise (meme doctrine que `poke()`). `taskId` (id de l'`AgentTask` + * cree dans la meme transaction) sert d'`event_id` stable, dedupliquable + * cote reception. + */ + private async notifyVigieProcure( + taskId: string, + input: CrmEventInput, + ): Promise { + await envoyerEvenementVigieProcure( + { + eventId: taskId, + kind: input.type, + payload: { record: input.record, data: input.data }, + }, + this.logger, + ); + } + async fieldBackfillRecords( entity: FieldEntity, keys: string[], @@ -520,13 +548,13 @@ export class AgentTriggerService { private async createEventTask( tx: Prisma.TransactionClient, input: CrmEventInput, - ): Promise { + ): Promise { const recordIds = { contactId: input.record.kind === "contact" ? input.record.id : null, companyId: input.record.kind === "company" ? input.record.id : null, dealId: input.record.kind === "deal" ? input.record.id : null, }; - await tx.agentTask.create({ + const task = await tx.agentTask.create({ data: { ...recordIds, kind: "agent-event", @@ -542,6 +570,7 @@ export class AgentTriggerService { dueAt: new Date(), }, }); + return task.id; } canReachAgent(): boolean { diff --git a/apps/api/src/agent/vigieprocure-bridge.ts b/apps/api/src/agent/vigieprocure-bridge.ts new file mode 100644 index 000000000..2cd128339 --- /dev/null +++ b/apps/api/src/agent/vigieprocure-bridge.ts @@ -0,0 +1,89 @@ +import type { Prisma } from "@crm/db"; + +const HEADER_SIGNATURE = "X-VigieProcure-Signature"; + +export interface VigieProcureBridge { + url: URL; + secret: string; +} + +/** + * `VIGIEPROCURE_WEBHOOK_SECRET` unset means there is no bridge, not an open + * one — the same rule `bridge()` (agent) already follows. Every caller has + * to say what it does without VigieProcure. + */ +export function vigieProcureBridge(): VigieProcureBridge | null { + const secret = process.env.VIGIEPROCURE_WEBHOOK_SECRET?.trim(); + const url = process.env.VIGIEPROCURE_WEBHOOK_URL?.trim(); + if (!secret || !url) return null; + + return { url: new URL(url), secret }; +} + +async function hmacSha256Hex(secret: string, data: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(data), + ); + return Buffer.from(signature).toString("hex"); +} + +export type VigieProcureEvent = { + eventId: string; + kind: string; + payload: Prisma.InputJsonValue; +}; + +/** + * Emet un evenement CRM vers VigieProcure — best-effort, echec silencieux + * journalise (meme doctrine que `agent-trigger.service.ts::post`, le "poke" + * vers l'agent Eve : aucun retry in-process, aucune garantie de livraison + * ici). L'outbox minimal cote reception (persistance avant tout traitement) + * reste la responsabilite de VigieProcure, pas de ce bridge. + */ +export async function envoyerEvenementVigieProcure( + event: VigieProcureEvent, + logger: { debug: (obj: Prisma.InputJsonObject) => void }, +): Promise { + const target = vigieProcureBridge(); + if (!target) return false; + + const corps = JSON.stringify({ + event_id: event.eventId, + kind: event.kind, + payload: event.payload, + }); + const signature = await hmacSha256Hex(target.secret, corps); + + try { + const response = await fetch(target.url, { + method: "POST", + headers: { + "content-type": "application/json", + [HEADER_SIGNATURE]: `sha256=${signature}`, + }, + body: corps, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`VigieProcure webhook returned ${response.status}.`); + } + + return true; + } catch (error) { + logger.debug({ + message: "VigieProcure webhook did not land", + reason: error instanceof Error ? error.message : String(error), + }); + return false; + } +} diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index d2157334e..c776ec3ac 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -198,6 +198,7 @@ export const companyDetailOutput = z.object({ githubUrl: z.string().nullable(), pricingUrl: z.string().nullable(), careersUrl: z.string().nullable(), + siren: z.string().nullable(), enrichmentStatus: companyEnrichmentStatus, enrichmentError: z.string().nullable(), source: companyRecordSource, @@ -255,3 +256,45 @@ export const companySetPrimaryContactOutput = z.object({ id: z.string(), primaryContactId: z.string().nullable(), }); + +export const companySirenResolveInput = z.object({ + id: z.string(), +}); + +const sirenCandidateOutput = z.object({ + siren: z.string(), + siret: z.string().nullable(), + nameCanonicalFull: z.string(), + legalFormLabel: z.string().nullable(), + nafCode: z.string().nullable(), + nafLabel: z.string().nullable(), + city: z.string().nullable(), + department: z.string().nullable(), + confidence: z.enum(["exact", "ambiguous", "weak"]), + matchedOn: z.string().nullable(), +}); + +export type SirenCandidateOutput = z.infer; + +export const companySirenResolveOutput = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("not-configured") }), + z.object({ outcome: z.literal("unauthorized"), reason: z.string() }), + z.object({ outcome: z.literal("failed"), reason: z.string() }), + z.object({ + outcome: z.literal("ok"), + candidates: z.array(sirenCandidateOutput), + }), +]); + +export const companySetSirenInput = z.object({ + id: z.string(), + siren: z.string().regex(/^\d{9}$/, "A SIREN is 9 digits."), +}); + +export const companySetSirenOutput = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("ok"), id: z.string(), siren: z.string() }), + z.object({ + outcome: z.literal("conflict"), + reason: z.string(), + }), +]); diff --git a/apps/api/src/companies/companies.router.ts b/apps/api/src/companies/companies.router.ts index ac11ccd35..0355f16cd 100644 --- a/apps/api/src/companies/companies.router.ts +++ b/apps/api/src/companies/companies.router.ts @@ -26,6 +26,10 @@ import { companyOptionsInput, companyResearchOutput, companySetPrimaryContactOutput, + companySetSirenInput, + companySetSirenOutput, + companySirenResolveInput, + companySirenResolveOutput, companySummaryOutput, companyUpdateArgs, setPrimaryContactInput, @@ -186,4 +190,22 @@ export class CompaniesRouter { ) { return this.companies.setPrimaryContact(input.companyId, input.contactId); } + + @Mutation({ + input: companySirenResolveInput, + output: companySirenResolveOutput, + meta: restMeta("POST", "/companies/{id}/resolve-siren", ["Companies"]), + }) + async resolveSiren(@Input("id") id: string) { + return this.companies.resolveSiren(id); + } + + @Mutation({ + input: companySetSirenInput, + output: companySetSirenOutput, + meta: restMeta("POST", "/companies/{id}/siren", ["Companies"]), + }) + async setSiren(@Input() input: z.infer) { + return this.companies.setSiren(input.id, input.siren); + } } diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 3960dc33c..f88f297ba 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -47,6 +47,7 @@ import type { } from "./companies.contracts"; import { normalizeDomain } from "./domain"; import { FaviconService } from "./favicon.service"; +import { resolveSiren as resolveSirenFromVigieProcure } from "./vigieprocure-companies.client"; const OWNER_SELECT = { id: true, @@ -183,6 +184,7 @@ export class CompaniesService { githubUrl: true, pricingUrl: true, careersUrl: true, + siren: true, enrichmentStatus: true, enrichedAt: true, enrichmentError: true, @@ -720,6 +722,71 @@ export class CompaniesService { }; } + /** + * Resout le SIREN d une company via VigieProcure. Ne persiste rien -- c est + * setSiren qui ecrit, sur decision explicite de l appelant (voir doctrine + * companies.contracts.ts::companySirenResolveOutput). + */ + async resolveSiren(id: string) { + const company = await this.db.company.findUnique({ + where: { id }, + select: { name: true, city: true, domain: true }, + }); + + if (!company) { + throw new NotFoundException(`No company with id ${id}.`); + } + + const resolution = await resolveSirenFromVigieProcure({ + name: company.name, + city: company.city, + domain: company.domain, + }); + + if (resolution.outcome !== "ok") return resolution; + + return { outcome: "ok" as const, candidates: resolution.candidates }; + } + + async setSiren(id: string, siren: string) { + const company = await this.db.company.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!company) { + throw new NotFoundException(`No company with id ${id}.`); + } + + try { + await this.db.company.update({ + where: { id }, + data: { siren }, + select: { id: true }, + }); + } catch (cause) { + if ( + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2002" + ) { + const conflicting = await this.db.company.findFirst({ + where: { siren, archivedAt: null }, + select: { id: true, name: true }, + }); + return { + outcome: "conflict" as const, + reason: conflicting + ? `Ce SIREN est deja rattache a la fiche ${conflicting.name}.` + : "Ce SIREN est deja rattache a une autre fiche.", + }; + } + throw this.translate(cause, id); + } + + this.logger.log({ message: "Company SIREN set", companyId: id, siren }); + + return { outcome: "ok" as const, id, siren }; + } + private translate(cause: unknown, id: string): never { if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { if (cause.code === "P2025") { diff --git a/apps/api/src/companies/vigieprocure-companies.client.ts b/apps/api/src/companies/vigieprocure-companies.client.ts new file mode 100644 index 000000000..ce9c1db99 --- /dev/null +++ b/apps/api/src/companies/vigieprocure-companies.client.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; + +const RESOLVE_TIMEOUT_MS = 15_000; +const RESOLVE_PATH = "/api/v1/companies/resolve"; + +export interface VigieProcureApi { + url: URL; + jwt: string; +} + +/** + * `VIGIEPROCURE_API_JWT` unset means there is no way to call VigieProcure, + * not an unauthenticated call to it -- same rule as `bridge()` (agent) and + * `vigieProcureBridge()` (webhook). Every caller has to say what it does + * without VigieProcure. + */ +export function vigieProcureApi(): VigieProcureApi | null { + const jwt = process.env.VIGIEPROCURE_API_JWT?.trim(); + const base = process.env.VIGIEPROCURE_API_URL?.trim(); + if (!jwt || !base) return null; + + return { url: new URL(RESOLVE_PATH, base), jwt }; +} + +const resolveConfidence = z.enum(["exact", "ambiguous", "weak"]); + +const resolveItem = z + .object({ + siren: z.string(), + siret: z.string().nullable().catch(null), + name_canonical_full: z.string(), + legal_form_label: z.string().nullable().catch(null), + naf_code: z.string().nullable().catch(null), + naf_label: z.string().nullable().catch(null), + city: z.string().nullable().catch(null), + department: z.string().nullable().catch(null), + confidence: resolveConfidence, + matched_on: z.string().nullable().catch(null), + }) + .transform((raw) => ({ + siren: raw.siren, + siret: raw.siret, + nameCanonicalFull: raw.name_canonical_full, + legalFormLabel: raw.legal_form_label, + nafCode: raw.naf_code, + nafLabel: raw.naf_label, + city: raw.city, + department: raw.department, + confidence: raw.confidence, + matchedOn: raw.matched_on, + })); + +const resolveResponse = z.object({ + items: z.array(resolveItem).catch([]), + count: z.number().catch(0), + total_matches: z.number().catch(0), +}); + +export type SirenCandidate = z.infer; + +export type SirenResolution = + | { outcome: "ok"; candidates: SirenCandidate[] } + | { outcome: "not-configured" } + | { outcome: "unauthorized"; reason: string } + | { outcome: "failed"; reason: string }; + +/** + * Resout un nom de company CRM en SIREN candidat(s) via l'API VigieProcure + * (`GET /api/v1/companies/resolve`). Jamais de SIREN unique implicite -- + * l'appelant tranche sur `candidates`, y compris quand `confidence` vaut + * "exact" pour un seul element. + */ +export async function resolveSiren(query: { + name: string; + city?: string | null; + domain?: string | null; +}): Promise { + const api = vigieProcureApi(); + if (!api) return { outcome: "not-configured" }; + + const target = new URL(api.url); + target.searchParams.set("name", query.name); + if (query.city) target.searchParams.set("city", query.city); + if (query.domain) target.searchParams.set("domain", query.domain); + + try { + const response = await fetch(target, { + headers: { authorization: `Bearer ${api.jwt}` }, + signal: AbortSignal.timeout(RESOLVE_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403) { + return { + outcome: "unauthorized", + reason: `VigieProcure answered ${response.status} -- the service JWT may be missing or expired.`, + }; + } + + if (!response.ok) { + return { + outcome: "failed", + reason: `VigieProcure answered ${response.status}.`, + }; + } + + const parsed = resolveResponse.safeParse(await response.json()); + if (!parsed.success) { + return { + outcome: "failed", + reason: "VigieProcure's response did not match the expected shape.", + }; + } + + return { outcome: "ok", candidates: parsed.data.items }; + } catch (cause) { + const aborted = cause instanceof Error && cause.name === "AbortError"; + return { + outcome: "failed", + reason: aborted + ? `Timed out after ${RESOLVE_TIMEOUT_MS}ms.` + : cause instanceof Error + ? cause.message + : String(cause), + }; + } +} diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c2..8eb776e75 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -123,6 +123,42 @@ export class EnvironmentVariables { @IsString() AGENT_BRIDGE_SECRET?: string; + // DEC-C-CRM-10 (Franck, 03/09/2026) : sens entrant F.24 active. Meme + // patron que AGENT_URL/AGENT_BRIDGE_SECRET -- vide = pas de bridge, pas + // un bridge ouvert (vigieprocure-bridge.ts::vigieProcureBridge). + @IsOptional() + @IsUrl( + { require_tld: false, require_protocol: true }, + { + message: + "VIGIEPROCURE_WEBHOOK_URL must be a full URL with a scheme, like https://api.vigieproc.fr/api/v1/crm/webhooks.", + }, + ) + VIGIEPROCURE_WEBHOOK_URL?: string; + + @IsOptional() + @IsString() + VIGIEPROCURE_WEBHOOK_SECRET?: string; + + // Sens sortant : le CRM appelle VigieProcure pour resoudre un SIREN + // (GET /api/v1/companies/resolve). Vide = fonctionnalite non configuree, + // jamais un appel non authentifie -- meme regle que les paires + // AGENT_URL/AGENT_BRIDGE_SECRET et VIGIEPROCURE_WEBHOOK_URL/_SECRET + // ci-dessus (vigieprocure-companies.client.ts::vigieProcureApi). + @IsOptional() + @IsUrl( + { require_tld: false, require_protocol: true }, + { + message: + "VIGIEPROCURE_API_URL must be a full URL with a scheme, like https://api.vigieproc.fr.", + }, + ) + VIGIEPROCURE_API_URL?: string; + + @IsOptional() + @IsString() + VIGIEPROCURE_API_JWT?: string; + @IsOptional() @IsString() CRM_TELEMETRY_DISABLED?: string; diff --git a/apps/api/src/contacts/contact-history-backfill.service.ts b/apps/api/src/contacts/contact-history-backfill.service.ts new file mode 100644 index 000000000..49c73a776 --- /dev/null +++ b/apps/api/src/contacts/contact-history-backfill.service.ts @@ -0,0 +1,162 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { CalendarSyncService } from "../google/calendar-sync.service"; +import { GmailClient } from "../google/gmail.client"; +import { parseGmailMessage } from "../google/gmail-message-parser"; +import { MailboxTokenService } from "../mailbox/mailbox-token.service"; +import { SyncStateService } from "../mailbox/sync-state.service"; +import { ThreadWriterService } from "../mailbox/thread-writer.service"; + +export const BACKFILL_WINDOW_MONTHS = 24; +export const BACKFILL_GMAIL_MAX_RESULTS = 200; +export const BACKFILL_CALENDAR_MAX_RESULTS = 100; + +export type SourceOutcome = { + status: "synced" | "skipped"; + written: number; + reason?: string; +}; + +/** + * Automatic, per-contact Gmail/Calendar history backfill -- triggered from + * ContactsService.create() (contacts.service.ts). The live sync is + * forward-only by upstream design (see migration + * 20260731210000_forward_only_sync); this fills the gap for a single named + * contact at the moment it's created, reusing ThreadWriterService.store() + * and CalendarSyncService.apply()/backfillForParticipant() with a + * preresolved company/contact rather than duplicating their dedup/ + * transaction/activity-projection logic. + */ +@Injectable() +export class ContactHistoryBackfillService { + private readonly logger = new Logger(ContactHistoryBackfillService.name); + + constructor( + private readonly gmail: GmailClient, + private readonly tokens: MailboxTokenService, + private readonly state: SyncStateService, + private readonly threads: ThreadWriterService, + private readonly calendarSync: CalendarSyncService, + ) {} + + async run(input: { + contactId: string; + email: string; + companyId: string | null; + userId: string; + }): Promise<{ gmail: SourceOutcome; calendar: SourceOutcome }> { + const before = new Date(); + const after = new Date(before); + after.setMonth(after.getMonth() - BACKFILL_WINDOW_MONTHS); + + const preresolved = { + companyId: input.companyId, + contactId: input.contactId, + }; + + const [gmail, calendar] = await Promise.all([ + this.backfillGmail( + input.userId, + input.email, + after, + before, + preresolved, + ).catch((cause) => this.failed("gmail", input.contactId, cause)), + this.calendarSync + .backfillForParticipant({ + userId: input.userId, + email: input.email, + companyId: input.companyId, + contactId: input.contactId, + after, + before, + maxResults: BACKFILL_CALENDAR_MAX_RESULTS, + }) + .catch((cause) => this.failed("calendar", input.contactId, cause)), + ]); + + return { gmail, calendar }; + } + + private async backfillGmail( + userId: string, + email: string, + after: Date, + before: Date, + preresolved: { companyId: string | null; contactId: string | null }, + ): Promise { + const row = await this.state.get(userId, "gmail"); + if (!row) { + return { + status: "skipped", + written: 0, + reason: "Gmail is not connected for this user.", + }; + } + + const token = await this.tokens.accessTokenFor(userId, "gmail"); + if (token.outcome !== "ok") { + return { status: "skipped", written: 0, reason: token.reason }; + } + + const profile = await this.gmail.profile(token.accessToken); + if (profile.outcome !== "ok") { + return { status: "skipped", written: 0, reason: profile.reason }; + } + + const mailbox = profile.data.emailAddress?.toLowerCase(); + if (!mailbox) { + return { + status: "skipped", + written: 0, + reason: "Gmail returned no mailbox address.", + }; + } + + const result = await this.gmail.searchByParticipant(token.accessToken, { + email, + after, + before, + maxResults: BACKFILL_GMAIL_MAX_RESULTS, + }); + if (result.outcome !== "ok") { + return { status: "skipped", written: 0, reason: result.reason }; + } + + const context = await this.threads.context(); + let written = 0; + + for (const item of result.data.messages ?? []) { + if (!item.id) continue; + + const message = await this.gmail.getMessage(token.accessToken, item.id); + if (message.outcome !== "ok") continue; + + const parsed = parseGmailMessage(message.data); + if (!parsed) continue; + + const stored = await this.threads.store( + row, + { mailbox, origin: "gmail" }, + parsed, + context, + preresolved, + ); + if (stored) written += 1; + } + + return { status: "synced", written }; + } + + private failed( + source: "gmail" | "calendar", + contactId: string, + cause: unknown, + ): SourceOutcome { + const reason = cause instanceof Error ? cause.message : String(cause); + this.logger.error( + { message: "Contact history backfill source failed", source, contactId }, + cause instanceof Error ? cause.stack : undefined, + ); + return { status: "skipped", written: 0, reason }; + } +} diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 790fbf935..2fe94e12c 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -2,13 +2,23 @@ import { Module } from "@nestjs/common"; import { AgentModule } from "../agent/agent.module"; import { CompaniesModule } from "../companies/companies.module"; import { FieldsModule } from "../fields/fields.module"; +import { GoogleModule } from "../google/google.module"; +import { MailboxModule } from "../mailbox/mailbox.module"; import { TrpcModule } from "../trpc/trpc.module"; +import { ContactHistoryBackfillService } from "./contact-history-backfill.service"; import { ContactsRouter } from "./contacts.router"; import { ContactsService } from "./contacts.service"; @Module({ - imports: [FieldsModule, TrpcModule, AgentModule, CompaniesModule], - providers: [ContactsService, ContactsRouter], + imports: [ + FieldsModule, + TrpcModule, + AgentModule, + CompaniesModule, + MailboxModule, + GoogleModule, + ], + providers: [ContactsService, ContactsRouter, ContactHistoryBackfillService], exports: [ContactsService], }) export class ContactsModule {} diff --git a/apps/api/src/contacts/contacts.router.ts b/apps/api/src/contacts/contacts.router.ts index 025ff6710..5324374db 100644 --- a/apps/api/src/contacts/contacts.router.ts +++ b/apps/api/src/contacts/contacts.router.ts @@ -60,8 +60,11 @@ export class ContactsRouter { output: contactBasicOutput, meta: restMeta("POST", "/contacts", ["Contacts"]), }) - async create(@Input() input: z.infer) { - return this.contacts.create(input); + async create( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.contacts.create(input, ctx.user.id); } @Mutation({ diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 043fd4b78..99247e19a 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -39,6 +39,7 @@ import { resolveOrderBy, splitSentinel, } from "../trpc/list-input"; +import { ContactHistoryBackfillService } from "./contact-history-backfill.service"; import type { ContactBulkCompanyInput, ContactBulkOwnerInput, @@ -101,6 +102,7 @@ export class ContactsService { private readonly queue: AgentQueueService, private readonly stamp: ActivityStampService, private readonly fields: FieldsService, + private readonly history: ContactHistoryBackfillService, ) {} async list(input: ContactListInput): Promise> { @@ -260,7 +262,7 @@ export class ContactsService { }; } - async create(input: ContactCreateInput) { + async create(input: ContactCreateInput, actorId?: string) { const email = normalizeEmail(input.email ?? ""); if (email) { @@ -324,6 +326,25 @@ export class ContactsService { this.logger.log({ message: "Contact created", contactId: contact.id }); + if (actorId && contact.email) { + this.history + .run({ + contactId: contact.id, + email: contact.email, + companyId: contact.companyId, + userId: actorId, + }) + .catch((error: Error) => { + this.logger.error( + { + message: "Contact history backfill failed", + contactId: contact.id, + }, + error.stack, + ); + }); + } + await this.agent.contactCreated( contact.id, "Added by a rep, with nothing on the record yet", @@ -613,12 +634,15 @@ export class ContactsService { const [threads, lastReply, meetings, nextMeeting, colleagues] = await Promise.all([ this.db.emailThread.aggregate({ - where: { contactId }, + where: { contactId, excludedAt: null }, _sum: { messageCount: true }, _count: { _all: true }, }), this.db.emailMessage.findFirst({ - where: { thread: { contactId }, direction: "INBOUND" }, + where: { + thread: { contactId, excludedAt: null }, + direction: "INBOUND", + }, orderBy: { sentAt: "desc" }, select: { sentAt: true }, }), diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..94311df02 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -13,10 +13,10 @@ import { z } from "zod"; const t = initTRPC.create(); const publicProcedure = t.procedure; -import { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput } from "../activities/activities.contracts"; +import { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput, emailThreadExclusionInput, emailThreadExclusionOutput } from "../activities/activities.contracts"; import { agentListOutput, agentReviseInput, agentReviseOutput, agentIdInput, agentFilesOutput, agentSaveFileInput, agentSaveFileOutput, agentByIdOutput, agentHistoryInput, agentHistoryOutput, agentActivityOutput, agentUpdateInput, agentUpdateOutput, agentDeployInput, agentDeployOutput, agentPauseOutput, agentResumeOutput, agentArchiveOutput, agentRestoreOutput, agentRemoveOutput, agentRunNowInput, agentRunNowOutput, agentRetryRunInput, agentRetryRunOutput, agentCancelRunInput, agentCancelRunOutput } from "../agent/agents.contracts"; import { apiKeyListInput, apiKeyListOutput, createApiKeyInput, createApiKeyOutput, revokeApiKeyInput, revokeApiKeyOutput } from "../api-keys/api-keys.contracts"; -import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput } from "../companies/companies.contracts"; +import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput, companySirenResolveInput, companySirenResolveOutput, companySetSirenInput, companySetSirenOutput } from "../companies/companies.contracts"; import { contactListInput, contactListOutput, contactIdInput, contactByIdOutput, contactCreateInput, contactBasicOutput, contactUpdateArgs, contactNameOutput, contactEnrichOutput, contactBulkOwnerInput, bulkResultOutput, contactBulkCompanyInput, contactBulkInput, factDecisionInput, decideFactOutput } from "../contacts/contacts.contracts"; import { conversationListInput, conversationListOutput, builderListOutput, builderResourceSearchInput, builderResourcesOutput, conversationIdInput, builderConversationDetailOutput, conversationEventsInput, conversationEventsOutput, conversationSaveInput, conversationIdOutput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, builderResponseRatingOutput, conversationShareStatusOutput, conversationShareTokenOutput, sharedConversationInput, sharedConversationOutput } from "../conversations/conversations.contracts"; import { currencySettingsOutput, setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts"; @@ -55,6 +55,14 @@ const appRouter = t.router({ complete: publicProcedure .input(completeInput) .output(completeOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + excludeEmail: publicProcedure + .input(emailThreadExclusionInput) + .output(emailThreadExclusionOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restoreEmail: publicProcedure + .input(emailThreadExclusionInput) + .output(emailThreadExclusionOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), agents: t.router({ @@ -204,6 +212,14 @@ const appRouter = t.router({ setPrimaryContact: publicProcedure .input(setPrimaryContactInput) .output(companySetPrimaryContactOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + resolveSiren: publicProcedure + .input(companySirenResolveInput) + .output(companySirenResolveOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setSiren: publicProcedure + .input(companySetSirenInput) + .output(companySetSirenOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), contacts: t.router({ diff --git a/apps/api/src/google/calendar-sync.service.ts b/apps/api/src/google/calendar-sync.service.ts index d219b3e9e..99bd3064f 100644 --- a/apps/api/src/google/calendar-sync.service.ts +++ b/apps/api/src/google/calendar-sync.service.ts @@ -74,18 +74,7 @@ export class CalendarSyncService { await this.state.markRunning(row.id); - const [internal, suppressedDomains, suppressedEmails] = await Promise.all([ - this.match.internalIdentity(), - this.match.suppressedDomains(), - this.match.suppressedEmails(), - ]); - - const context = { - ourAddresses: internal.addresses, - ourDomains: internal.domains, - suppressedDomains, - suppressedEmails, - }; + const context = await this.buildContext(); let pageToken: string | undefined; let syncToken = row.cursor ?? undefined; @@ -188,10 +177,39 @@ export class CalendarSyncService { }; } - private async apply( + /** + * Builds the internal-identity/suppression context apply() and + * backfillForParticipant() need. Public: the contact history backfill + * (and tests exercising apply() directly) build a context without + * running a full sync() tick, the same way ThreadWriterService.context() + * is already public for the equivalent reason on the email side. + */ + async buildContext(): Promise { + const [internal, suppressedDomains, suppressedEmails] = await Promise.all([ + this.match.internalIdentity(), + this.match.suppressedDomains(), + this.match.suppressedEmails(), + ]); + + return { + ourAddresses: internal.addresses, + ourDomains: internal.domains, + suppressedDomains, + suppressedEmails, + }; + } + + /** + * Public so the contact history backfill (and tests) can drive it + * directly with a preresolved company/contact, bypassing match.resolve() + * -- default (preresolved omitted) behavior is unchanged for the live + * incremental sync's own call in sync()/apply() above. + */ + async apply( event: GoogleEvent, row: MailboxSync, context: MatchContext, + preresolved?: { companyId: string | null; contactId: string | null }, ): Promise<"written" | "removed" | "ignored"> { const iCalUid = event.iCalUID; if (!iCalUid) return "ignored"; @@ -221,23 +239,34 @@ export class CalendarSyncService { const end = eventTime(event.end); if (!start || !end) return "ignored"; - const participants = this.participantsOf(event); - - const declinedByUs = event.attendees?.some( - (attendee) => attendee.self && attendee.responseStatus === "declined", - ); + let matchedCompanyId: string | null; + let matchedContactId: string | null; + + if (preresolved) { + matchedCompanyId = preresolved.companyId; + matchedContactId = preresolved.contactId; + } else { + const participants = this.participantsOf(event); + + const declinedByUs = event.attendees?.some( + (attendee) => attendee.self && attendee.responseStatus === "declined", + ); + + const match = await this.match.resolve( + { + participants, + allowCreate: row.autoCreate && !declinedByUs, + source: RecordSource.CALENDAR, + ownerId: row.userId, + }, + context, + ); - const match = await this.match.resolve( - { - participants, - allowCreate: row.autoCreate && !declinedByUs, - source: RecordSource.CALENDAR, - ownerId: row.userId, - }, - context, - ); + matchedCompanyId = match.companyId; + matchedContactId = match.contactId; + } - if (!match.companyId && !match.contactId) { + if (!matchedCompanyId && !matchedContactId) { return "ignored"; } @@ -258,8 +287,8 @@ export class CalendarSyncService { isAllDay: start.isAllDay, status: event.status ?? "confirmed", organizerEmail: organizer, - companyId: match.companyId, - contactId: match.contactId, + companyId: matchedCompanyId, + contactId: matchedContactId, syncedByUserId: row.userId, googleEventId: event.id ?? null, }, @@ -273,8 +302,8 @@ export class CalendarSyncService { isAllDay: start.isAllDay, status: event.status ?? "confirmed", organizerEmail: organizer, - companyId: match.companyId, - contactId: match.contactId, + companyId: matchedCompanyId, + contactId: matchedContactId, }, select: { id: true }, }); @@ -284,14 +313,69 @@ export class CalendarSyncService { await this.project(record.id, row.userId, { title: event.summary ?? "Meeting", startsAt: start.at, - companyId: match.companyId, - contactId: match.contactId, + companyId: matchedCompanyId, + contactId: matchedContactId, location: event.location ?? null, }); return "written"; } + /** + * Contact history backfill entry point: search for events involving a + * single known participant and write matches with the contact already + * resolved -- never touches the live sync's cursor/pagination state. + */ + async backfillForParticipant(input: { + userId: string; + email: string; + companyId: string | null; + contactId: string | null; + after: Date; + before: Date; + maxResults: number; + }): Promise<{ + status: "synced" | "skipped"; + written: number; + reason?: string; + }> { + const row = await this.state.get(input.userId, "calendar"); + if (!row) { + return { + status: "skipped", + written: 0, + reason: "Calendar is not connected for this user.", + }; + } + + const token = await this.tokens.accessTokenFor(input.userId, "calendar"); + if (token.outcome !== "ok") { + return { status: "skipped", written: 0, reason: token.reason }; + } + + const context = await this.buildContext(); + const result = await this.calendar.searchByParticipant(token.accessToken, { + email: input.email, + timeMin: input.after, + timeMax: input.before, + maxResults: input.maxResults, + }); + if (result.outcome !== "ok") { + return { status: "skipped", written: 0, reason: result.reason }; + } + + let written = 0; + for (const item of result.data.items ?? []) { + const applied = await this.apply(item, row, context, { + companyId: input.companyId, + contactId: input.contactId, + }); + if (applied === "written") written += 1; + } + + return { status: "synced", written }; + } + private async syncAttendees( eventId: string, event: GoogleEvent, diff --git a/apps/api/src/google/calendar.client.ts b/apps/api/src/google/calendar.client.ts index 8ec52501b..1bc86d566 100644 --- a/apps/api/src/google/calendar.client.ts +++ b/apps/api/src/google/calendar.client.ts @@ -53,6 +53,7 @@ export type EventsQuery = { timeMax?: string; pageToken?: string; maxResults?: number; + q?: string; }; @Injectable() @@ -73,9 +74,31 @@ export class CalendarClient { maxResults: query.maxResults ?? 250, syncToken: query.syncToken, pageToken: query.pageToken, + q: query.q, ...window, }); } + + /** + * Targeted search for a single contact backfill (not the live incremental + * sync, which never passes q). + */ + async searchByParticipant( + accessToken: string, + options: { + email: string; + timeMin: Date; + timeMax: Date; + maxResults?: number; + }, + ): Promise> { + return this.listEvents(accessToken, { + q: options.email, + timeMin: options.timeMin.toISOString(), + timeMax: options.timeMax.toISOString(), + maxResults: options.maxResults, + }); + } } export function conferenceUrl(event: GoogleEvent): string | null { diff --git a/apps/api/src/google/gmail-message-parser.ts b/apps/api/src/google/gmail-message-parser.ts new file mode 100644 index 000000000..86e1d6564 --- /dev/null +++ b/apps/api/src/google/gmail-message-parser.ts @@ -0,0 +1,90 @@ +import { + normaliseMessageId, + stripQuotedHistory, +} from "../mailbox/message-text"; +import { parseAddress, parseAddressList } from "../mailbox/participants"; +import type { IncomingMessage } from "../mailbox/thread-writer.service"; +import type { GmailMessage } from "./gmail.client"; +import { + type GmailHeader, + header, + plainTextBody, + rootMessageId, +} from "./gmail-mime"; + +/** + * A message with more recipients than this is a broadcast/mailing-list -- + * real 1:1 or small-group business correspondence never has this many. + * Measured on real data (WP crm-enrich, 02/09/2026): the smallest observed + * mailing-list broadcast carried 26 recipients, the largest real 1:1 thread + * carried 1. This threshold sits with a wide margin below that gap. + */ +export const BULK_MAIL_RECIPIENT_THRESHOLD = 5; + +/** + * Extracted verbatim from GmailSyncService's former private parse()/sentAt() + * so the contact-history backfill can reuse it without duplicating parsing + * logic (apps/api/src/contacts/contact-history-backfill.service.ts). + */ +export function parseGmailMessage( + message: GmailMessage, +): IncomingMessage | null { + const headers = message.payload?.headers; + + const rawMessageId = header(headers, "message-id"); + if (!rawMessageId) return null; + + const from = parseAddress(header(headers, "from") ?? ""); + if (!from) return null; + + if (header(headers, "list-unsubscribe")) return null; + + const sentAt = messageSentAt(message, headers); + if (!sentAt) return null; + + const rootId = rootMessageId(headers) ?? normaliseMessageId(rawMessageId); + + const to = parseAddressList(header(headers, "to")).map((person) => ({ + email: person.email, + name: person.name, + kind: "to" as const, + })); + + const cc = parseAddressList(header(headers, "cc")).map((person) => ({ + email: person.email, + name: person.name, + kind: "cc" as const, + })); + + const recipients = [...to, ...cc]; + if (recipients.length > BULK_MAIL_RECIPIENT_THRESHOLD) return null; + + const body = stripQuotedHistory(plainTextBody(message.payload)); + + return { + rfcMessageId: normaliseMessageId(rawMessageId), + rootId, + subject: header(headers, "subject"), + from, + recipients, + body, + sentAt, + gmailMessageId: message.id ?? null, + }; +} + +function messageSentAt( + message: GmailMessage, + headers: readonly GmailHeader[] | undefined, +): Date | null { + if (message.internalDate) { + const at = new Date(Number(message.internalDate)); + if (!Number.isNaN(at.getTime())) return at; + } + + const raw = header(headers, "date"); + if (!raw) return null; + + const at = new Date(raw); + return Number.isNaN(at.getTime()) ? null : at; +} diff --git a/apps/api/src/google/gmail-sync.service.ts b/apps/api/src/google/gmail-sync.service.ts index a693261bd..2a9b27666 100644 --- a/apps/api/src/google/gmail-sync.service.ts +++ b/apps/api/src/google/gmail-sync.service.ts @@ -7,23 +7,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import type { MatchContext } from "../mailbox/mailbox-match.service"; import { MailboxTokenService } from "../mailbox/mailbox-token.service"; -import { - normaliseMessageId, - stripQuotedHistory, -} from "../mailbox/message-text"; -import { parseAddress, parseAddressList } from "../mailbox/participants"; import { SyncStateService } from "../mailbox/sync-state.service"; -import { - type IncomingMessage, - ThreadWriterService, -} from "../mailbox/thread-writer.service"; -import { GmailClient, type GmailMessage } from "./gmail.client"; -import { - type GmailHeader, - header, - plainTextBody, - rootMessageId, -} from "./gmail-mime"; +import { ThreadWriterService } from "../mailbox/thread-writer.service"; +import { GmailClient } from "./gmail.client"; +import { parseGmailMessage } from "./gmail-message-parser"; const MAX_MESSAGES_PER_TICK = 120; @@ -216,7 +203,7 @@ export class GmailSyncService { const message = await this.gmail.getMessage(accessToken, id); if (message.outcome !== "ok") continue; - const parsed = this.parse(message.data); + const parsed = parseGmailMessage(message.data); if (!parsed) continue; const stored = await this.threads.store( @@ -231,62 +218,6 @@ export class GmailSyncService { return { written, remaining }; } - private parse(message: GmailMessage): IncomingMessage | null { - const headers = message.payload?.headers; - - const rawMessageId = header(headers, "message-id"); - if (!rawMessageId) return null; - - const from = parseAddress(header(headers, "from") ?? ""); - if (!from) return null; - - const sentAt = this.sentAt(message, headers); - if (!sentAt) return null; - - const rootId = rootMessageId(headers) ?? normaliseMessageId(rawMessageId); - - const to = parseAddressList(header(headers, "to")).map((person) => ({ - email: person.email, - name: person.name, - kind: "to" as const, - })); - - const cc = parseAddressList(header(headers, "cc")).map((person) => ({ - email: person.email, - name: person.name, - kind: "cc" as const, - })); - - const body = stripQuotedHistory(plainTextBody(message.payload)); - - return { - rfcMessageId: normaliseMessageId(rawMessageId), - rootId, - subject: header(headers, "subject"), - from, - recipients: [...to, ...cc], - body, - sentAt, - gmailMessageId: message.id ?? null, - }; - } - - private sentAt( - message: GmailMessage, - headers: readonly GmailHeader[] | undefined, - ): Date | null { - if (message.internalDate) { - const at = new Date(Number(message.internalDate)); - if (!Number.isNaN(at.getTime())) return at; - } - - const raw = header(headers, "date"); - if (!raw) return null; - - const at = new Date(raw); - return Number.isNaN(at.getTime()) ? null : at; - } - private async handleFailure( row: MailboxSync, result: { outcome: string; reason: string; retryAfterMs?: number }, diff --git a/apps/api/src/google/gmail.client.ts b/apps/api/src/google/gmail.client.ts index 4ac2e35b6..d5ff24f6d 100644 --- a/apps/api/src/google/gmail.client.ts +++ b/apps/api/src/google/gmail.client.ts @@ -55,18 +55,48 @@ export class GmailClient { before: Date; pageToken?: string; maxResults?: number; + query?: string; }, ): Promise> { const after = Math.floor(options.after.getTime() / 1000); const before = Math.ceil(options.before.getTime() / 1000); + const q = [ + WORK_MAIL_QUERY, + `after:${after}`, + `before:${before}`, + options.query, + ] + .filter(Boolean) + .join(" "); return this.api.get(`${BASE}/messages`, accessToken, { - q: `${WORK_MAIL_QUERY} after:${after} before:${before}`, + q, maxResults: options.maxResults ?? 100, pageToken: options.pageToken, }); } + /** + * Targeted search for a single contact backfill (not the live incremental + * sync, which never calls this) — reuses listMessages' q= support. + */ + async searchByParticipant( + accessToken: string, + options: { + email: string; + after: Date; + before: Date; + maxResults?: number; + }, + ): Promise> { + return this.listMessages(accessToken, { + after: options.after, + before: options.before, + maxResults: options.maxResults, + query: `(from:${options.email} OR to:${options.email})`, + }); + } + async listHistory( accessToken: string, options: { startHistoryId: string; pageToken?: string }, diff --git a/apps/api/src/google/google.module.ts b/apps/api/src/google/google.module.ts index 7b841cd88..491492c64 100644 --- a/apps/api/src/google/google.module.ts +++ b/apps/api/src/google/google.module.ts @@ -23,6 +23,11 @@ import { GoogleSyncService } from "./google-sync.service"; ConversationService, GoogleRouter, ], - exports: [GoogleSyncService, GoogleConnectionService], + exports: [ + GoogleSyncService, + GoogleConnectionService, + CalendarSyncService, + GmailClient, + ], }) export class GoogleModule {} diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 9b06692c6..e2eca9574 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -61,6 +61,7 @@ export class ThreadWriterService { options: { mailbox: string; origin: SyncSource }, parsed: IncomingMessage, context: MatchContext, + preresolved?: { companyId: string | null; contactId: string | null }, ): Promise { const existing = await this.db.emailMessage.findUnique({ where: { rfcMessageId: parsed.rfcMessageId }, @@ -75,7 +76,15 @@ export class ThreadWriterService { }, }, }); - if (existing?.thread.activity) return false; + if (existing?.thread.activity) { + if ( + preresolved?.contactId && + this.canRelink(existing.thread, preresolved) + ) { + await this.relink(existing.threadId, preresolved); + } + return false; + } const repair = existing !== null; const participants = [parsed.from, ...parsed.recipients]; @@ -96,22 +105,27 @@ export class ThreadWriterService { let contactId = thread?.contactId ?? null; if (!thread) { - const repliedTo = - outbound || - (await this.hasOutboundInThread(parsed.rootId, options.mailbox)); - - const match = await this.match.resolve( - { - participants, - allowCreate: row.autoCreate && repliedTo, - source: RecordSource.EMAIL, - ownerId: row.userId, - }, - context, - ); + if (preresolved) { + companyId = preresolved.companyId; + contactId = preresolved.contactId; + } else { + const repliedTo = + outbound || + (await this.hasOutboundInThread(parsed.rootId, options.mailbox)); + + const match = await this.match.resolve( + { + participants, + allowCreate: row.autoCreate && repliedTo, + source: RecordSource.EMAIL, + ownerId: row.userId, + }, + context, + ); - companyId = match.companyId; - contactId = match.contactId; + companyId = match.companyId; + contactId = match.contactId; + } if (!companyId && !contactId) { return false; @@ -237,6 +251,50 @@ export class ThreadWriterService { } } + /** + * Only ever consulted when a caller passes `preresolved` (the contact + * history backfill) -- never in the live incremental sync's default path. + * Refuses to relink a thread that already points at a different contact + * or a different company than the one being backfilled. + */ + private canRelink( + thread: { contactId: string | null; companyId: string | null }, + preresolved: { companyId: string | null; contactId: string | null }, + ): boolean { + if (thread.contactId !== null) return false; + return ( + thread.companyId === null || thread.companyId === preresolved.companyId + ); + } + + private async relink( + emailThreadId: string, + preresolved: { companyId: string | null; contactId: string | null }, + ): Promise { + const companyPatch = preresolved.companyId + ? { companyId: preresolved.companyId } + : {}; + + const at = await this.db.$transaction(async (tx) => { + const thread = await tx.emailThread.update({ + where: { id: emailThreadId }, + data: { contactId: preresolved.contactId, ...companyPatch }, + select: { lastMessageAt: true }, + }); + await tx.activity.updateMany({ + where: { emailThreadId }, + data: { contactId: preresolved.contactId, ...companyPatch }, + }); + return thread.lastMessageAt; + }); + + await this.touch( + { companyId: preresolved.companyId, contactId: preresolved.contactId }, + at, + emailThreadId, + ); + } + private async hasOutboundInThread( rootMessageId: string, mailbox: string, diff --git a/apps/api/test/activities-timeline-filter.spec.ts b/apps/api/test/activities-timeline-filter.spec.ts new file mode 100644 index 000000000..a7cd02093 --- /dev/null +++ b/apps/api/test/activities-timeline-filter.spec.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ActivityType, db } from "@crm/db"; +import { ActivitiesService } from "../src/activities/activities.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; + +const suffix = process.env.TEST_RUN_ID ?? "activities-timeline-filter-spec"; +const domain = `timeline-filter-${suffix}.test`; +const userId = `user-${suffix}`; + +const stamp = new ActivityStampService(db); +const service = new ActivitiesService(db, stamp); + +let contactId: string; + +async function clean() { + await db.activity.deleteMany({ + where: { subject: { endsWith: `[${suffix}]` } }, + }); + await db.contact.deleteMany({ where: { email: { endsWith: `@${domain}` } } }); + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +async function seed(type: ActivityType, subjectPrefix: string) { + await db.activity.create({ + data: { + type, + subject: `${subjectPrefix} [${suffix}]`, + occurredAt: new Date("2026-01-01T10:00:00Z"), + contactId, + createdById: userId, + }, + }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: `rep@${domain}` }, + }); + + const company = await db.company.create({ + data: { name: "Timeline Filter Co", domain }, + select: { id: true }, + }); + + const contact = await db.contact.create({ + data: { + firstName: "Timeline", + lastName: "Filter", + email: `contact@${domain}`, + companyId: company.id, + }, + select: { id: true }, + }); + contactId = contact.id; + + await seed(ActivityType.NOTE, "A note"); + await seed(ActivityType.CALL, "A call log"); + await seed(ActivityType.EMAIL, "A synced email"); + await seed(ActivityType.MEETING, "A synced meeting"); +}); + +afterAll(clean); + +describe("ActivitiesService.timeline -- notes filter scope", () => { + it("the notes tab shows manually-written entries (notes, calls), not synced emails or meetings", async () => { + const result = await service.timeline({ + contactId, + filter: "notes", + limit: 30, + }); + + const subjects = result.entries.map((entry) => entry.subject); + + expect(subjects).toContain(`A note [${suffix}]`); + expect(subjects).toContain(`A call log [${suffix}]`); + expect(subjects).not.toContain(`A synced email [${suffix}]`); + expect(subjects).not.toContain(`A synced meeting [${suffix}]`); + }); + + it("the email tab still shows only emails -- unaffected by the notes-tab fix", async () => { + const result = await service.timeline({ + contactId, + filter: "email", + limit: 30, + }); + + const subjects = result.entries.map((entry) => entry.subject); + + expect(subjects).toEqual([`A synced email [${suffix}]`]); + }); + + it("the meetings tab still shows only meetings -- unaffected by the notes-tab fix", async () => { + const result = await service.timeline({ + contactId, + filter: "meetings", + limit: 30, + }); + + const subjects = result.entries.map((entry) => entry.subject); + + expect(subjects).toEqual([`A synced meeting [${suffix}]`]); + }); + + it("the all tab still shows everything", async () => { + const result = await service.timeline({ + contactId, + filter: "all", + limit: 30, + }); + + expect(result.entries.length).toBe(4); + }); + + it("timelineCounts.notes matches the notes tab, not inflated by email/meetings", async () => { + const counts = await service.timelineCounts({ contactId }); + + expect(counts.notes).toBe(2); + }); +}); + +describe("ActivitiesService email thread exclusion -- removing noise from the synthesis", () => { + let threadId: string; + + beforeAll(async () => { + const thread = await db.emailThread.create({ + data: { + rootMessageId: ``, + subject: "Excludable", + contactId, + firstMessageAt: new Date("2026-02-01T10:00:00Z"), + lastMessageAt: new Date("2026-02-01T10:00:00Z"), + messageCount: 1, + }, + select: { id: true }, + }); + threadId = thread.id; + + await db.activity.create({ + data: { + type: ActivityType.EMAIL, + subject: `An excludable email [${suffix}]`, + occurredAt: new Date("2026-02-01T10:00:00Z"), + contactId, + createdById: userId, + emailThreadId: threadId, + }, + }); + }); + + afterAll(async () => { + await db.emailThread.deleteMany({ + where: { rootMessageId: `` }, + }); + }); + + it("shows up in the timeline before it is excluded", async () => { + const result = await service.timeline({ + contactId, + filter: "email", + limit: 30, + }); + expect(result.entries.map((entry) => entry.subject)).toContain( + `An excludable email [${suffix}]`, + ); + }); + + it("excludeEmailThread hides it from the timeline and every count", async () => { + const excluded = await service.excludeEmailThread(threadId); + expect(excluded.excludedAt).not.toBeNull(); + + const [all, email] = await Promise.all([ + service.timeline({ contactId, filter: "all", limit: 30 }), + service.timeline({ contactId, filter: "email", limit: 30 }), + ]); + + expect(all.entries.map((entry) => entry.subject)).not.toContain( + `An excludable email [${suffix}]`, + ); + expect(email.entries.map((entry) => entry.subject)).not.toContain( + `An excludable email [${suffix}]`, + ); + }); + + it("restoreEmailThread brings it back", async () => { + const restored = await service.restoreEmailThread(threadId); + expect(restored.excludedAt).toBeNull(); + + const result = await service.timeline({ + contactId, + filter: "email", + limit: 30, + }); + expect(result.entries.map((entry) => entry.subject)).toContain( + `An excludable email [${suffix}]`, + ); + }); + + it("excluding a thread that does not exist throws NotFoundException", async () => { + await expect( + service.excludeEmailThread(`missing-${suffix}`), + ).rejects.toThrow(`No email thread with id missing-${suffix}.`); + }); +}); diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index 9a0315188..d8d0baeab 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -5,6 +5,7 @@ import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; import { CompanyDirectoryService } from "../src/companies/company-directory.service"; import type { FaviconService } from "../src/companies/favicon.service"; +import type { ContactHistoryBackfillService } from "../src/contacts/contact-history-backfill.service"; import { ContactsService } from "../src/contacts/contacts.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; @@ -31,6 +32,14 @@ const conversion = new ConversionService(db); const directory = new CompanyDirectoryService(agent); const fields = new FieldsService(db, agent); +// actorId is never passed by this spec's contacts.create({...}) calls, so +// the backfill branch never fires -- history.run is a no-op stub. +const history = { + run: async () => ({ + gmail: { status: "skipped" as const, written: 0 }, + calendar: { status: "skipped" as const, written: 0 }, + }), +} as unknown as ContactHistoryBackfillService; const contacts = new ContactsService( db, directory, @@ -38,6 +47,7 @@ const contacts = new ContactsService( queue, stamp, fields, + history, ); const companies = new CompaniesService( db, diff --git a/apps/api/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts new file mode 100644 index 000000000..a1e91799a --- /dev/null +++ b/apps/api/test/calendar-client.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { CalendarClient } from "../src/google/calendar.client"; +import { MailboxApiClient } from "../src/mailbox/mailbox-api.client"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubCapturingUrl() { + const calls: URL[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(new URL(input.toString())); + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return { calls }; +} + +const client = new CalendarClient(new MailboxApiClient()); + +describe("CalendarClient.listEvents", () => { + it("omits q from the request when none is given", async () => { + const { calls } = stubCapturingUrl(); + await client.listEvents("token", { + timeMin: "2026-01-01T00:00:00Z", + timeMax: "2026-02-01T00:00:00Z", + }); + + expect(calls[0]?.searchParams.has("q")).toBe(false); + }); + + it("forwards q when given", async () => { + const { calls } = stubCapturingUrl(); + await client.listEvents("token", { + timeMin: "2026-01-01T00:00:00Z", + timeMax: "2026-02-01T00:00:00Z", + q: "dvignault@scalair.fr", + }); + + expect(calls[0]?.searchParams.get("q")).toBe("dvignault@scalair.fr"); + }); +}); + +describe("CalendarClient.searchByParticipant", () => { + it("sends the participant email as q, and the window as timeMin/timeMax", async () => { + const { calls } = stubCapturingUrl(); + await client.searchByParticipant("token", { + email: "dvignault@scalair.fr", + timeMin: new Date("2026-01-01T00:00:00Z"), + timeMax: new Date("2026-02-01T00:00:00Z"), + }); + + expect(calls[0]?.searchParams.get("q")).toBe("dvignault@scalair.fr"); + expect(calls[0]?.searchParams.get("timeMin")).toBe( + "2026-01-01T00:00:00.000Z", + ); + expect(calls[0]?.searchParams.get("timeMax")).toBe( + "2026-02-01T00:00:00.000Z", + ); + }); + + it("forwards maxResults", async () => { + const { calls } = stubCapturingUrl(); + await client.searchByParticipant("token", { + email: "dvignault@scalair.fr", + timeMin: new Date("2026-01-01T00:00:00Z"), + timeMax: new Date("2026-02-01T00:00:00Z"), + maxResults: 42, + }); + + expect(calls[0]?.searchParams.get("maxResults")).toBe("42"); + }); +}); diff --git a/apps/api/test/calendar-sync-apply.spec.ts b/apps/api/test/calendar-sync-apply.spec.ts new file mode 100644 index 000000000..45e5b443f --- /dev/null +++ b/apps/api/test/calendar-sync-apply.spec.ts @@ -0,0 +1,331 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db, type MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import type { + CalendarClient, + GoogleEvent, +} from "../src/google/calendar.client"; +import { CalendarSyncService } from "../src/google/calendar-sync.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { + MailboxTokenService, + TokenResult, +} from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "calendar-sync-apply-spec"; +const knownDomain = `calsync-known-${suffix}.test`; +const unmatchedDomain = `calsync-unmatched-${suffix}.test`; +const userId = `user-${suffix}`; + +const agent = { + contactCreated: async () => true, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => true, + meetingSoon: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); + +function tokenStub(result: TokenResult): MailboxTokenService { + return { + accessTokenFor: async () => result, + } as unknown as MailboxTokenService; +} + +function calendarStub(items: GoogleEvent[]): CalendarClient { + return { + searchByParticipant: async () => ({ outcome: "ok", data: { items } }), + } as unknown as CalendarClient; +} + +let row: MailboxSync; +let knownCompanyId: string; + +function event( + iCalUid: string, + organizerEmail: string, + startsAt: Date, +): GoogleEvent { + return { + id: `gcal-${iCalUid}`, + iCalUID: iCalUid, + status: "confirmed", + summary: "Sync", + organizer: { email: organizerEmail }, + attendees: [{ email: organizerEmail }], + start: { dateTime: startsAt.toISOString() }, + end: { dateTime: new Date(startsAt.getTime() + 3600_000).toISOString() }, + }; +} + +async function clean() { + await db.calendarEvent.deleteMany({ + where: { syncedByUserId: userId }, + }); + await db.contact.deleteMany({ + where: { email: { endsWith: `@${knownDomain}` } }, + }); + await db.company.deleteMany({ + where: { domain: { in: [knownDomain, unmatchedDomain] } }, + }); + await db.mailboxSync.deleteMany({ where: { userId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await clean(); + await db.user.create({ + data: { id: userId, name: "Test Rep", email: `rep-${suffix}@example.test` }, + }); + row = await db.mailboxSync.create({ + data: { userId, source: "calendar", autoCreate: false }, + }); + const company = await db.company.create({ + data: { name: "Known Co", domain: knownDomain }, + select: { id: true }, + }); + knownCompanyId = company.id; +}); + +afterAll(clean); + +describe("CalendarSyncService.apply() with preresolved", () => { + it("writes using preresolved ids, without calling match.resolve", async () => { + const service = new CalendarSyncService( + db, + {} as unknown as CalendarClient, + tokenStub({ outcome: "ok", accessToken: "unused" }), + match, + state, + stamp, + agent, + ); + const context = await service.buildContext(); + + const contact = await db.contact.create({ + data: { + firstName: "Relink", + lastName: "Target", + email: `relink@${knownDomain}`, + companyId: knownCompanyId, + }, + select: { id: true }, + }); + + const result = await service.apply( + event( + `ical-preresolved-${suffix}-a`, + `nobody@${unmatchedDomain}`, + new Date("2026-01-10T10:00:00Z"), + ), + row, + context, + { companyId: knownCompanyId, contactId: contact.id }, + ); + + expect(result).toBe("written"); + + const stored = await db.calendarEvent.findUnique({ + where: { + iCalUid_originalStartTime: { + iCalUid: `ical-preresolved-${suffix}-a`, + originalStartTime: new Date("2026-01-10T10:00:00Z"), + }, + }, + select: { companyId: true, contactId: true }, + }); + expect(stored?.companyId).toBe(knownCompanyId); + expect(stored?.contactId).toBe(contact.id); + }); + + it("regression: apply() without preresolved is unchanged (unmatched participant, autoCreate off -> ignored)", async () => { + const service = new CalendarSyncService( + db, + {} as unknown as CalendarClient, + tokenStub({ outcome: "ok", accessToken: "unused" }), + match, + state, + stamp, + agent, + ); + const context = await service.buildContext(); + + const result = await service.apply( + event( + `ical-preresolved-${suffix}-b`, + `nobody@${unmatchedDomain}`, + new Date("2026-01-11T10:00:00Z"), + ), + row, + context, + ); + + expect(result).toBe("ignored"); + }); + + it("dedup: a second apply() with preresolved wins on the update branch over a first match.resolve-driven write", async () => { + const service = new CalendarSyncService( + db, + {} as unknown as CalendarClient, + tokenStub({ outcome: "ok", accessToken: "unused" }), + match, + state, + stamp, + agent, + ); + const context = await service.buildContext(); + const iCalUid = `ical-preresolved-${suffix}-c`; + const startsAt = new Date("2026-01-12T10:00:00Z"); + + // First: default match.resolve() path, matches by domain only + // (company-only, no contact registered for this exact address). + const first = await service.apply( + event(iCalUid, `someone@${knownDomain}`, startsAt), + row, + context, + ); + expect(first).toBe("written"); + + const afterFirst = await db.calendarEvent.findUnique({ + where: { + iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt }, + }, + select: { id: true, companyId: true, contactId: true }, + }); + expect(afterFirst?.companyId).toBe(knownCompanyId); + expect(afterFirst?.contactId).toBeNull(); + + const contact = await db.contact.create({ + data: { + firstName: "Dedup", + lastName: "Winner", + email: `dedup-${suffix}@${knownDomain}`, + companyId: knownCompanyId, + }, + select: { id: true }, + }); + + // Second: same iCalUid/originalStartTime, preresolved this time -- + // must upsert to the SAME row, preresolved ids win. + const second = await service.apply( + event(iCalUid, `someone@${knownDomain}`, startsAt), + row, + context, + { companyId: knownCompanyId, contactId: contact.id }, + ); + expect(second).toBe("written"); + + const rows = await db.calendarEvent.findMany({ + where: { iCalUid }, + select: { id: true, contactId: true }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(afterFirst?.id); + expect(rows[0]?.contactId).toBe(contact.id); + }); +}); + +describe("CalendarSyncService.backfillForParticipant()", () => { + it("writes matched events found by search, using preresolved ids", async () => { + const iCalUid = `ical-backfill-${suffix}-a`; + const startsAt = new Date("2026-01-15T10:00:00Z"); + const service = new CalendarSyncService( + db, + calendarStub([event(iCalUid, `someone@${unmatchedDomain}`, startsAt)]), + tokenStub({ outcome: "ok", accessToken: "token" }), + match, + state, + stamp, + agent, + ); + + const contact = await db.contact.create({ + data: { + firstName: "Backfill", + lastName: "Target", + email: `backfill@${knownDomain}`, + companyId: knownCompanyId, + }, + select: { id: true }, + }); + + const result = await service.backfillForParticipant({ + userId, + email: `someone@${unmatchedDomain}`, + companyId: knownCompanyId, + contactId: contact.id, + after: new Date("2024-01-01T00:00:00Z"), + before: new Date("2026-12-31T00:00:00Z"), + maxResults: 100, + }); + + expect(result).toEqual({ status: "synced", written: 1 }); + + const stored = await db.calendarEvent.findUnique({ + where: { + iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt }, + }, + select: { contactId: true }, + }); + expect(stored?.contactId).toBe(contact.id); + }); + + it("skips when the calendar token needs reconnect", async () => { + const service = new CalendarSyncService( + db, + calendarStub([]), + tokenStub({ outcome: "needs-reconnect", reason: "expired" }), + match, + state, + stamp, + agent, + ); + + const result = await service.backfillForParticipant({ + userId, + email: `x@${unmatchedDomain}`, + companyId: null, + contactId: "irrelevant", + after: new Date("2024-01-01T00:00:00Z"), + before: new Date("2026-12-31T00:00:00Z"), + maxResults: 100, + }); + + expect(result.status).toBe("skipped"); + }); + + it("skips when the user has no MailboxSync row for calendar", async () => { + const neverConnectedUserId = `never-connected-${suffix}`; + const service = new CalendarSyncService( + db, + calendarStub([]), + tokenStub({ outcome: "ok", accessToken: "token" }), + match, + state, + stamp, + agent, + ); + + const result = await service.backfillForParticipant({ + userId: neverConnectedUserId, + email: `x@${unmatchedDomain}`, + companyId: null, + contactId: "irrelevant", + after: new Date("2024-01-01T00:00:00Z"), + before: new Date("2026-12-31T00:00:00Z"), + maxResults: 100, + }); + + expect(result.status).toBe("skipped"); + }); +}); diff --git a/apps/api/test/contact-history-backfill.spec.ts b/apps/api/test/contact-history-backfill.spec.ts new file mode 100644 index 000000000..30e4cdba2 --- /dev/null +++ b/apps/api/test/contact-history-backfill.spec.ts @@ -0,0 +1,552 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { + BACKFILL_CALENDAR_MAX_RESULTS, + BACKFILL_GMAIL_MAX_RESULTS, + BACKFILL_WINDOW_MONTHS, + ContactHistoryBackfillService, +} from "../src/contacts/contact-history-backfill.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import type { + CalendarClient, + GoogleEvent, +} from "../src/google/calendar.client"; +import { CalendarSyncService } from "../src/google/calendar-sync.service"; +import type { + GmailClient, + GmailMessage, + MessageList, +} from "../src/google/gmail.client"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { + MailboxTokenService, + TokenResult, +} from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { ThreadWriterService } from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "contact-history-backfill-spec"; +const domain = `backfill-${suffix}.test`; +const senderDomain = `sender-${suffix}.test`; + +const agent = { + contactCreated: async () => true, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => true, + meetingSoon: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); +const threads = new ThreadWriterService(db, match, stamp); + +function gmailMessage(rfcId: string, from: string, sentAt: Date): GmailMessage { + return { + id: `gm-${rfcId}`, + payload: { + headers: [ + { name: "message-id", value: `<${rfcId}>` }, + { name: "from", value: from }, + { name: "to", value: "rep@example.test" }, + { name: "subject", value: "History" }, + { name: "date", value: sentAt.toUTCString() }, + ], + }, + }; +} + +type GmailStubOptions = { + profile?: + | { outcome: "ok"; data: { emailAddress: string } } + | { outcome: "failed"; reason: string; retryable: boolean }; + searchIds?: string[]; + messages?: Record; + searchThrows?: boolean; + captured?: { maxResults?: number; after?: Date; before?: Date }; +}; + +function gmailStub(options: GmailStubOptions): GmailClient { + return { + profile: async () => + options.profile ?? { + outcome: "ok", + data: { emailAddress: "rep@example.test" }, + }, + searchByParticipant: async ( + _token: string, + args: { email: string; after: Date; before: Date; maxResults?: number }, + ) => { + if (options.captured) { + options.captured.maxResults = args.maxResults; + options.captured.after = args.after; + options.captured.before = args.before; + } + if (options.searchThrows) throw new Error("gmail search exploded"); + const messages: MessageList["messages"] = (options.searchIds ?? []).map( + (id) => ({ id, threadId: id }), + ); + return { outcome: "ok", data: { messages } }; + }, + getMessage: async (_token: string, id: string) => { + const found = options.messages?.[id]; + return found + ? { outcome: "ok", data: found } + : { outcome: "failed", reason: "not found", retryable: false }; + }, + } as unknown as GmailClient; +} + +function tokenStub( + gmail: TokenResult, + calendar: TokenResult = { outcome: "ok", accessToken: "cal-token" }, +): MailboxTokenService { + return { + accessTokenFor: async ( + _userId: string, + source: "gmail" | "calendar" | "outlook", + ) => (source === "gmail" ? gmail : calendar), + } as unknown as MailboxTokenService; +} + +function calendarClientStub( + items: GoogleEvent[] = [], + captured?: { maxResults?: number; timeMin?: string; timeMax?: string }, +): CalendarClient { + return { + searchByParticipant: async ( + _token: string, + args: { + email: string; + timeMin: Date; + timeMax: Date; + maxResults?: number; + }, + ) => { + if (captured) { + captured.maxResults = args.maxResults; + captured.timeMin = args.timeMin.toISOString(); + captured.timeMax = args.timeMax.toISOString(); + } + return { outcome: "ok", data: { items } }; + }, + } as unknown as CalendarClient; +} + +function calendarEvent( + iCalUid: string, + organizerEmail: string, + startsAt: Date, +): GoogleEvent { + return { + id: `gcal-${iCalUid}`, + iCalUID: iCalUid, + status: "confirmed", + summary: "History meeting", + organizer: { email: organizerEmail }, + attendees: [{ email: organizerEmail }], + start: { dateTime: startsAt.toISOString() }, + end: { dateTime: new Date(startsAt.getTime() + 3600_000).toISOString() }, + }; +} + +function service( + gmail: GmailClient, + tokens: MailboxTokenService, + calendar: CalendarClient, +) { + const calendarSync = new CalendarSyncService( + db, + calendar, + tokens, + match, + state, + stamp, + agent, + ); + return new ContactHistoryBackfillService( + gmail, + tokens, + state, + threads, + calendarSync, + ); +} + +let companyId: string; +let contactId: string; +let contactEmail: string; +let userId: string; + +async function newUserWithSync(id: string): Promise { + await db.user.create({ + data: { id, name: "Test Rep", email: `${id}@example.test` }, + }); + await db.mailboxSync.create({ + data: { userId: id, source: "gmail", autoCreate: false }, + }); + await db.mailboxSync.create({ + data: { userId: id, source: "calendar", autoCreate: false }, + }); +} + +async function clean() { + await db.calendarEvent.deleteMany({ + where: { syncedByUserId: { startsWith: `user-${suffix}` } }, + }); + await db.emailThread.deleteMany({ + where: { rootMessageId: { contains: suffix } }, + }); + await db.contact.deleteMany({ where: { email: { endsWith: `@${domain}` } } }); + await db.company.deleteMany({ where: { domain } }); + await db.mailboxSync.deleteMany({ + where: { userId: { startsWith: `user-${suffix}` } }, + }); + await db.user.deleteMany({ where: { id: { startsWith: `user-${suffix}` } } }); +} + +beforeAll(async () => { + await clean(); + const company = await db.company.create({ + data: { name: "History Co", domain }, + select: { id: true }, + }); + companyId = company.id; + contactEmail = `target@${domain}`; + const contact = await db.contact.create({ + data: { + firstName: "Target", + lastName: "Contact", + email: contactEmail, + companyId, + }, + select: { id: true }, + }); + contactId = contact.id; + userId = `user-${suffix}-main`; + await newUserWithSync(userId); +}); + +afterAll(clean); + +describe("ContactHistoryBackfillService.run()", () => { + it("writes matching Gmail and Calendar history for the contact", async () => { + const rfcId = `history-happy-${suffix}@mail.test`; + const message = gmailMessage( + rfcId, + `sender@${senderDomain}`, + new Date("2026-02-01T10:00:00Z"), + ); + const svc = service( + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([ + calendarEvent( + `ical-happy-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-02T10:00:00Z"), + ), + ]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); + + expect(result.gmail).toEqual({ status: "synced", written: 1 }); + expect(result.calendar).toEqual({ status: "synced", written: 1 }); + + const thread = await db.emailThread.findFirst({ + where: { messages: { some: { rfcMessageId: rfcId } } }, + select: { contactId: true }, + }); + expect(thread?.contactId).toBe(contactId); + }); + + it("capstone: relinks a thread the live sync already stored by company only -- the relationship panel query now returns it", async () => { + const rfcId = `history-relink-${suffix}@mail.test`; + const rootId = ``; + const sentAt = new Date("2026-02-03T10:00:00Z"); + + const existingThread = await db.emailThread.create({ + data: { + rootMessageId: rootId, + subject: "History", + companyId, + contactId: null, + firstMessageAt: sentAt, + lastMessageAt: sentAt, + messageCount: 1, + }, + select: { id: true }, + }); + await db.emailMessage.create({ + data: { + threadId: existingThread.id, + rfcMessageId: rfcId, + syncedByUserId: userId, + direction: "INBOUND", + fromEmail: `sender@${senderDomain}`, + fromName: "Sender", + recipients: [], + subject: "History", + snippet: "Body.", + body: "Body.", + sentAt, + }, + }); + await db.activity.create({ + data: { + type: "EMAIL", + subject: "History", + body: "Body.", + occurredAt: sentAt, + companyId, + contactId: null, + createdById: userId, + emailThreadId: existingThread.id, + meta: { synced: true, source: "gmail" }, + }, + }); + + const message = gmailMessage(rfcId, `sender@${senderDomain}`, sentAt); + const svc = service( + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([]), + ); + + await svc.run({ contactId, email: contactEmail, companyId, userId }); + + const linked = await db.emailThread.aggregate({ + where: { contactId }, + _count: { _all: true }, + }); + expect(linked._count._all).toBeGreaterThan(0); + }); + + it("skips Gmail but still attempts Calendar when the Gmail token needs reconnect", async () => { + const svc = service( + gmailStub({}), + tokenStub({ outcome: "needs-reconnect", reason: "expired" }), + calendarClientStub([ + calendarEvent( + `ical-gmailskip-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-04T10:00:00Z"), + ), + ]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); + + expect(result.gmail.status).toBe("skipped"); + expect(result.calendar).toEqual({ status: "synced", written: 1 }); + }); + + it("skips Calendar but still attempts Gmail when the Calendar token needs reconnect", async () => { + const rfcId = `history-calskip-${suffix}@mail.test`; + const message = gmailMessage( + rfcId, + `sender@${senderDomain}`, + new Date("2026-02-05T10:00:00Z"), + ); + const svc = service( + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), + tokenStub( + { outcome: "ok", accessToken: "gmail-token" }, + { outcome: "needs-reconnect", reason: "expired" }, + ), + calendarClientStub([]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); + + expect(result.gmail).toEqual({ status: "synced", written: 1 }); + expect(result.calendar.status).toBe("skipped"); + }); + + it("skips a source silently when it was never connected (no MailboxSync row)", async () => { + const bareUserId = `user-${suffix}-bare`; + await db.user.create({ + data: { + id: bareUserId, + name: "Bare", + email: `${bareUserId}@example.test`, + }, + }); + + const svc = service( + gmailStub({}), + tokenStub({ outcome: "ok", accessToken: "unused" }), + calendarClientStub([]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId: bareUserId, + }); + + expect(result.gmail.status).toBe("skipped"); + expect(result.calendar.status).toBe("skipped"); + }); + + it("does not duplicate a Gmail message the live sync already fully stored for this contact", async () => { + const rfcId = `history-dedup-${suffix}@mail.test`; + const rootId = ``; + const sentAt = new Date("2026-02-06T10:00:00Z"); + + const existingThread = await db.emailThread.create({ + data: { + rootMessageId: rootId, + subject: "History", + companyId, + contactId, + firstMessageAt: sentAt, + lastMessageAt: sentAt, + messageCount: 1, + }, + select: { id: true }, + }); + await db.emailMessage.create({ + data: { + threadId: existingThread.id, + rfcMessageId: rfcId, + syncedByUserId: userId, + direction: "INBOUND", + fromEmail: `sender@${senderDomain}`, + fromName: "Sender", + recipients: [], + subject: "History", + snippet: "Body.", + body: "Body.", + sentAt, + }, + }); + await db.activity.create({ + data: { + type: "EMAIL", + subject: "History", + body: "Body.", + occurredAt: sentAt, + companyId, + contactId, + createdById: userId, + emailThreadId: existingThread.id, + meta: { synced: true, source: "gmail" }, + }, + }); + + const message = gmailMessage(rfcId, `sender@${senderDomain}`, sentAt); + const svc = service( + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); + + expect(result.gmail).toEqual({ status: "synced", written: 0 }); + expect( + await db.emailMessage.count({ where: { rfcMessageId: rfcId } }), + ).toBe(1); + }); + + it("respects the Gmail and Calendar result caps", async () => { + const gmailCaptured: GmailStubOptions["captured"] = {}; + const calendarCaptured: Parameters[1] = {}; + const svc = service( + gmailStub({ captured: gmailCaptured }), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([], calendarCaptured), + ); + + await svc.run({ contactId, email: contactEmail, companyId, userId }); + + expect(gmailCaptured.maxResults).toBe(BACKFILL_GMAIL_MAX_RESULTS); + expect(calendarCaptured.maxResults).toBe(BACKFILL_CALENDAR_MAX_RESULTS); + }); + + it("searches roughly the last BACKFILL_WINDOW_MONTHS months", async () => { + const calendarCaptured: Parameters[1] = {}; + const svc = service( + gmailStub({}), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([], calendarCaptured), + ); + + const before = Date.now(); + await svc.run({ contactId, email: contactEmail, companyId, userId }); + + const expectedAfter = new Date(before); + expectedAfter.setMonth(expectedAfter.getMonth() - BACKFILL_WINDOW_MONTHS); + + const actualAfter = new Date(calendarCaptured.timeMin as string).getTime(); + expect(Math.abs(actualAfter - expectedAfter.getTime())).toBeLessThan( + 60_000, + ); + }); + + it("stays resilient when the Gmail search throws -- Calendar still completes", async () => { + const svc = service( + gmailStub({ searchThrows: true }), + tokenStub({ outcome: "ok", accessToken: "gmail-token" }), + calendarClientStub([ + calendarEvent( + `ical-resilience-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-07T10:00:00Z"), + ), + ]), + ); + + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); + + expect(result.gmail.status).toBe("skipped"); + expect(result.calendar).toEqual({ status: "synced", written: 1 }); + }); +}); diff --git a/apps/api/test/contacts-create-backfill.spec.ts b/apps/api/test/contacts-create-backfill.spec.ts new file mode 100644 index 000000000..23190b0e4 --- /dev/null +++ b/apps/api/test/contacts-create-backfill.spec.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { AgentQueueService } from "../src/agent/agent-queue.service"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import type { ContactHistoryBackfillService } from "../src/contacts/contact-history-backfill.service"; +import { ContactsService } from "../src/contacts/contacts.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "contacts-create-backfill-spec"; +const domain = `create-backfill-${suffix}.test`; + +const agent = { + contactCreated: async () => true, + companyCreated: async () => undefined, + companyRequested: async () => true, + withCrmEvents: withDiscardedCrmEvents, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const queue = new AgentQueueService(db); +const directory = new CompanyDirectoryService(agent); +const fields = new FieldsService(db, agent); + +type RunCall = { + contactId: string; + email: string; + companyId: string | null; + userId: string; +}; + +function historyStub(options: { + calls: RunCall[]; + rejects?: boolean; +}): ContactHistoryBackfillService { + return { + run: async (input: RunCall) => { + options.calls.push(input); + if (options.rejects) throw new Error("history backfill exploded"); + return { + gmail: { status: "skipped", written: 0 }, + calendar: { status: "skipped", written: 0 }, + }; + }, + } as unknown as ContactHistoryBackfillService; +} + +async function clean() { + await db.contact.deleteMany({ where: { email: { endsWith: `@${domain}` } } }); +} + +beforeAll(clean); +afterAll(clean); + +describe("ContactsService.create() triggers the history backfill", () => { + it("calls history.run with the right fields when actorId is given and the contact has an email", async () => { + const calls: RunCall[] = []; + const contacts = new ContactsService( + db, + directory, + agent, + queue, + stamp, + fields, + historyStub({ calls }), + ); + + const created = await contacts.create( + { firstName: "Actor", email: `with-actor@${domain}` }, + `actor-${suffix}`, + ); + // companyForEmail() may auto-create a company from the domain -- + // not the point of this test, so read back whatever it actually set. + const stored = await db.contact.findUniqueOrThrow({ + where: { id: created.id }, + select: { companyId: true }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + contactId: created.id, + email: `with-actor@${domain}`, + companyId: stored.companyId, + userId: `actor-${suffix}`, + }); + }); + + it("does not call history.run when actorId is omitted", async () => { + const calls: RunCall[] = []; + const contacts = new ContactsService( + db, + directory, + agent, + queue, + stamp, + fields, + historyStub({ calls }), + ); + + await contacts.create({ + firstName: "NoActor", + email: `no-actor@${domain}`, + }); + + expect(calls).toHaveLength(0); + }); + + it("does not call history.run when the contact has no email", async () => { + const calls: RunCall[] = []; + const contacts = new ContactsService( + db, + directory, + agent, + queue, + stamp, + fields, + historyStub({ calls }), + ); + + await contacts.create({ firstName: "NoEmail" }, `actor-${suffix}`); + + expect(calls).toHaveLength(0); + }); + + it("catches and does not surface a history.run rejection", async () => { + const calls: RunCall[] = []; + const contacts = new ContactsService( + db, + directory, + agent, + queue, + stamp, + fields, + historyStub({ calls, rejects: true }), + ); + + const result = await contacts.create( + { firstName: "Resilient", email: `resilient@${domain}` }, + `actor-${suffix}`, + ); + + expect(result.firstName).toBe("Resilient"); + expect(calls).toHaveLength(1); + }); +}); diff --git a/apps/api/test/fields.spec.ts b/apps/api/test/fields.spec.ts index acecd1376..6cb76fdbb 100644 --- a/apps/api/test/fields.spec.ts +++ b/apps/api/test/fields.spec.ts @@ -12,6 +12,7 @@ import { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; import { CompanyDirectoryService } from "../src/companies/company-directory.service"; import type { FaviconService } from "../src/companies/favicon.service"; +import type { ContactHistoryBackfillService } from "../src/contacts/contact-history-backfill.service"; import { ContactsService } from "../src/contacts/contacts.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; @@ -60,6 +61,14 @@ const companies = new CompaniesService( conversion, fields, ); +// actorId is never passed by this spec's contacts.create({...}) calls, so +// the backfill branch never fires -- history.run is a no-op stub. +const history = { + run: async () => ({ + gmail: { status: "skipped" as const, written: 0 }, + calendar: { status: "skipped" as const, written: 0 }, + }), +} as unknown as ContactHistoryBackfillService; const contacts = new ContactsService( db, new CompanyDirectoryService(agent), @@ -67,6 +76,7 @@ const contacts = new ContactsService( queue, stamp, fields, + history, ); const deals = new DealsService(db, agent, stamp, conversion, fields); diff --git a/apps/api/test/gmail-client.spec.ts b/apps/api/test/gmail-client.spec.ts new file mode 100644 index 000000000..d56211763 --- /dev/null +++ b/apps/api/test/gmail-client.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { GmailClient, WORK_MAIL_QUERY } from "../src/google/gmail.client"; +import { MailboxApiClient } from "../src/mailbox/mailbox-api.client"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubCapturingUrl() { + const calls: URL[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(new URL(input.toString())); + return new Response(JSON.stringify({ messages: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return { calls }; +} + +const client = new GmailClient(new MailboxApiClient()); + +describe("GmailClient.listMessages", () => { + it("sends the same q string as before when no extra query is given", async () => { + const { calls } = stubCapturingUrl(); + await client.listMessages("token", { + after: new Date("2026-01-01T00:00:00Z"), + before: new Date("2026-02-01T00:00:00Z"), + }); + + const q = calls[0]?.searchParams.get("q"); + expect(q).toBe(`${WORK_MAIL_QUERY} after:1767225600 before:1769904000`); + }); +}); + +describe("GmailClient.searchByParticipant", () => { + it("builds a q combining the work-mail filter, the date window, and the participant clause", async () => { + const { calls } = stubCapturingUrl(); + await client.searchByParticipant("token", { + email: "dvignault@scalair.fr", + after: new Date("2026-01-01T00:00:00Z"), + before: new Date("2026-02-01T00:00:00Z"), + }); + + const q = calls[0]?.searchParams.get("q"); + expect(q).toBe( + `${WORK_MAIL_QUERY} after:1767225600 before:1769904000 (from:dvignault@scalair.fr OR to:dvignault@scalair.fr)`, + ); + }); + + it("forwards maxResults", async () => { + const { calls } = stubCapturingUrl(); + await client.searchByParticipant("token", { + email: "dvignault@scalair.fr", + after: new Date("2026-01-01T00:00:00Z"), + before: new Date("2026-02-01T00:00:00Z"), + maxResults: 42, + }); + + expect(calls[0]?.searchParams.get("maxResults")).toBe("42"); + }); +}); diff --git a/apps/api/test/gmail-message-parser.spec.ts b/apps/api/test/gmail-message-parser.spec.ts new file mode 100644 index 000000000..f360ee1bb --- /dev/null +++ b/apps/api/test/gmail-message-parser.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import type { GmailMessage } from "../src/google/gmail.client"; +import { + BULK_MAIL_RECIPIENT_THRESHOLD, + parseGmailMessage, +} from "../src/google/gmail-message-parser"; + +function messageWith( + headers: Record, + overrides: Partial = {}, +): GmailMessage { + return { + id: "msg-1", + payload: { + headers: Object.entries(headers).map(([name, value]) => ({ + name, + value, + })), + }, + ...overrides, + }; +} + +const BASE_HEADERS = { + "message-id": "", + from: "Damien Vignault ", + to: "Franck ", + cc: "Assistant ", + subject: "RE: appel d'offre", + date: "Tue, 1 Sep 2026 10:00:00 +0200", +}; + +describe("parseGmailMessage", () => { + it("parses a valid message into an IncomingMessage", () => { + const parsed = parseGmailMessage(messageWith(BASE_HEADERS)); + + expect(parsed).not.toBeNull(); + expect(parsed?.rfcMessageId).toBe("abc123@mail.example.com"); + expect(parsed?.from).toEqual({ + email: "dvignault@scalair.fr", + name: "Damien Vignault", + }); + expect(parsed?.subject).toBe("RE: appel d'offre"); + }); + + it("returns null when message-id is missing", () => { + const { "message-id": _drop, ...rest } = BASE_HEADERS; + expect(parseGmailMessage(messageWith(rest))).toBeNull(); + }); + + it("returns null when from is missing", () => { + const { from: _drop, ...rest } = BASE_HEADERS; + expect(parseGmailMessage(messageWith(rest))).toBeNull(); + }); + + it("returns null when from cannot be parsed as an address", () => { + expect( + parseGmailMessage(messageWith({ ...BASE_HEADERS, from: "not-an-email" })), + ).toBeNull(); + }); + + it("uses internalDate when present, over the date header", () => { + const parsed = parseGmailMessage( + messageWith(BASE_HEADERS, { internalDate: "1767225600000" }), + ); + expect(parsed?.sentAt.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("falls back to the date header when internalDate is absent", () => { + const parsed = parseGmailMessage(messageWith(BASE_HEADERS)); + expect(parsed?.sentAt.toISOString()).toBe("2026-09-01T08:00:00.000Z"); + }); + + it("returns null when internalDate is invalid and there is no date header", () => { + const { date: _drop, ...rest } = BASE_HEADERS; + const parsed = parseGmailMessage( + messageWith(rest, { internalDate: "not-a-number" }), + ); + expect(parsed).toBeNull(); + }); + + it("derives rootId from references when present", () => { + const parsed = parseGmailMessage( + messageWith({ + ...BASE_HEADERS, + references: " ", + }), + ); + expect(parsed?.rootId).toBe("root-msg@mail.example.com"); + }); + + it("falls back to the normalised message-id as rootId when there is no thread history", () => { + const parsed = parseGmailMessage(messageWith(BASE_HEADERS)); + expect(parsed?.rootId).toBe("abc123@mail.example.com"); + }); + + it("splits to/cc headers into recipients with the correct kind", () => { + const parsed = parseGmailMessage(messageWith(BASE_HEADERS)); + + expect(parsed?.recipients).toEqual([ + { email: "franck@vigieproc.fr", name: "Franck", kind: "to" }, + { email: "assistant@vigieproc.fr", name: "Assistant", kind: "cc" }, + ]); + }); + + it("returns null when a list-unsubscribe header is present -- a mailing-list broadcast, not personal correspondence", () => { + const parsed = parseGmailMessage( + messageWith({ + ...BASE_HEADERS, + "list-unsubscribe": "", + }), + ); + expect(parsed).toBeNull(); + }); + + it(`returns null when there are more than ${BULK_MAIL_RECIPIENT_THRESHOLD} recipients -- a broadcast CC list, not personal correspondence`, () => { + const manyRecipients = Array.from( + { length: BULK_MAIL_RECIPIENT_THRESHOLD + 1 }, + (_, i) => `person${i}@example.com`, + ).join(", "); + + const parsed = parseGmailMessage( + messageWith({ ...BASE_HEADERS, to: manyRecipients, cc: "" }), + ); + expect(parsed).toBeNull(); + }); + + it(`still parses a message with exactly ${BULK_MAIL_RECIPIENT_THRESHOLD} recipients -- the threshold is a ceiling, not a trap`, () => { + const recipients = Array.from( + { length: BULK_MAIL_RECIPIENT_THRESHOLD }, + (_, i) => `person${i}@example.com`, + ).join(", "); + + const parsed = parseGmailMessage( + messageWith({ ...BASE_HEADERS, to: recipients, cc: "" }), + ); + expect(parsed).not.toBeNull(); + }); +}); diff --git a/apps/api/test/mailbox-thread-writer-preresolved.spec.ts b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts new file mode 100644 index 000000000..178bbc857 --- /dev/null +++ b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts @@ -0,0 +1,380 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db, type MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import { + type IncomingMessage, + ThreadWriterService, +} from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "thread-writer-preresolved-spec"; +// A domain no company/contact fixture below is ever registered under, so +// that if match.resolve() were mistakenly still invoked, it would find +// nothing (proving preresolved actually bypasses it, not just happens to +// agree with it). +const unmatchedDomain = `unmatched-${suffix}.test`; +const mailbox = `rep-${suffix}@example.test`; +const userId = `user-${suffix}`; + +const agent = { + contactCreated: async () => true, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => true, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const threads = new ThreadWriterService(db, match, stamp); + +let row: MailboxSync; +let companyId: string; +let contactId: string; + +function message( + id: string, + sentAt: Date, + rootId: string, + from = `sender@${unmatchedDomain}`, +): IncomingMessage { + return { + rfcMessageId: id, + rootId, + subject: "Pricing", + from: { email: from, name: "Unmatched Sender" }, + recipients: [{ email: mailbox, name: "Test Rep", kind: "to" }], + body: "Body.", + sentAt, + gmailMessageId: null, + outlookMessageId: null, + outlookWebLink: null, + }; +} + +async function clean() { + await db.emailThread.deleteMany({ + where: { rootMessageId: { startsWith: ` { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + row = await db.mailboxSync.create({ + data: { userId, source: "gmail", autoCreate: false }, + }); + + const company = await db.company.create({ + data: { name: "Preresolved Co", domain: `known-${suffix}.test` }, + select: { id: true }, + }); + companyId = company.id; + + const contact = await db.contact.create({ + data: { + firstName: "Known", + lastName: "Contact", + email: `known-${suffix}@known.test`, + companyId: company.id, + }, + select: { id: true }, + }); + contactId = contact.id; +}); + +afterAll(clean); + +describe("store() with preresolved", () => { + it("writes a brand-new thread using preresolved ids, without calling match.resolve", async () => { + const stored = await threads.store( + row, + { mailbox, origin: "gmail" }, + message( + ``, + new Date("2026-01-01T10:00:00Z"), + ``, + ), + await threads.context(), + { companyId, contactId }, + ); + + expect(stored).toBe(true); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: `` }, + select: { companyId: true, contactId: true }, + }); + expect(thread?.companyId).toBe(companyId); + expect(thread?.contactId).toBe(contactId); + }); + + it("bails out with false when preresolved is {null, null} on a brand-new thread", async () => { + const stored = await threads.store( + row, + { mailbox, origin: "gmail" }, + message( + ``, + new Date("2026-01-01T10:00:00Z"), + ``, + ), + await threads.context(), + { companyId: null, contactId: null }, + ); + + expect(stored).toBe(false); + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: `` }, + }); + expect(thread).toBeNull(); + }); +}); + +describe("store() relink (only ever active when preresolved is passed)", () => { + it("relinks a thread the live sync stored by company only, and moves the contact's lastActivityAt", async () => { + // Seed exactly what the live sync leaves for a company-only match: + // companyId set, contactId null. + const rfcMessageId = ``; + const rootMessageId = ``; + const sentAt = new Date("2026-01-05T10:00:00Z"); + + const thread = await db.emailThread.create({ + data: { + rootMessageId, + subject: "Pricing", + companyId, + contactId: null, + firstMessageAt: sentAt, + lastMessageAt: sentAt, + messageCount: 1, + }, + select: { id: true }, + }); + await db.emailMessage.create({ + data: { + threadId: thread.id, + rfcMessageId, + syncedByUserId: userId, + direction: "INBOUND", + fromEmail: `sender@${unmatchedDomain}`, + fromName: "Unmatched Sender", + recipients: [], + subject: "Pricing", + snippet: "Body.", + body: "Body.", + sentAt, + }, + }); + await db.activity.create({ + data: { + type: "EMAIL", + subject: "Pricing", + body: "Body.", + occurredAt: sentAt, + companyId, + contactId: null, + createdById: userId, + emailThreadId: thread.id, + meta: { synced: true, source: "gmail" }, + }, + }); + + // touch() is a monotonic max-update -- reset so this test's older + // seeded sentAt can prove it moves forward, independent of whatever + // other tests in this file already bumped it to "now". + await db.contact.update({ + where: { id: contactId }, + data: { lastActivityAt: null }, + }); + + const stored = await threads.store( + row, + { mailbox, origin: "gmail" }, + message(rfcMessageId, sentAt, rootMessageId), + await threads.context(), + { companyId, contactId }, + ); + + // Not newly written -- the message already existed -- but relinked. + expect(stored).toBe(false); + + const relinkedThread = await db.emailThread.findUnique({ + where: { id: thread.id }, + select: { contactId: true, companyId: true }, + }); + expect(relinkedThread?.contactId).toBe(contactId); + expect(relinkedThread?.companyId).toBe(companyId); + + const activity = await db.activity.findUnique({ + where: { emailThreadId: thread.id }, + select: { contactId: true }, + }); + expect(activity?.contactId).toBe(contactId); + + const after = await db.contact.findUnique({ + where: { id: contactId }, + select: { lastActivityAt: true }, + }); + expect(after?.lastActivityAt).not.toBeNull(); + }); + + it("does not overwrite a thread that already has a different contact", async () => { + const rfcMessageId = ``; + const rootMessageId = ``; + const sentAt = new Date("2026-01-06T10:00:00Z"); + + const otherContact = await db.contact.create({ + data: { + firstName: "Other", + lastName: "Contact", + email: `other-${suffix}@${unmatchedDomain}`, + companyId, + }, + select: { id: true }, + }); + + const thread = await db.emailThread.create({ + data: { + rootMessageId, + subject: "Pricing", + companyId, + contactId: otherContact.id, + firstMessageAt: sentAt, + lastMessageAt: sentAt, + messageCount: 1, + }, + select: { id: true }, + }); + await db.emailMessage.create({ + data: { + threadId: thread.id, + rfcMessageId, + syncedByUserId: userId, + direction: "INBOUND", + fromEmail: `sender@${unmatchedDomain}`, + fromName: "Unmatched Sender", + recipients: [], + subject: "Pricing", + snippet: "Body.", + body: "Body.", + sentAt, + }, + }); + await db.activity.create({ + data: { + type: "EMAIL", + subject: "Pricing", + body: "Body.", + occurredAt: sentAt, + companyId, + contactId: otherContact.id, + createdById: userId, + emailThreadId: thread.id, + meta: { synced: true, source: "gmail" }, + }, + }); + + await threads.store( + row, + { mailbox, origin: "gmail" }, + message(rfcMessageId, sentAt, rootMessageId), + await threads.context(), + { companyId, contactId }, + ); + + const untouched = await db.emailThread.findUnique({ + where: { id: thread.id }, + select: { contactId: true }, + }); + expect(untouched?.contactId).toBe(otherContact.id); + }); + + it("does not overwrite a thread that belongs to a different company", async () => { + const otherCompany = await db.company.create({ + data: { name: "Other Co", domain: `other-${suffix}.test` }, + select: { id: true }, + }); + + const rfcMessageId = ``; + const rootMessageId = ``; + const sentAt = new Date("2026-01-07T10:00:00Z"); + + const thread = await db.emailThread.create({ + data: { + rootMessageId, + subject: "Pricing", + companyId: otherCompany.id, + contactId: null, + firstMessageAt: sentAt, + lastMessageAt: sentAt, + messageCount: 1, + }, + select: { id: true }, + }); + await db.emailMessage.create({ + data: { + threadId: thread.id, + rfcMessageId, + syncedByUserId: userId, + direction: "INBOUND", + fromEmail: `sender@${unmatchedDomain}`, + fromName: "Unmatched Sender", + recipients: [], + subject: "Pricing", + snippet: "Body.", + body: "Body.", + sentAt, + }, + }); + await db.activity.create({ + data: { + type: "EMAIL", + subject: "Pricing", + body: "Body.", + occurredAt: sentAt, + companyId: otherCompany.id, + contactId: null, + createdById: userId, + emailThreadId: thread.id, + meta: { synced: true, source: "gmail" }, + }, + }); + + await threads.store( + row, + { mailbox, origin: "gmail" }, + message(rfcMessageId, sentAt, rootMessageId), + await threads.context(), + { companyId, contactId }, + ); + + const untouched = await db.emailThread.findUnique({ + where: { id: thread.id }, + select: { contactId: true }, + }); + expect(untouched?.contactId).toBeNull(); + }); +}); diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index cac084ab1..630a8b6df 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -5,6 +5,7 @@ import { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompaniesService } from "../src/companies/companies.service"; import { CompanyDirectoryService } from "../src/companies/company-directory.service"; import { FaviconService } from "../src/companies/favicon.service"; +import type { ContactHistoryBackfillService } from "../src/contacts/contact-history-backfill.service"; import { ContactsService } from "../src/contacts/contacts.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; @@ -37,6 +38,14 @@ const queue = new AgentQueueService(db); const conversion = new ConversionService(db); const fields = new FieldsService(db, agent); +// actorId is never passed by this spec's contacts.create({...}) calls, so +// the backfill branch never fires -- history.run is a no-op stub. +const history = { + run: async () => ({ + gmail: { status: "skipped" as const, written: 0 }, + calendar: { status: "skipped" as const, written: 0 }, + }), +} as unknown as ContactHistoryBackfillService; const contacts = new ContactsService( db, directory, @@ -44,6 +53,7 @@ const contacts = new ContactsService( queue, stamp, fields, + history, ); const companies = new CompaniesService( db, diff --git a/apps/api/test/vigieprocure-bridge.spec.ts b/apps/api/test/vigieprocure-bridge.spec.ts new file mode 100644 index 000000000..f18446df0 --- /dev/null +++ b/apps/api/test/vigieprocure-bridge.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Prisma } from "@crm/db"; +import { + envoyerEvenementVigieProcure, + vigieProcureBridge, +} from "../src/agent/vigieprocure-bridge"; + +let previousUrl: string | undefined; +let previousSecret: string | undefined; + +beforeEach(() => { + previousUrl = process.env.VIGIEPROCURE_WEBHOOK_URL; + previousSecret = process.env.VIGIEPROCURE_WEBHOOK_SECRET; + delete process.env.VIGIEPROCURE_WEBHOOK_URL; + delete process.env.VIGIEPROCURE_WEBHOOK_SECRET; +}); + +afterEach(() => { + if (previousUrl === undefined) delete process.env.VIGIEPROCURE_WEBHOOK_URL; + else process.env.VIGIEPROCURE_WEBHOOK_URL = previousUrl; + if (previousSecret === undefined) + delete process.env.VIGIEPROCURE_WEBHOOK_SECRET; + else process.env.VIGIEPROCURE_WEBHOOK_SECRET = previousSecret; +}); + +describe("vigieProcureBridge", () => { + it("returns null when the secret is unset -- no bridge, not an open one", () => { + process.env.VIGIEPROCURE_WEBHOOK_URL = + "https://api.vigieproc.fr/api/v1/crm/webhooks"; + expect(vigieProcureBridge()).toBeNull(); + }); + + it("returns null when the url is unset", () => { + process.env.VIGIEPROCURE_WEBHOOK_SECRET = "test-secret"; + expect(vigieProcureBridge()).toBeNull(); + }); + + it("returns a bridge when both url and secret are set", () => { + process.env.VIGIEPROCURE_WEBHOOK_URL = + "https://api.vigieproc.fr/api/v1/crm/webhooks"; + process.env.VIGIEPROCURE_WEBHOOK_SECRET = "test-secret"; + + const result = vigieProcureBridge(); + expect(result).not.toBeNull(); + expect(result?.secret).toBe("test-secret"); + expect(result?.url.toString()).toBe( + "https://api.vigieproc.fr/api/v1/crm/webhooks", + ); + }); +}); + +describe("envoyerEvenementVigieProcure", () => { + it("returns false silently when no bridge is configured -- best effort, never throws", async () => { + const logged: Prisma.InputJsonObject[] = []; + const result = await envoyerEvenementVigieProcure( + { eventId: "task-1", kind: "deal.stage.changed", payload: {} }, + { debug: (obj) => logged.push(obj) }, + ); + + expect(result).toBe(false); + expect(logged).toHaveLength(0); + }); +}); diff --git a/apps/api/test/vigieprocure-companies-client.spec.ts b/apps/api/test/vigieprocure-companies-client.spec.ts new file mode 100644 index 000000000..b993e1d6b --- /dev/null +++ b/apps/api/test/vigieprocure-companies-client.spec.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { z } from "zod"; +import { + resolveSiren, + vigieProcureApi, +} from "../src/companies/vigieprocure-companies.client"; + +const realFetch = globalThis.fetch; + +let previousUrl: string | undefined; +let previousJwt: string | undefined; + +beforeEach(() => { + previousUrl = process.env.VIGIEPROCURE_API_URL; + previousJwt = process.env.VIGIEPROCURE_API_JWT; + delete process.env.VIGIEPROCURE_API_URL; + delete process.env.VIGIEPROCURE_API_JWT; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + if (previousUrl === undefined) delete process.env.VIGIEPROCURE_API_URL; + else process.env.VIGIEPROCURE_API_URL = previousUrl; + if (previousJwt === undefined) delete process.env.VIGIEPROCURE_API_JWT; + else process.env.VIGIEPROCURE_API_JWT = previousJwt; +}); + +function stub(status: number, body: z.core.util.JSONType): void { + globalThis.fetch = (async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; +} + +describe("vigieProcureApi", () => { + it("returns null when the JWT is unset -- not an unauthenticated call", () => { + process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr"; + expect(vigieProcureApi()).toBeNull(); + }); + + it("returns null when the URL is unset", () => { + process.env.VIGIEPROCURE_API_JWT = "test-jwt"; + expect(vigieProcureApi()).toBeNull(); + }); + + it("returns a config when both are set", () => { + process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr"; + process.env.VIGIEPROCURE_API_JWT = "test-jwt"; + + const result = vigieProcureApi(); + expect(result).not.toBeNull(); + expect(result?.jwt).toBe("test-jwt"); + expect(result?.url.toString()).toBe( + "https://api.vigieproc.fr/api/v1/companies/resolve", + ); + }); +}); + +describe("resolveSiren", () => { + it("degrades to not-configured when the env is unset -- never crashes", async () => { + const result = await resolveSiren({ name: "Acme" }); + expect(result).toEqual({ outcome: "not-configured" }); + }); + + it("maps a single exact candidate through", async () => { + process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr"; + process.env.VIGIEPROCURE_API_JWT = "test-jwt"; + stub(200, { + items: [ + { + siren: "424982650", + siret: "42498265000012", + name_canonical_full: "SCC FRANCE", + legal_form_label: "SAS", + naf_code: "4651Z", + naf_label: "Commerce de gros", + city: "Suresnes", + department: "92", + confidence: "exact", + matched_on: "name", + }, + ], + count: 1, + total_matches: 1, + query: { name: "SCC FRANCE" }, + provenance: {}, + }); + + const result = await resolveSiren({ name: "SCC FRANCE" }); + expect(result.outcome).toBe("ok"); + if (result.outcome === "ok") { + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]?.siren).toBe("424982650"); + expect(result.candidates[0]?.confidence).toBe("exact"); + } + }); + + it("maps 401 to unauthorized", async () => { + process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr"; + process.env.VIGIEPROCURE_API_JWT = "test-jwt"; + stub(401, { detail: "Not authenticated" }); + + const result = await resolveSiren({ name: "Acme" }); + expect(result.outcome).toBe("unauthorized"); + }); + + it("treats a malformed body as failed, not a crash", async () => { + process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr"; + process.env.VIGIEPROCURE_API_JWT = "test-jwt"; + stub(200, { unexpected: "shape" }); + + const result = await resolveSiren({ name: "Acme" }); + expect(result.outcome).toBe("ok"); + if (result.outcome === "ok") { + expect(result.candidates).toEqual([]); + } + }); +}); diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile new file mode 100644 index 000000000..39afac565 --- /dev/null +++ b/apps/app/Dockerfile @@ -0,0 +1,53 @@ +# apps/app/Dockerfile +FROM oven/bun:1.3.12 AS deps +WORKDIR /repo +COPY package.json bun.lock turbo.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/api/scripts/ apps/api/scripts/ +COPY apps/app/package.json apps/app/package.json +COPY apps/agent/package.json apps/agent/package.json +COPY packages/ packages/ +# @crm/db's postinstall runs `prisma generate`, and prisma.config.ts resolves +# DATABASE_URL eagerly (env(...) throws if unset) even though `generate` never +# opens a connection. A build-time placeholder satisfies that check; apps/app +# never connects to the database directly at runtime — the real value (used +# by the api/agent containers) is irrelevant here. +ENV DATABASE_URL="postgresql://user:password@localhost:5432/db?schema=public" +# apps/app depends on the "api" workspace package (for its tRPC app-router +# type), which carries its own postinstall (scripts/chmod-trpc-binary.mjs, +# copied above) that `bun install` runs as part of resolving this workspace. +RUN bun install --frozen-lockfile + +FROM deps AS build +COPY . . +ARG API_URL +ARG APP_URL +ENV API_URL=${API_URL} +ENV APP_URL=${APP_URL} +# Build @crm/db (prisma generate) and api first via turbo/bun — both build +# fine under bun. `next build` itself does NOT: under bun, Next.js 16's +# "Collecting page data" phase (worker_threads loading a compiled runtime +# chunk as CommonJS) segfaults Bun outright ("Expected CommonJS module to +# have a function wrapper... Bun has crashed"), a real Bun/Next-worker +# incompatibility, not a code bug. Fix: run `next build` itself with a real +# Node.js binary (copied from the official node image — root package.json's +# engines field already requires node >=22, this satisfies it) while bun +# still owns install/db/api. Turbo isn't needed for this single command. +# Pinned to the exact patch tag verified to run standalone inside this Bun +# image (not the floating `22-slim` tag) — a future patch bump could ship a +# different glibc than what was tested here. +COPY --from=node:22.23.2-slim /usr/local/bin/node /usr/local/bin/node +RUN bunx turbo run build --filter=api +RUN cd apps/app && /usr/local/bin/node node_modules/.bin/next build + +# Nothing in this app runs under bun at runtime (`next start` needs real +# Node — see build stage comment), so the runtime stage is Node directly +# rather than Bun-plus-a-borrowed-node-binary: simpler, and it removes the +# risk of a rebuild pulling a Bun base and a Node base with mismatched +# glibc versions. +FROM node:22.23.2-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=build /repo /repo +EXPOSE 3000 +CMD ["sh", "-c", "cd apps/app && node node_modules/.bin/next start -H 0.0.0.0 -p 3000"] diff --git a/apps/app/Dockerfile.dockerignore b/apps/app/Dockerfile.dockerignore new file mode 100644 index 000000000..f855c3e4d --- /dev/null +++ b/apps/app/Dockerfile.dockerignore @@ -0,0 +1,18 @@ +# apps/app/Dockerfile.dockerignore +# +# Named to match Docker's Dockerfile-specific ignore-file convention +# (.dockerignore, resolved at the +# build context root) — same mechanism verified empirically in Task 3 +# (apps/api/Dockerfile.dockerignore): a plain apps/app/.dockerignore is NOT +# picked up because this build's context is the repo root +# (`docker build -f apps/app/Dockerfile ... .`), not apps/app/. +node_modules +**/node_modules +.turbo +**/.turbo +dist +**/dist +.next +**/.next +.git +*.log diff --git a/apps/app/app/t/[site]/route.ts b/apps/app/app/t/[site]/route.ts index 98e5e16f5..434a72af1 100644 --- a/apps/app/app/t/[site]/route.ts +++ b/apps/app/app/t/[site]/route.ts @@ -1,11 +1,11 @@ import { CONFIG_MAX_AGE_SECONDS, isSiteId } from "@crm/db/tracking"; -import { API_URL } from "@/lib/env"; +import { API_URL, APP_URL } from "@/lib/env"; import { trackerSource } from "@/lib/tracking/tracker"; const EMPTY = "/* no tracking site is configured */\n"; export async function GET( - request: Request, + _request: Request, { params }: { params: Promise<{ site: string }> }, ): Promise { const { site } = await params; @@ -29,7 +29,13 @@ export async function GET( if (!payload?.config) return empty(); - const origin = new URL(request.url).origin; + // `new URL(request.url).origin` reflects the raw socket the app server + // sees, not the public Host -- under `next start` behind nginx this comes + // back as the container's bind address (0.0.0.0:3000), never the real + // public origin, confirmed by forcing Host/X-Forwarded-* headers directly + // against the container and observing no change. APP_URL is the known-good + // public origin already used for this exact purpose elsewhere in the stack. + const origin = APP_URL; const source = trackerSource( payload.config as Parameters[0], `${origin}/api/t/e`, diff --git a/apps/app/components/crm/company-siren-field.tsx b/apps/app/components/crm/company-siren-field.tsx new file mode 100644 index 000000000..044fea0f6 --- /dev/null +++ b/apps/app/components/crm/company-siren-field.tsx @@ -0,0 +1,206 @@ +"use client"; + +import Renew from "@carbon/icons-react/es/Renew"; +import Search from "@carbon/icons-react/es/Search"; +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { Spinner } from "@crm/ui/components/spinner"; +import { cn } from "@crm/ui/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { PROPERTY_LABEL, PROPERTY_ROW } from "@/components/detail-sheet"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type ResolveResult = RouterOutputs["companies"]["resolveSiren"]; +type SirenCandidate = Extract< + ResolveResult, + { outcome: "ok" } +>["candidates"][number]; + +function formatSiren(siren: string): string { + return siren.replace(/(\d{3})(?=\d)/g, "$1 ").trim(); +} + +/** + * Affiche le SIREN d une company, ou propose de le resoudre via + * VigieProcure. La resolution est une mutation (jamais un effet declenche + * au chargement) -- le clic explicite reste le seul declencheur, y compris + * pour l'ecriture automatique sur candidat unique "exact" (doctrine + * validee par Franck : exception ciblee et reversible, pas un backfill). + */ +export function CompanySirenField({ + companyId, + siren, +}: { + companyId: string; + siren: string | null; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const [candidates, setCandidates] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); + + const setSirenMutation = useMutation( + trpc.companies.setSiren.mutationOptions({ + onSuccess: async (result) => { + if (result.outcome === "conflict") { + toast.error(result.reason); + return; + } + setCandidates(null); + await cache.company(companyId, { settle: "record" }); + toast.success(`SIREN ${formatSiren(result.siren)} enregistre.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const resolve = useMutation( + trpc.companies.resolveSiren.mutationOptions({ + onSuccess: (result) => { + if (result.outcome === "not-configured") { + setNotConfigured(true); + return; + } + if (result.outcome === "unauthorized" || result.outcome === "failed") { + toast.error(result.reason); + return; + } + + if (result.candidates.length === 0) { + toast("Aucun SIREN trouve pour cette fiche."); + return; + } + + const [only, ...rest] = result.candidates; + if (only && rest.length === 0 && only.confidence === "exact") { + setSirenMutation.mutate({ id: companyId, siren: only.siren }); + return; + } + + setCandidates(result.candidates); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (siren) { + return ( +
+ SIREN + {formatSiren(siren)} +
+ ); + } + + return ( +
+ SIREN +
+ {notConfigured ? ( + + Resolution SIREN non configuree sur cette installation. + + ) : ( + + )} + + {candidates ? ( + + setSirenMutation.mutate({ + id: companyId, + siren: candidate.siren, + }) + } + onRetry={() => resolve.mutate({ id: companyId })} + /> + ) : null} +
+
+ ); +} + +function SirenCandidateList({ + candidates, + saving, + onPick, + onRetry, +}: { + candidates: SirenCandidate[]; + saving: boolean; + onPick: (candidate: SirenCandidate) => void; + onRetry: () => void; +}) { + return ( +
+
+ + {candidates.length === 1 + ? "1 correspondance possible" + : `${candidates.length} correspondances possibles`} + + +
+ {candidates.map((candidate) => ( + + ))} +
+ ); +} diff --git a/apps/app/components/crm/record-sheet/company-sheet.tsx b/apps/app/components/crm/record-sheet/company-sheet.tsx index 90d89214a..e0af5ba94 100644 --- a/apps/app/components/crm/record-sheet/company-sheet.tsx +++ b/apps/app/components/crm/record-sheet/company-sheet.tsx @@ -25,6 +25,7 @@ import { formatMoney } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { AgentPanel } from "@/components/crm/agent-panel"; +import { CompanySirenField } from "@/components/crm/company-siren-field"; import { EnrichmentActions } from "@/components/crm/enrichment-actions"; import { EnrichmentIndicator } from "@/components/crm/enrichment-status"; import { FieldsCog, RecordFields } from "@/components/crm/fields/record-fields"; @@ -386,6 +387,7 @@ function CompanyOverview({ company }: { company: Company }) { saving={isSaving("country")} onSave={(country) => save({ country })} /> + /dev/null || true", "build": "turbo run build", diff --git a/packages/db/prisma/migrations/20260903000000_email_thread_exclusion/migration.sql b/packages/db/prisma/migrations/20260903000000_email_thread_exclusion/migration.sql new file mode 100644 index 000000000..5cffac343 --- /dev/null +++ b/packages/db/prisma/migrations/20260903000000_email_thread_exclusion/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "emailThread" ADD COLUMN "excludedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260904000000_add_company_siren/migration.sql b/packages/db/prisma/migrations/20260904000000_add_company_siren/migration.sql new file mode 100644 index 000000000..34831619c --- /dev/null +++ b/packages/db/prisma/migrations/20260904000000_add_company_siren/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "company" ADD COLUMN "siren" CHAR(9); + +-- CreateIndex +CREATE UNIQUE INDEX "company_siren_key" ON "company"("siren") WHERE ("archivedAt" IS NULL); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..85cfef140 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -298,6 +298,13 @@ model Company { country String? countryCode String? + // Identifiant SIRENE (France) -- rapproche la fiche du referentiel + // legal_entities/companies cote VigieProcure. Nullable : la plupart des + // fiches n'ont pas encore ete resolues (rapprochement manuel ou via + // GET /api/v1/companies/resolve cote api_v2), et les comptes non + // francais n'en auront jamais. + siren String? @db.Char(9) + phone String? email String? linkedinUrl String? @@ -332,6 +339,7 @@ model Company { updatedAt DateTime @updatedAt @@unique([domain], map: "company_domain_active_key", where: { archivedAt: null }) + @@unique([siren], map: "company_siren_key", where: { archivedAt: null }) @@index([ownerId]) @@index([name]) @@index([lastActivityAt]) @@ -1190,6 +1198,14 @@ model EmailThread { lastMessageAt DateTime messageCount Int @default(0) + // Set when a rep removes this thread from the CRM's synthesis (e.g. a + // mailing-list broadcast the automated bulk-mail filter didn't catch). + // Soft, not a delete: the row (and its rfcMessageId/rootMessageId dedup + // keys) stays in place so the live sync never re-imports it, and it can + // be un-excluded. Filtered out of relationship counts and the activity + // timeline everywhere they read from EmailThread/Activity. + excludedAt DateTime? + messages EmailMessage[] activity Activity?