From 1561073c78a4c0e693a91cad2501e522320d1855 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:38:18 +0000 Subject: [PATCH 01/36] chore: release release --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index cddbaefb9..c95106184 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa2ed919..e2dea4a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0](https://github.com/trycompai/crm/compare/v1.1.0...v1.2.0) (2026-08-07) + + +### Features + +* **api:** add microsoft sign-in and outlook mailbox sync ([#73](https://github.com/trycompai/crm/issues/73)) ([2a0062f](https://github.com/trycompai/crm/commit/2a0062fb76ffdaa5bbbb3848a5573b8b53cd0036)) + ## [1.1.0](https://github.com/trycompai/crm/compare/v1.0.0...v1.1.0) (2026-08-06) diff --git a/package.json b/package.json index 58f50e8b1..bd61fd43b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build": "turbo run build", From 167abb4ff0716f48970568626779d21818cdb97a Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 28 Aug 2026 18:30:34 +0200 Subject: [PATCH 02/36] infra: add production Dockerfile for apps/api Multi-stage build (deps/build/runtime) on oven/bun:1.3.12. Two fixes verified empirically against the plan's first draft: - apps/api's postinstall (chmod-trpc-binary.mjs) needs apps/api/scripts/ present in the deps stage, not just package.json, or bun install fails. - @crm/db's postinstall runs `prisma generate`, whose prisma.config.ts calls env("DATABASE_URL") eagerly and throws if unset -- even though generate never opens a connection. Deps stage sets a placeholder DATABASE_URL for install; the real value is supplied at `docker run` time and overrides it. Ignore file named apps/api/Dockerfile.dockerignore, not apps/api/.dockerignore: Task 3's build command uses the repo root as build context (docker build -f apps/api/Dockerfile ... .), and Docker only honors a Dockerfile-specific ignore file when it is named .dockerignore at the context root. Verified with a canary file: apps/api/.dockerignore was silently ignored (canary leaked into the image), Dockerfile.dockerignore excludes node_modules/dist/.git as intended. Verified: image builds, runs against the real crm_trycompai database (137.74.172.178), connects, and serves GET /health -> 503 {"status":"error","database":"down"} -- expected since Task 2 only provisioned an empty database, migrations run in Task 6. --- apps/api/Dockerfile | 26 ++++++++++++++++++++++++++ apps/api/Dockerfile.dockerignore | 15 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/Dockerfile.dockerignore diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 000000000..5324216ab --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,26 @@ +# 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 + +FROM oven/bun:1.3.12-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=build /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 From e9af38582e363368139e8f63093a1e2b5465d3a8 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 28 Aug 2026 18:53:37 +0200 Subject: [PATCH 03/36] infra: add production Dockerfile for apps/app --- apps/app/Dockerfile | 50 ++++++++++++++++++++++++++++++++ apps/app/Dockerfile.dockerignore | 18 ++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 apps/app/Dockerfile create mode 100644 apps/app/Dockerfile.dockerignore diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile new file mode 100644 index 000000000..d25b4b42d --- /dev/null +++ b/apps/app/Dockerfile @@ -0,0 +1,50 @@ +# 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. +COPY --from=node:22-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 + +FROM oven/bun:1.3.12-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=build /repo /repo +# `next start` also uses the same worker-thread machinery that crashes under +# bun at build time (see build stage comment) — run it with real Node too, +# for the same reason. bun itself is unused at runtime for this app; kept as +# the base image only because it already ships everything else needed. +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +EXPOSE 3000 +CMD ["sh", "-c", "cd apps/app && /usr/local/bin/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 From 00cebb9a0e721d88d1f0a2a8b3b02ea7dcbf29e0 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 28 Aug 2026 21:20:26 +0200 Subject: [PATCH 04/36] fix(infra): apps/app runtime image is node:22-slim directly, no bun --- apps/app/Dockerfile | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile index d25b4b42d..39afac565 100644 --- a/apps/app/Dockerfile +++ b/apps/app/Dockerfile @@ -33,18 +33,21 @@ ENV APP_URL=${APP_URL} # 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. -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +# 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 -FROM oven/bun:1.3.12-slim AS runtime +# 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 -# `next start` also uses the same worker-thread machinery that crashes under -# bun at build time (see build stage comment) — run it with real Node too, -# for the same reason. bun itself is unused at runtime for this app; kept as -# the base image only because it already ships everything else needed. -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node EXPOSE 3000 -CMD ["sh", "-c", "cd apps/app && /usr/local/bin/node node_modules/.bin/next start -H 0.0.0.0 -p 3000"] +CMD ["sh", "-c", "cd apps/app && node node_modules/.bin/next start -H 0.0.0.0 -p 3000"] From 595a6f6bb1aba2c681094b5c938bc8d0ea818cd2 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 28 Aug 2026 22:09:39 +0200 Subject: [PATCH 05/36] feat(agent): route the LLM through DeepSeek instead of Vercel AI Gateway, add Dockerfile Vigieproc fork adaptation -- avoids a Vercel account dependency for the main agent's LLM calls, cf. scripts/SPEC-fork-trycompai-crm.md Task 5. eve already accepts a raw AI SDK LanguageModel in place of a Gateway model-id string (PublicAgentStaticModelDefinition = string | LanguageModel), so no change to eve itself was needed. deepseekModel() uses @ai-sdk/openai's .chat(...) form explicitly, not the callable-provider shorthand: the shorthand defaults to the Responses API (POSTs to /responses), which DeepSeek's OpenAI-compatible endpoint does not implement -- found empirically via the unit test's provider assertion. `name: "deepseek"` overrides the default `openai` provider id. selectedModel()/ModelSelection are kept (not removed as the task's draft code suggested) because apps/agent/agent/subagents/agent_builder/agent.ts and test/model.integration.spec.ts still depend on them for the per-run custom-agent-builder model picker -- out of scope for this task, which only swaps the main agent's static model. Its db import is now lazy so importing this module (for deepseekModel(), from the new unit test) doesn't eagerly require DATABASE_URL/TEST_DATABASE_URL. The new unit test lives in apps/agent/test/ (not co-located next to model.ts as first drafted) to match this app's existing convention -- every other bun:test spec lives there, specifically because apps/agent's tsconfig include glob only covers agent/**, which has no bun:test types and fails tsc --noEmit otherwise. Dockerfile mirrors apps/api's and apps/app's (Tasks 3-4): DATABASE_URL placeholder for @crm/db's postinstall prisma generate, multi-stage bun build via turbo. Runtime CMD is `cd apps/agent && bun run start`, not `bun apps/agent/scripts/start.ts` directly -- start.ts spawns the `eve` CLI via child_process.spawn, which resolves against $PATH, and the image's system PATH does not include node_modules/.bin (only `bun run` augments it) -- confirmed empirically ("Executable not found in $PATH: eve" with the direct form, despite the binary existing on disk). No docker.io/docker CLI added to the runtime image: eve's sandbox backend selection (selectDefaultSandbox in its own source) only tries Docker if isDockerDaemonAvailableSync() is true, and falls back to microsandbox otherwise. With no docker socket mounted (that lands in Task 6), the container initializes its sandbox template and starts cleanly without the CLI -- confirmed by running it standalone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014caYm8q32Dzgm2TiTeKK8G --- apps/agent/Dockerfile | 35 ++++++++++++++++++++++++ apps/agent/Dockerfile.dockerignore | 18 +++++++++++++ apps/agent/agent/agent.ts | 10 +++---- apps/agent/agent/lib/model.ts | 43 ++++++++++++++++++++++++++++-- apps/agent/package.json | 2 ++ apps/agent/test/model.test.ts | 12 +++++++++ bun.lock | 20 ++++++++++++-- 7 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 apps/agent/Dockerfile create mode 100644 apps/agent/Dockerfile.dockerignore create mode 100644 apps/agent/test/model.test.ts diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 000000000..f39ea0674 --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,35 @@ +# 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 + +FROM oven/bun:1.3.12-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production +COPY --from=build /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..8d7607a05 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -19,6 +19,7 @@ "clean": "rm -rf .turbo .eve node_modules" }, "dependencies": { + "@ai-sdk/openai": "^4.0.51", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", @@ -28,6 +29,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@ai-sdk/provider": "^4.0.8", "@crm/typescript-config": "workspace:*", "@types/node": "^24.0.0", "just-bash": "^3.2.0", diff --git a/apps/agent/test/model.test.ts b/apps/agent/test/model.test.ts new file mode 100644 index 000000000..9183a409c --- /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(typeof model.doGenerate).toBe("function"); + expect(typeof model.doStream).toBe("function"); + }); +}); diff --git a/bun.lock b/bun.lock index 1c6df3e63..0b4ef3db6 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "name": "agent", "version": "0.0.1", "dependencies": { + "@ai-sdk/openai": "^4.0.51", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", @@ -26,6 +27,7 @@ "zod": "^4.4.3", }, "devDependencies": { + "@ai-sdk/provider": "^4.0.8", "@crm/typescript-config": "workspace:*", "@types/node": "^24.0.0", "just-bash": "^3.2.0", @@ -254,9 +256,11 @@ "packages": { "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N1P6bdW/aC5rxLeuGYgx3X4el3DoZy8UWlky+g+AeIZSmxaEi/AToHJL4cmZ6nCPHk1byqJWwC+PaOZG0hK0dw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.51", "", { "dependencies": { "@ai-sdk/provider": "4.0.8", "@ai-sdk/provider-utils": "5.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ByLpf4FrmiAFFV/hI2f3EwtuRR+e0AWttik2UsUgTpS10KIGEOvdO6RSMcMcDINQyqIQh8iCb9V7MC/6RtmDsg=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.18", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UBNCrkxS5llgN2/RXLBRkjCFTIuL6YB1Goq5c+yRecnznhtX4XPjTwLHz2hrsTltTVZ0rqVegtnwFXdPBkjDHA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.33", "", { "dependencies": { "@ai-sdk/provider": "4.0.8", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TfjJqJmRsQyxAlb+3hGmP1o1xLUIT79yhOgtJuTF4hqsB37IC3CufGsuFhU04EeTOg7R9iptQ6Bzm0MW0n/c3g=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -2650,6 +2654,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.18", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UBNCrkxS5llgN2/RXLBRkjCFTIuL6YB1Goq5c+yRecnznhtX4XPjTwLHz2hrsTltTVZ0rqVegtnwFXdPBkjDHA=="], + "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -2910,6 +2918,10 @@ "agent/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.18", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UBNCrkxS5llgN2/RXLBRkjCFTIuL6YB1Goq5c+yRecnznhtX4XPjTwLHz2hrsTltTVZ0rqVegtnwFXdPBkjDHA=="], + "api/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "app/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -3040,6 +3052,8 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@ai-sdk/gateway/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@better-auth/cli/better-auth/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], "@better-auth/cli/better-auth/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], @@ -3090,6 +3104,8 @@ "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "app/next/@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], From eef531c14d8d12678467c9d33b4755287ef77953 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sat, 29 Aug 2026 02:54:55 +0200 Subject: [PATCH 06/36] fix(infra): slim api/agent runtime images to production deps only crm-api (3.65GB) and crm-agent (4.56GB) runtime images copied the FULL monorepo node_modules from the build stage (dev+prod deps for every workspace at once) -- repeatedly exhausted vigiep1's 72GB disk across rebuild cycles in production (29/08/2026). Both Dockerfiles now reinstall with 'bun install --production' in a dedicated stage before the runtime COPY, after removing the full node_modules tree. apps/agent/package.json: moved microsandbox and just-bash from devDependencies to dependencies -- eve's sandbox backend needs at least one importable at runtime even though docker.sock (the preferred backend in this deployment) is mounted; a --production install would otherwise have silently dropped them (carried-forward minor finding from Task 5's review, now directly relevant). --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 0b4ef3db6..787a86d77 100644 --- a/bun.lock +++ b/bun.lock @@ -24,14 +24,14 @@ "@crm/validation": "workspace:*", "context.dev": "2.10.0", "eve": "^0.29.4", + "just-bash": "^3.2.0", + "microsandbox": "^0.6.8", "zod": "^4.4.3", }, "devDependencies": { "@ai-sdk/provider": "^4.0.8", "@crm/typescript-config": "workspace:*", "@types/node": "^24.0.0", - "just-bash": "^3.2.0", - "microsandbox": "^0.6.8", "typescript": "^5.9.2", }, }, From 120df57babc2bde867345e418767b4e00699fd36 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sat, 29 Aug 2026 02:59:40 +0200 Subject: [PATCH 07/36] fix(infra): skip postinstall scripts on the production-only reinstall bun install --production re-triggers every workspace's postinstall hook on a fresh install, including @crm/db's ('prisma generate') -- but prisma (the CLI) is a devDependency, excluded by --production, so the hook fails with exit 127. Unnecessary anyway: the Prisma Client was already generated during the earlier 'build' stage and persists on disk (packages/db/src/generated/), untouched by the node_modules rm+reinstall. --ignore-scripts skips re-running it. --- apps/agent/Dockerfile | 20 +++++++++++++++++++- apps/api/Dockerfile | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile index f39ea0674..efa2a25f6 100644 --- a/apps/agent/Dockerfile +++ b/apps/agent/Dockerfile @@ -18,10 +18,28 @@ 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=build /repo /repo +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 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 5324216ab..5be91d42a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -18,9 +18,23 @@ 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=build /repo /repo +COPY --from=prod-deps /repo /repo EXPOSE 3001 CMD ["bun", "apps/api/dist/main.js"] From 7a0a50a8981c6aaff2c378049b47e698a6268655 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sat, 29 Aug 2026 03:10:04 +0200 Subject: [PATCH 08/36] fix(infra): move @crm/typescript-config and typescript to agent's dependencies crm-agent crash-looped after the --production slim-down: '[TSCONFIG_ERROR] Failed to load tsconfig @crm/typescript-config/base.json: Tsconfig not found' -- eve's 'start' re-bundles the authored agent module on every boot (not just 'eve build' ahead of time), and needs tsconfig resolution (and presumably the typescript toolchain) present at runtime, not just at build/dev time. Both were devDependencies, excluded by --production. --- apps/agent/package.json | 10 +++++----- bun.lock | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/agent/package.json b/apps/agent/package.json index 8d7607a05..201c8c560 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -23,17 +23,17 @@ "@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": { "@ai-sdk/provider": "^4.0.8", - "@crm/typescript-config": "workspace:*", - "@types/node": "^24.0.0", - "just-bash": "^3.2.0", - "microsandbox": "^0.6.8", - "typescript": "^5.9.2" + "@types/node": "^24.0.0" } } diff --git a/bun.lock b/bun.lock index 787a86d77..c22228a9f 100644 --- a/bun.lock +++ b/bun.lock @@ -21,18 +21,18 @@ "@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": { "@ai-sdk/provider": "^4.0.8", - "@crm/typescript-config": "workspace:*", "@types/node": "^24.0.0", - "typescript": "^5.9.2", }, }, "apps/api": { From 5438f58adac3bf8875e94701673575e29e684a4b Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:42:52 +0200 Subject: [PATCH 09/36] feat(gmail): add searchByParticipant for contact history backfill listMessages() already supported an arbitrary q= filter but nothing called it with a participant clause -- the live incremental sync only ever used listHistory(). searchByParticipant() reuses it unchanged (default q string is byte-identical when query is omitted, pinned by a regression test). --- apps/api/src/google/gmail.client.ts | 32 +++++++++++++- apps/api/test/gmail-client.spec.ts | 66 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/gmail-client.spec.ts 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/test/gmail-client.spec.ts b/apps/api/test/gmail-client.spec.ts new file mode 100644 index 000000000..906218802 --- /dev/null +++ b/apps/api/test/gmail-client.spec.ts @@ -0,0 +1,66 @@ +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(): { calls: URL[] } { + const calls: URL[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + 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"); + }); +}); From 68c8b348170b735b68921d653a29f8647af93322 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:43:28 +0200 Subject: [PATCH 10/36] feat(calendar): add q param + searchByParticipant for contact history backfill Same treatment as GmailClient -- listEvents() gains an optional q passthrough (omitted by default, matching today's behavior exactly), searchByParticipant() is the new targeted-search entry point. --- apps/api/src/google/calendar.client.ts | 23 ++++++++ apps/api/test/calendar-client.spec.ts | 77 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 apps/api/test/calendar-client.spec.ts 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/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts new file mode 100644 index 000000000..bef4945fb --- /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(): { calls: URL[] } { + const calls: URL[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + 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"); + }); +}); From 573c89c74cf8549697c4d323090b26945a8c3ad9 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:45:48 +0200 Subject: [PATCH 11/36] refactor(gmail): extract parseGmailMessage into a pure, reusable module Behavior-preserving move of GmailSyncService's former private parse()/ sentAt() -- verbatim logic, now unit-testable on its own and reusable by the contact-history backfill without duplicating parsing logic. gmail-sync.service.ts delegates, no behavior change (no dedicated spec existed for that service before; the extracted pure-function test is the regression net for this move). --- apps/api/src/google/gmail-message-parser.ts | 76 +++++++++++++++ apps/api/src/google/gmail-sync.service.ts | 77 +-------------- apps/api/test/calendar-client.spec.ts | 2 +- apps/api/test/gmail-client.spec.ts | 2 +- apps/api/test/gmail-message-parser.spec.ts | 102 ++++++++++++++++++++ 5 files changed, 184 insertions(+), 75 deletions(-) create mode 100644 apps/api/src/google/gmail-message-parser.ts create mode 100644 apps/api/test/gmail-message-parser.spec.ts 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..dbdced1e2 --- /dev/null +++ b/apps/api/src/google/gmail-message-parser.ts @@ -0,0 +1,76 @@ +import type { IncomingMessage } from "../mailbox/thread-writer.service"; +import { + normaliseMessageId, + stripQuotedHistory, +} from "../mailbox/message-text"; +import { parseAddress, parseAddressList } from "../mailbox/participants"; +import { + type GmailHeader, + header, + plainTextBody, + rootMessageId, +} from "./gmail-mime"; +import type { GmailMessage } from "./gmail.client"; + +/** + * 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; + + 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 body = stripQuotedHistory(plainTextBody(message.payload)); + + return { + rfcMessageId: normaliseMessageId(rawMessageId), + rootId, + subject: header(headers, "subject"), + from, + recipients: [...to, ...cc], + 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..16201bbfc 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 { parseGmailMessage } from "./gmail-message-parser"; +import { GmailClient } from "./gmail.client"; 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/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts index bef4945fb..b25b12162 100644 --- a/apps/api/test/calendar-client.spec.ts +++ b/apps/api/test/calendar-client.spec.ts @@ -10,7 +10,7 @@ afterEach(() => { function stubCapturingUrl(): { calls: URL[] } { const calls: URL[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { + globalThis.fetch = (async (input: string | URL | Request) => { calls.push(new URL(input.toString())); return new Response(JSON.stringify({ items: [] }), { status: 200, diff --git a/apps/api/test/gmail-client.spec.ts b/apps/api/test/gmail-client.spec.ts index 906218802..86501d82c 100644 --- a/apps/api/test/gmail-client.spec.ts +++ b/apps/api/test/gmail-client.spec.ts @@ -10,7 +10,7 @@ afterEach(() => { function stubCapturingUrl(): { calls: URL[] } { const calls: URL[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { + globalThis.fetch = (async (input: string | URL | Request) => { calls.push(new URL(input.toString())); return new Response(JSON.stringify({ messages: [] }), { status: 200, 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..a80eb1963 --- /dev/null +++ b/apps/api/test/gmail-message-parser.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test"; +import type { GmailMessage } from "../src/google/gmail.client"; +import { 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" }, + ]); + }); +}); From 319f3ac5d85e3fb3b4920617d4f151e24caa2a02 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:48:27 +0200 Subject: [PATCH 12/36] feat(mailbox): ThreadWriterService.store() accepts preresolved company/contact + relinks Optional 5th param, omitted everywhere in the live incremental sync (byte-identical default behavior, pinned by re-running mailbox-thread-writer.spec.ts unmodified). When passed by the contact history backfill: - a brand-new thread uses the given ids directly, skipping match.resolve() entirely - a thread the live sync already stored by company-only match (companyId set, contactId null -- the common case) gets relinked to the newly-created contact instead of silently staying invisible on its Relationship panel. Relink never touches a thread already pointing at a different contact or a different company. --- apps/api/src/mailbox/thread-writer.service.ts | 85 +++- .../mailbox-thread-writer-preresolved.spec.ts | 368 ++++++++++++++++++ 2 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 apps/api/test/mailbox-thread-writer-preresolved.spec.ts diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 9b06692c6..f766a7604 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,12 @@ 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 +102,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 +248,48 @@ 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/mailbox-thread-writer-preresolved.spec.ts b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts new file mode 100644 index 000000000..5b9c9df22 --- /dev/null +++ b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts @@ -0,0 +1,368 @@ +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(); + }); +}); From 03a91cc8a8f84705b7da1c6f6716a3905e10a24c Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:51:38 +0200 Subject: [PATCH 13/36] feat(calendar): CalendarSyncService gains public apply()/preresolved/backfillForParticipant() apply() and a new buildContext() (extracted from sync(), same pattern as ThreadWriterService.context()) become public so a preresolved company/contact can drive writes directly -- default (preresolved omitted) apply() behavior is unchanged for the live incremental sync's own call. This is the first direct test coverage for this service; flagging the pre-existing gap, not just closing it silently. backfillForParticipant() is the contact-history-backfill entry point: search by participant email, apply() each result with the known contact/company, never touching the live sync's cursor/pagination state. --- apps/api/src/google/calendar-sync.service.ts | 148 +++++++-- apps/api/test/calendar-sync-apply.spec.ts | 322 +++++++++++++++++++ 2 files changed, 436 insertions(+), 34 deletions(-) create mode 100644 apps/api/test/calendar-sync-apply.spec.ts diff --git a/apps/api/src/google/calendar-sync.service.ts b/apps/api/src/google/calendar-sync.service.ts index d219b3e9e..5169956cb 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,65 @@ 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/test/calendar-sync-apply.spec.ts b/apps/api/test/calendar-sync-apply.spec.ts new file mode 100644 index 000000000..367f33872 --- /dev/null +++ b/apps/api/test/calendar-sync-apply.spec.ts @@ -0,0 +1,322 @@ +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"); + }); +}); From e97d9b2610ae1930ba5c5df0f4a3e7a015099ee8 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:54:50 +0200 Subject: [PATCH 14/36] feat(contacts): add ContactHistoryBackfillService orchestrator Given a newly-created contact's email, searches Gmail (via GmailClient.searchByParticipant + parseGmailMessage + ThreadWriterService.store()) and Calendar (via CalendarSyncService.backfillForParticipant()) in parallel, each independently failing without blocking the other. Not wired into ContactsService.create() yet -- that's Unit H. Window/caps as tunable exported constants (24 months, 200 Gmail results, 100 Calendar results), no pagination in v1 (documented limitation, not silent). Capstone test proves the actual point of this feature: a thread the live sync already stored by company-only match becomes visible on the contact's Relationship panel query after backfill, not just 'no error'. --- .../contact-history-backfill.service.ts | 158 +++++++ .../api/test/contact-history-backfill.spec.ts | 428 ++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 apps/api/src/contacts/contact-history-backfill.service.ts create mode 100644 apps/api/test/contact-history-backfill.spec.ts 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..84b79d44f --- /dev/null +++ b/apps/api/src/contacts/contact-history-backfill.service.ts @@ -0,0 +1,158 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { CalendarSyncService } from "../google/calendar-sync.service"; +import { parseGmailMessage } from "../google/gmail-message-parser"; +import { GmailClient } from "../google/gmail.client"; +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( + (error: unknown) => this.failed("gmail", input.contactId, error), + ), + this.calendarSync + .backfillForParticipant({ + userId: input.userId, + email: input.email, + companyId: input.companyId, + contactId: input.contactId, + after, + before, + maxResults: BACKFILL_CALENDAR_MAX_RESULTS, + }) + .catch((error: unknown) => this.failed("calendar", input.contactId, error)), + ]); + + 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, + error: unknown, + ): SourceOutcome { + const reason = error instanceof Error ? error.message : String(error); + this.logger.error( + { message: "Contact history backfill source failed", source, contactId }, + error instanceof Error ? error.stack : undefined, + ); + return { status: "skipped", written: 0, reason }; + } +} 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..2436a19d1 --- /dev/null +++ b/apps/api/test/contact-history-backfill.spec.ts @@ -0,0 +1,428 @@ +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 { + 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 { CalendarSyncService } from "../src/google/calendar-sync.service"; +import type { CalendarClient, GoogleEvent } from "../src/google/calendar.client"; +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: { maxResults?: number } = {}; + const calendarCaptured: { maxResults?: number } = {}; + 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: { timeMin?: string } = {}; + 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 }); + }); +}); From fae57ac89364330486f8c0d65e0f0a8876514a3b Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 18:56:59 +0200 Subject: [PATCH 15/36] chore(di): wire ContactHistoryBackfillService into the module graph google.module.ts exports CalendarSyncService + GmailClient (previously internal-only). contacts.module.ts imports MailboxModule + GoogleModule and registers ContactHistoryBackfillService. No circular dependency (confirmed: nothing in mailbox/, google/, agent/, companies/, trpc/ imports ContactsModule). Verified by booting the real AppModule (auth.e2e.spec.ts, --timeout 60000 -- the default 5s bun test timeout is too short for this heavy a bootstrap, unrelated to this change). --- apps/api/src/contacts/contacts.module.ts | 14 ++++++++++++-- apps/api/src/google/google.module.ts | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) 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/google/google.module.ts b/apps/api/src/google/google.module.ts index 7b841cd88..487ed7044 100644 --- a/apps/api/src/google/google.module.ts +++ b/apps/api/src/google/google.module.ts @@ -23,6 +23,6 @@ import { GoogleSyncService } from "./google-sync.service"; ConversationService, GoogleRouter, ], - exports: [GoogleSyncService, GoogleConnectionService], + exports: [GoogleSyncService, GoogleConnectionService, CalendarSyncService, GmailClient], }) export class GoogleModule {} From 6946d280b01aa7f87e177b19d22964d5bce3b42a Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 19:00:19 +0200 Subject: [PATCH 16/36] feat(contacts): wire the history backfill into ContactsService.create() Router passes ctx.user.id, same pattern as decideFact (contacts.router.ts). Service gains an optional actorId param -- when given and the contact has an email, fires history.run() in the background (.catch()+log, same fire-and-forget pattern already used for fields.queueBackfillForNewRecord). actorId omitted (all 3 pre-existing test call sites) or no email -> never fires, byte- identical to before. bulk.spec.ts, fields.spec.ts, record-delete.spec.ts each get a 7th constructor stub (no behavior change, confirmed by re-running all 39 tests across the three files unmodified otherwise). --- apps/api/src/contacts/contacts.router.ts | 7 +- apps/api/src/contacts/contacts.service.ts | 23 ++- apps/api/test/bulk.spec.ts | 10 ++ .../api/test/contacts-create-backfill.spec.ts | 133 ++++++++++++++++++ apps/api/test/fields.spec.ts | 10 ++ apps/api/test/record-delete.spec.ts | 10 ++ 6 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 apps/api/test/contacts-create-backfill.spec.ts 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..2d74034e7 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", 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/contacts-create-backfill.spec.ts b/apps/api/test/contacts-create-backfill.spec.ts new file mode 100644 index 000000000..d1d3f055e --- /dev/null +++ b/apps/api/test/contacts-create-backfill.spec.ts @@ -0,0 +1,133 @@ +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/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, From 5b914c0b508d13efae6572c6e727a6f43219863d Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 19:04:00 +0200 Subject: [PATCH 17/36] chore(lint): fix import ordering flagged by biome assist/source/organizeImports Reorders 3 files touched by this branch. Remaining 'needs formatting' findings across the repo (this branch included) are the pre-existing CRLF/core.autocrlf Windows artifact already documented in CR-FORK-TRYCOMPAI-CRM-DEPLOYE-20260828.md -- confirmed present on a clean release checkout too (241 pre-existing errors), not introduced here. --- apps/api/src/contacts/contact-history-backfill.service.ts | 2 +- apps/api/src/google/gmail-message-parser.ts | 4 ++-- apps/api/src/google/gmail-sync.service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/contacts/contact-history-backfill.service.ts b/apps/api/src/contacts/contact-history-backfill.service.ts index 84b79d44f..7d26b7029 100644 --- a/apps/api/src/contacts/contact-history-backfill.service.ts +++ b/apps/api/src/contacts/contact-history-backfill.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { CalendarSyncService } from "../google/calendar-sync.service"; -import { parseGmailMessage } from "../google/gmail-message-parser"; 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"; diff --git a/apps/api/src/google/gmail-message-parser.ts b/apps/api/src/google/gmail-message-parser.ts index dbdced1e2..76dacdc27 100644 --- a/apps/api/src/google/gmail-message-parser.ts +++ b/apps/api/src/google/gmail-message-parser.ts @@ -1,16 +1,16 @@ -import type { IncomingMessage } from "../mailbox/thread-writer.service"; 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"; -import type { GmailMessage } from "./gmail.client"; /** * Extracted verbatim from GmailSyncService's former private parse()/sentAt() diff --git a/apps/api/src/google/gmail-sync.service.ts b/apps/api/src/google/gmail-sync.service.ts index 16201bbfc..2a9b27666 100644 --- a/apps/api/src/google/gmail-sync.service.ts +++ b/apps/api/src/google/gmail-sync.service.ts @@ -9,8 +9,8 @@ import type { MatchContext } from "../mailbox/mailbox-match.service"; import { MailboxTokenService } from "../mailbox/mailbox-token.service"; import { SyncStateService } from "../mailbox/sync-state.service"; import { ThreadWriterService } from "../mailbox/thread-writer.service"; -import { parseGmailMessage } from "./gmail-message-parser"; import { GmailClient } from "./gmail.client"; +import { parseGmailMessage } from "./gmail-message-parser"; const MAX_MESSAGES_PER_TICK = 120; From 0ffb62cb8ff73a538cb012da4df9273798547e06 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 21:14:39 +0200 Subject: [PATCH 18/36] fix(gmail): filter out mailing-list broadcasts from personal correspondence Measured on real backfilled data (Damien Vignault, WP crm-enrich, 02/09/2026): 6 threads/13 messages from a decision-makers' club mailing list (26-84 recipients each, sender bruno.hervein@orange.fr, none of them personal) were polluting the contact's business relationship view alongside 3 real 1:1 threads (1 recipient each). parseGmailMessage() now returns null for a message that either carries a List-Unsubscribe header or has more than BULK_MAIL_RECIPIENT_THRESHOLD (5) recipients -- both signals proven against the real data that triggered this fix. Applies to both the live incremental sync and the contact-history backfill, since both share this parser. --- apps/api/src/google/gmail-message-parser.ts | 16 ++++++++- apps/api/test/gmail-message-parser.spec.ts | 39 ++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/api/src/google/gmail-message-parser.ts b/apps/api/src/google/gmail-message-parser.ts index 76dacdc27..86e1d6564 100644 --- a/apps/api/src/google/gmail-message-parser.ts +++ b/apps/api/src/google/gmail-message-parser.ts @@ -12,6 +12,15 @@ import { 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 @@ -28,6 +37,8 @@ export function parseGmailMessage( 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; @@ -45,6 +56,9 @@ export function parseGmailMessage( kind: "cc" as const, })); + const recipients = [...to, ...cc]; + if (recipients.length > BULK_MAIL_RECIPIENT_THRESHOLD) return null; + const body = stripQuotedHistory(plainTextBody(message.payload)); return { @@ -52,7 +66,7 @@ export function parseGmailMessage( rootId, subject: header(headers, "subject"), from, - recipients: [...to, ...cc], + recipients, body, sentAt, gmailMessageId: message.id ?? null, diff --git a/apps/api/test/gmail-message-parser.spec.ts b/apps/api/test/gmail-message-parser.spec.ts index a80eb1963..f360ee1bb 100644 --- a/apps/api/test/gmail-message-parser.spec.ts +++ b/apps/api/test/gmail-message-parser.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { GmailMessage } from "../src/google/gmail.client"; -import { parseGmailMessage } from "../src/google/gmail-message-parser"; +import { + BULK_MAIL_RECIPIENT_THRESHOLD, + parseGmailMessage, +} from "../src/google/gmail-message-parser"; function messageWith( headers: Record, @@ -99,4 +102,38 @@ describe("parseGmailMessage", () => { { 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(); + }); }); From 2c3192e036ce7d47322cbdd25745ebdd990b7094 Mon Sep 17 00:00:00 2001 From: Franck H Date: Wed, 2 Sep 2026 23:02:56 +0200 Subject: [PATCH 19/36] fix(activities): scope the Notes tab to notes/calls, not every synced email/meeting NOTE_TYPES fed the "Notes" tab's filter clause, but included EMAIL and MEETING alongside NOTE/CALL -- both of which already have their own dedicated tab. Effect: every synced email and every synced meeting also showed up under Notes, which its own empty-state copy describes as "what you write down for the next person to read" -- i.e. manual entries only. Found investigating a report that "all mail lands in the notes channel" after the contact-history backfill made Notes tab traffic visible for the first time (previously the tab was empty because the live sync had never stored anything). NOTE_TYPES narrowed to [NOTE, CALL] -- the two types with no dedicated tab of their own. --- apps/api/src/activities/activities.service.ts | 15 ++- .../test/activities-timeline-filter.spec.ts | 114 ++++++++++++++++++ 2 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 apps/api/test/activities-timeline-filter.spec.ts diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 50439fcbc..c80781d89 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -61,12 +61,15 @@ 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]; @Injectable() export class ActivitiesService { 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..a9b7d4743 --- /dev/null +++ b/apps/api/test/activities-timeline-filter.spec.ts @@ -0,0 +1,114 @@ +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); + }); +}); From 044e350103e955417df5e26cd2f0c348e27e4600 Mon Sep 17 00:00:00 2001 From: Franck H Date: Thu, 3 Sep 2026 06:36:57 +0200 Subject: [PATCH 20/36] feat(activities): let a rep exclude an email thread from the CRM synthesis Requested after the deployed bulk-mail filter (gmail-message-parser.ts) didn't catch every Club'IT broadcast for Damien Vignault -- some slip under the recipient-count/List-Unsubscribe heuristics. Reps need a manual escape hatch for whatever the automated filter misses. EmailThread gains excludedAt (soft, not a delete -- the row and its rfcMessageId/rootMessageId dedup keys stay in place so the live sync never re-imports an excluded thread). ActivitiesService.timeline()/ timelineCounts() and ContactsService.relationship() now filter it out everywhere the synthesis reads from EmailThread/Activity. New tRPC mutations activities.excludeEmail/restoreEmail (reversible). Known gap, not covered by a dedicated test: ActivityStampService's lastActivityAt is not recomputed on exclude -- if the excluded thread was the most recent activity, lastActivityAt won't fall back to the next one until something else touches it. Acceptable for v1 (matches this session's "backend only, no UI yet" scoping), flagged for anyone picking this up next. --- .../src/activities/activities.contracts.ts | 9 +++ apps/api/src/activities/activities.router.ts | 20 +++++ apps/api/src/activities/activities.service.ts | 69 +++++++++++++++-- apps/api/src/contacts/contacts.service.ts | 7 +- .../test/activities-timeline-filter.spec.ts | 74 +++++++++++++++++++ .../migration.sql | 2 + packages/db/prisma/schema.prisma | 8 ++ 7 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 packages/db/prisma/migrations/20260903000000_email_thread_exclusion/migration.sql 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..1a04b3a00 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,22 @@ 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 c80781d89..b0671a10a 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -71,6 +71,19 @@ const ENTRY_SELECT = { */ 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 { private readonly logger = new Logger(ActivitiesService.name); @@ -82,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, @@ -111,19 +124,21 @@ 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 }, }), ]); @@ -190,6 +205,46 @@ 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/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 2d74034e7..99247e19a 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -634,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/test/activities-timeline-filter.spec.ts b/apps/api/test/activities-timeline-filter.spec.ts index a9b7d4743..e986b8864 100644 --- a/apps/api/test/activities-timeline-filter.spec.ts +++ b/apps/api/test/activities-timeline-filter.spec.ts @@ -112,3 +112,77 @@ describe("ActivitiesService.timeline -- notes filter scope", () => { 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/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/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..7413d878f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1190,6 +1190,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? From c1f430510589ddb454bc906ad7821cfa75ba5e60 Mon Sep 17 00:00:00 2001 From: Franck H Date: Thu, 3 Sep 2026 16:48:00 +0200 Subject: [PATCH 21/36] feat(agent): notify VigieProcure on CRM deal lifecycle events DEC-C-CRM-10 (Franck, 2026-09-03): inbound direction of F.24 (VigieProcure's CRM gateway) activated. Branches into the same point that already queues agent-worker events (withCrmEvents) -- best-effort, HMAC-SHA256 signed on the raw body, same "no secret = no bridge" rule as the existing agent bridge. New vigieprocure-bridge.ts, VIGIEPROCURE_WEBHOOK_URL/_SECRET env vars (both optional, unset = no-op). createEventTask now returns the AgentTask id, used as a stable event_id for VigieProcure-side deduplication. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GLAb4MA3VwGs6CKRhxtsmD --- .env.example | 10 +++ apps/api/src/agent/agent-trigger.service.ts | 43 ++++++++-- apps/api/src/agent/vigieprocure-bridge.ts | 87 +++++++++++++++++++++ apps/api/src/config/env.validation.ts | 17 ++++ apps/api/test/vigieprocure-bridge.spec.ts | 60 ++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/agent/vigieprocure-bridge.ts create mode 100644 apps/api/test/vigieprocure-bridge.spec.ts diff --git a/.env.example b/.env.example index 12fac543c..341991cbb 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,16 @@ 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="" + # PORT="3001" 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..f36fc049c --- /dev/null +++ b/apps/api/src/agent/vigieprocure-bridge.ts @@ -0,0 +1,87 @@ +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: Record; +}; + +/** + * 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: Record) => 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/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c2..ba6effdd8 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -123,6 +123,23 @@ 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; + @IsOptional() @IsString() CRM_TELEMETRY_DISABLED?: string; diff --git a/apps/api/test/vigieprocure-bridge.spec.ts b/apps/api/test/vigieprocure-bridge.spec.ts new file mode 100644 index 000000000..65336975e --- /dev/null +++ b/apps/api/test/vigieprocure-bridge.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +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: Record[] = []; + 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); + }); +}); From e65fb26ae1784e0080169028077e34786148ef2e Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 05:57:46 +0200 Subject: [PATCH 22/36] style: applique le formatage biome sur vigieprocure-bridge.spec.ts Corrige deux lignes trop longues detectees par le hook pre-push (bun run lint). Aucun changement de comportement. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GLAb4MA3VwGs6CKRhxtsmD --- apps/api/test/vigieprocure-bridge.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/test/vigieprocure-bridge.spec.ts b/apps/api/test/vigieprocure-bridge.spec.ts index 65336975e..8cc628f3a 100644 --- a/apps/api/test/vigieprocure-bridge.spec.ts +++ b/apps/api/test/vigieprocure-bridge.spec.ts @@ -24,7 +24,8 @@ afterEach(() => { 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"; + process.env.VIGIEPROCURE_WEBHOOK_URL = + "https://api.vigieproc.fr/api/v1/crm/webhooks"; expect(vigieProcureBridge()).toBeNull(); }); @@ -34,7 +35,8 @@ describe("vigieProcureBridge", () => { }); 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_URL = + "https://api.vigieproc.fr/api/v1/crm/webhooks"; process.env.VIGIEPROCURE_WEBHOOK_SECRET = "test-secret"; const result = vigieProcureBridge(); From db2184bdd1038a6477e1968779882d6124e0aca4 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 06:14:17 +0200 Subject: [PATCH 23/36] chore(lint): apply Biome safe fixes to apps/api (formatting only) bunx biome check --write . after biome.jsonc's formatter/organizeImports rules. All 12 files are pure line-wrap/reformatting (long object literals, type unions, function signatures split across lines) -- verified diff-by-diff, no logic or values changed. This was the actual scope of the pre-push lint gate failure blocking feat/company-siren-column. The other ~790 files git status flagged were a false positive from local core.autocrlf=true (Windows checkout) vs this repo's LF blobs -- confirmed via `git diff --stat` (empty) and `git ls-files --eol` (i/lf w/lf, no divergence). No commit needed for those; see follow-up note on core.autocrlf / .gitattributes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/src/activities/activities.router.ts | 8 +- apps/api/src/activities/activities.service.ts | 40 +++- .../contact-history-backfill.service.ts | 14 +- apps/api/src/google/calendar-sync.service.ts | 6 +- apps/api/src/google/google.module.ts | 7 +- apps/api/src/mailbox/thread-writer.service.ts | 9 +- .../test/activities-timeline-filter.spec.ts | 36 +++- apps/api/test/calendar-sync-apply.spec.ts | 17 +- .../api/test/contact-history-backfill.spec.ts | 194 ++++++++++++++---- .../api/test/contacts-create-backfill.spec.ts | 24 ++- apps/api/test/gmail-client.spec.ts | 4 +- .../mailbox-thread-writer-preresolved.spec.ts | 20 +- 12 files changed, 301 insertions(+), 78 deletions(-) diff --git a/apps/api/src/activities/activities.router.ts b/apps/api/src/activities/activities.router.ts index 1a04b3a00..e6feab0a5 100644 --- a/apps/api/src/activities/activities.router.ts +++ b/apps/api/src/activities/activities.router.ts @@ -90,7 +90,9 @@ export class ActivitiesRouter { output: emailThreadExclusionOutput, meta: restMeta("POST", "/activities/emails/exclude", ["Activities"]), }) - async excludeEmail(@Input() input: z.infer) { + async excludeEmail( + @Input() input: z.infer, + ) { return this.activities.excludeEmailThread(input.threadId); } @@ -99,7 +101,9 @@ export class ActivitiesRouter { output: emailThreadExclusionOutput, meta: restMeta("POST", "/activities/emails/restore", ["Activities"]), }) - async restoreEmail(@Input() input: z.infer) { + 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 b0671a10a..b9b350d86 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -129,7 +129,11 @@ export class ActivitiesService { where: { ...anchor, ...filterClause("notes"), ...VISIBLE_EMAIL_THREAD }, }), this.db.activity.count({ - where: { ...anchor, ...filterClause("upcoming"), ...VISIBLE_EMAIL_THREAD }, + where: { + ...anchor, + ...filterClause("upcoming"), + ...VISIBLE_EMAIL_THREAD, + }, }), this.db.activity.count({ where: { ...anchor, ...filterClause("done"), ...VISIBLE_EMAIL_THREAD }, @@ -138,7 +142,11 @@ export class ActivitiesService { where: { ...anchor, ...filterClause("email"), ...VISIBLE_EMAIL_THREAD }, }), this.db.activity.count({ - where: { ...anchor, ...filterClause("meetings"), ...VISIBLE_EMAIL_THREAD }, + where: { + ...anchor, + ...filterClause("meetings"), + ...VISIBLE_EMAIL_THREAD, + }, }), ]); @@ -205,7 +213,9 @@ export class ActivitiesService { return serializeEntry(updated); } - async excludeEmailThread(threadId: string): Promise<{ id: string; excludedAt: string | null }> { + async excludeEmailThread( + threadId: string, + ): Promise<{ id: string; excludedAt: string | null }> { const thread = await this.db.emailThread.findUnique({ where: { id: threadId }, select: { id: true }, @@ -220,12 +230,20 @@ export class ActivitiesService { select: { id: true, excludedAt: true }, }); - this.logger.log({ message: "Email thread excluded from synthesis", threadId }); + this.logger.log({ + message: "Email thread excluded from synthesis", + threadId, + }); - return { id: updated.id, excludedAt: updated.excludedAt?.toISOString() ?? null }; + return { + id: updated.id, + excludedAt: updated.excludedAt?.toISOString() ?? null, + }; } - async restoreEmailThread(threadId: string): Promise<{ id: string; excludedAt: string | null }> { + async restoreEmailThread( + threadId: string, + ): Promise<{ id: string; excludedAt: string | null }> { const thread = await this.db.emailThread.findUnique({ where: { id: threadId }, select: { id: true }, @@ -240,9 +258,15 @@ export class ActivitiesService { select: { id: true, excludedAt: true }, }); - this.logger.log({ message: "Email thread restored to synthesis", threadId }); + this.logger.log({ + message: "Email thread restored to synthesis", + threadId, + }); - return { id: updated.id, excludedAt: updated.excludedAt?.toISOString() ?? null }; + return { + id: updated.id, + excludedAt: updated.excludedAt?.toISOString() ?? null, + }; } async myTasks( diff --git a/apps/api/src/contacts/contact-history-backfill.service.ts b/apps/api/src/contacts/contact-history-backfill.service.ts index 7d26b7029..209143d7b 100644 --- a/apps/api/src/contacts/contact-history-backfill.service.ts +++ b/apps/api/src/contacts/contact-history-backfill.service.ts @@ -54,9 +54,13 @@ export class ContactHistoryBackfillService { }; const [gmail, calendar] = await Promise.all([ - this.backfillGmail(input.userId, input.email, after, before, preresolved).catch( - (error: unknown) => this.failed("gmail", input.contactId, error), - ), + this.backfillGmail( + input.userId, + input.email, + after, + before, + preresolved, + ).catch((error: unknown) => this.failed("gmail", input.contactId, error)), this.calendarSync .backfillForParticipant({ userId: input.userId, @@ -67,7 +71,9 @@ export class ContactHistoryBackfillService { before, maxResults: BACKFILL_CALENDAR_MAX_RESULTS, }) - .catch((error: unknown) => this.failed("calendar", input.contactId, error)), + .catch((error: unknown) => + this.failed("calendar", input.contactId, error), + ), ]); return { gmail, calendar }; diff --git a/apps/api/src/google/calendar-sync.service.ts b/apps/api/src/google/calendar-sync.service.ts index 5169956cb..99bd3064f 100644 --- a/apps/api/src/google/calendar-sync.service.ts +++ b/apps/api/src/google/calendar-sync.service.ts @@ -334,7 +334,11 @@ export class CalendarSyncService { after: Date; before: Date; maxResults: number; - }): Promise<{ status: "synced" | "skipped"; written: number; reason?: string }> { + }): Promise<{ + status: "synced" | "skipped"; + written: number; + reason?: string; + }> { const row = await this.state.get(input.userId, "calendar"); if (!row) { return { diff --git a/apps/api/src/google/google.module.ts b/apps/api/src/google/google.module.ts index 487ed7044..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, CalendarSyncService, GmailClient], + 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 f766a7604..e2eca9574 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -77,7 +77,10 @@ export class ThreadWriterService { }, }); if (existing?.thread.activity) { - if (preresolved?.contactId && this.canRelink(existing.thread, preresolved)) { + if ( + preresolved?.contactId && + this.canRelink(existing.thread, preresolved) + ) { await this.relink(existing.threadId, preresolved); } return false; @@ -259,7 +262,9 @@ export class ThreadWriterService { preresolved: { companyId: string | null; contactId: string | null }, ): boolean { if (thread.contactId !== null) return false; - return thread.companyId === null || thread.companyId === preresolved.companyId; + return ( + thread.companyId === null || thread.companyId === preresolved.companyId + ); } private async relink( diff --git a/apps/api/test/activities-timeline-filter.spec.ts b/apps/api/test/activities-timeline-filter.spec.ts index e986b8864..a7cd02093 100644 --- a/apps/api/test/activities-timeline-filter.spec.ts +++ b/apps/api/test/activities-timeline-filter.spec.ts @@ -13,7 +13,9 @@ const service = new ActivitiesService(db, stamp); let contactId: string; async function clean() { - await db.activity.deleteMany({ where: { subject: { endsWith: `[${suffix}]` } } }); + 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 } }); @@ -34,7 +36,9 @@ async function seed(type: ActivityType, subjectPrefix: string) { beforeAll(async () => { await clean(); - await db.user.create({ data: { id: userId, name: "Test Rep", email: `rep@${domain}` } }); + 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 }, @@ -101,7 +105,11 @@ describe("ActivitiesService.timeline -- notes filter scope", () => { }); it("the all tab still shows everything", async () => { - const result = await service.timeline({ contactId, filter: "all", limit: 30 }); + const result = await service.timeline({ + contactId, + filter: "all", + limit: 30, + }); expect(result.entries.length).toBe(4); }); @@ -143,11 +151,17 @@ describe("ActivitiesService email thread exclusion -- removing noise from the sy }); afterAll(async () => { - await db.emailThread.deleteMany({ where: { rootMessageId: `` } }); + 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 }); + const result = await service.timeline({ + contactId, + filter: "email", + limit: 30, + }); expect(result.entries.map((entry) => entry.subject)).toContain( `An excludable email [${suffix}]`, ); @@ -174,15 +188,19 @@ describe("ActivitiesService email thread exclusion -- removing noise from the sy const restored = await service.restoreEmailThread(threadId); expect(restored.excludedAt).toBeNull(); - const result = await service.timeline({ contactId, filter: "email", limit: 30 }); + 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}.`, - ); + await expect( + service.excludeEmailThread(`missing-${suffix}`), + ).rejects.toThrow(`No email thread with id missing-${suffix}.`); }); }); diff --git a/apps/api/test/calendar-sync-apply.spec.ts b/apps/api/test/calendar-sync-apply.spec.ts index 367f33872..45e5b443f 100644 --- a/apps/api/test/calendar-sync-apply.spec.ts +++ b/apps/api/test/calendar-sync-apply.spec.ts @@ -4,7 +4,10 @@ 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 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 { @@ -34,7 +37,9 @@ 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; + return { + accessTokenFor: async () => result, + } as unknown as MailboxTokenService; } function calendarStub(items: GoogleEvent[]): CalendarClient { @@ -192,7 +197,9 @@ describe("CalendarSyncService.apply() with preresolved", () => { expect(first).toBe("written"); const afterFirst = await db.calendarEvent.findUnique({ - where: { iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt } }, + where: { + iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt }, + }, select: { id: true, companyId: true, contactId: true }, }); expect(afterFirst?.companyId).toBe(knownCompanyId); @@ -265,7 +272,9 @@ describe("CalendarSyncService.backfillForParticipant()", () => { expect(result).toEqual({ status: "synced", written: 1 }); const stored = await db.calendarEvent.findUnique({ - where: { iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt } }, + where: { + iCalUid_originalStartTime: { iCalUid, originalStartTime: startsAt }, + }, select: { contactId: true }, }); expect(stored?.contactId).toBe(contact.id); diff --git a/apps/api/test/contact-history-backfill.spec.ts b/apps/api/test/contact-history-backfill.spec.ts index 2436a19d1..465f1e241 100644 --- a/apps/api/test/contact-history-backfill.spec.ts +++ b/apps/api/test/contact-history-backfill.spec.ts @@ -10,9 +10,16 @@ import { } 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 { CalendarClient, GoogleEvent } from "../src/google/calendar.client"; -import type { GmailClient, GmailMessage, MessageList } from "../src/google/gmail.client"; +import type { + GmailClient, + GmailMessage, + MessageList, +} from "../src/google/gmail.client"; import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; import type { MailboxTokenService, @@ -57,7 +64,9 @@ function gmailMessage(rfcId: string, from: string, sentAt: Date): GmailMessage { } type GmailStubOptions = { - profile?: { outcome: "ok"; data: { emailAddress: string } } | { outcome: "failed"; reason: string; retryable: boolean }; + profile?: + | { outcome: "ok"; data: { emailAddress: string } } + | { outcome: "failed"; reason: string; retryable: boolean }; searchIds?: string[]; messages?: Record; searchThrows?: boolean; @@ -67,7 +76,10 @@ type GmailStubOptions = { function gmailStub(options: GmailStubOptions): GmailClient { return { profile: async () => - options.profile ?? { outcome: "ok", data: { emailAddress: "rep@example.test" } }, + options.profile ?? { + outcome: "ok", + data: { emailAddress: "rep@example.test" }, + }, searchByParticipant: async ( _token: string, args: { email: string; after: Date; before: Date; maxResults?: number }, @@ -97,8 +109,10 @@ function tokenStub( calendar: TokenResult = { outcome: "ok", accessToken: "cal-token" }, ): MailboxTokenService { return { - accessTokenFor: async (_userId: string, source: "gmail" | "calendar" | "outlook") => - source === "gmail" ? gmail : calendar, + accessTokenFor: async ( + _userId: string, + source: "gmail" | "calendar" | "outlook", + ) => (source === "gmail" ? gmail : calendar), } as unknown as MailboxTokenService; } @@ -109,7 +123,12 @@ function calendarClientStub( return { searchByParticipant: async ( _token: string, - args: { email: string; timeMin: Date; timeMax: Date; maxResults?: number }, + args: { + email: string; + timeMin: Date; + timeMax: Date; + maxResults?: number; + }, ) => { if (captured) { captured.maxResults = args.maxResults; @@ -121,7 +140,11 @@ function calendarClientStub( } as unknown as CalendarClient; } -function calendarEvent(iCalUid: string, organizerEmail: string, startsAt: Date): GoogleEvent { +function calendarEvent( + iCalUid: string, + organizerEmail: string, + startsAt: Date, +): GoogleEvent { return { id: `gcal-${iCalUid}`, iCalUID: iCalUid, @@ -134,7 +157,11 @@ function calendarEvent(iCalUid: string, organizerEmail: string, startsAt: Date): }; } -function service(gmail: GmailClient, tokens: MailboxTokenService, calendar: CalendarClient) { +function service( + gmail: GmailClient, + tokens: MailboxTokenService, + calendar: CalendarClient, +) { const calendarSync = new CalendarSyncService( db, calendar, @@ -144,7 +171,13 @@ function service(gmail: GmailClient, tokens: MailboxTokenService, calendar: Cale stamp, agent, ); - return new ContactHistoryBackfillService(gmail, tokens, state, threads, calendarSync); + return new ContactHistoryBackfillService( + gmail, + tokens, + state, + threads, + calendarSync, + ); } let companyId: string; @@ -153,17 +186,29 @@ 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 } }); + 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.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.mailboxSync.deleteMany({ + where: { userId: { startsWith: `user-${suffix}` } }, + }); await db.user.deleteMany({ where: { id: { startsWith: `user-${suffix}` } } }); } @@ -176,7 +221,12 @@ beforeAll(async () => { companyId = company.id; contactEmail = `target@${domain}`; const contact = await db.contact.create({ - data: { firstName: "Target", lastName: "Contact", email: contactEmail, companyId }, + data: { + firstName: "Target", + lastName: "Contact", + email: contactEmail, + companyId, + }, select: { id: true }, }); contactId = contact.id; @@ -189,16 +239,32 @@ 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 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 } }), + 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")), + calendarEvent( + `ical-happy-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-02T10:00:00Z"), + ), ]), ); - const result = await svc.run({ contactId, email: contactEmail, companyId, userId }); + 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 }); @@ -258,7 +324,10 @@ describe("ContactHistoryBackfillService.run()", () => { const message = gmailMessage(rfcId, `sender@${senderDomain}`, sentAt); const svc = service( - gmailStub({ searchIds: [message.id as string], messages: { [message.id as string]: message } }), + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), tokenStub({ outcome: "ok", accessToken: "gmail-token" }), calendarClientStub([]), ); @@ -277,11 +346,20 @@ describe("ContactHistoryBackfillService.run()", () => { gmailStub({}), tokenStub({ outcome: "needs-reconnect", reason: "expired" }), calendarClientStub([ - calendarEvent(`ical-gmailskip-${suffix}`, `organizer@${senderDomain}`, new Date("2026-02-04T10:00:00Z")), + calendarEvent( + `ical-gmailskip-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-04T10:00:00Z"), + ), ]), ); - const result = await svc.run({ contactId, email: contactEmail, companyId, userId }); + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); expect(result.gmail.status).toBe("skipped"); expect(result.calendar).toEqual({ status: "synced", written: 1 }); @@ -289,9 +367,16 @@ describe("ContactHistoryBackfillService.run()", () => { 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 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 } }), + gmailStub({ + searchIds: [message.id as string], + messages: { [message.id as string]: message }, + }), tokenStub( { outcome: "ok", accessToken: "gmail-token" }, { outcome: "needs-reconnect", reason: "expired" }, @@ -299,7 +384,12 @@ describe("ContactHistoryBackfillService.run()", () => { calendarClientStub([]), ); - const result = await svc.run({ contactId, email: contactEmail, companyId, userId }); + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); expect(result.gmail).toEqual({ status: "synced", written: 1 }); expect(result.calendar.status).toBe("skipped"); @@ -307,11 +397,26 @@ describe("ContactHistoryBackfillService.run()", () => { 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` } }); + await db.user.create({ + data: { + id: bareUserId, + name: "Bare", + email: `${bareUserId}@example.test`, + }, + }); - const svc = service(gmailStub({}), tokenStub({ outcome: "ok", accessToken: "unused" }), calendarClientStub([])); + const svc = service( + gmailStub({}), + tokenStub({ outcome: "ok", accessToken: "unused" }), + calendarClientStub([]), + ); - const result = await svc.run({ contactId, email: contactEmail, companyId, userId: bareUserId }); + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId: bareUserId, + }); expect(result.gmail.status).toBe("skipped"); expect(result.calendar.status).toBe("skipped"); @@ -365,12 +470,20 @@ describe("ContactHistoryBackfillService.run()", () => { const message = gmailMessage(rfcId, `sender@${senderDomain}`, sentAt); const svc = service( - gmailStub({ searchIds: [message.id as string], messages: { [message.id as string]: message } }), + 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 }); + const result = await svc.run({ + contactId, + email: contactEmail, + companyId, + userId, + }); expect(result.gmail).toEqual({ status: "synced", written: 0 }); expect( @@ -408,7 +521,9 @@ describe("ContactHistoryBackfillService.run()", () => { 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); + expect(Math.abs(actualAfter - expectedAfter.getTime())).toBeLessThan( + 60_000, + ); }); it("stays resilient when the Gmail search throws -- Calendar still completes", async () => { @@ -416,11 +531,20 @@ describe("ContactHistoryBackfillService.run()", () => { gmailStub({ searchThrows: true }), tokenStub({ outcome: "ok", accessToken: "gmail-token" }), calendarClientStub([ - calendarEvent(`ical-resilience-${suffix}`, `organizer@${senderDomain}`, new Date("2026-02-07T10:00:00Z")), + calendarEvent( + `ical-resilience-${suffix}`, + `organizer@${senderDomain}`, + new Date("2026-02-07T10:00:00Z"), + ), ]), ); - const result = await svc.run({ contactId, email: contactEmail, companyId, userId }); + 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 index d1d3f055e..23190b0e4 100644 --- a/apps/api/test/contacts-create-backfill.spec.ts +++ b/apps/api/test/contacts-create-backfill.spec.ts @@ -24,14 +24,25 @@ 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 { +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 } }; + return { + gmail: { status: "skipped", written: 0 }, + calendar: { status: "skipped", written: 0 }, + }; }, } as unknown as ContactHistoryBackfillService; } @@ -88,7 +99,10 @@ describe("ContactsService.create() triggers the history backfill", () => { historyStub({ calls }), ); - await contacts.create({ firstName: "NoActor", email: `no-actor@${domain}` }); + await contacts.create({ + firstName: "NoActor", + email: `no-actor@${domain}`, + }); expect(calls).toHaveLength(0); }); diff --git a/apps/api/test/gmail-client.spec.ts b/apps/api/test/gmail-client.spec.ts index 86501d82c..c76b30021 100644 --- a/apps/api/test/gmail-client.spec.ts +++ b/apps/api/test/gmail-client.spec.ts @@ -31,9 +31,7 @@ describe("GmailClient.listMessages", () => { }); const q = calls[0]?.searchParams.get("q"); - expect(q).toBe( - `${WORK_MAIL_QUERY} after:1767225600 before:1769904000`, - ); + expect(q).toBe(`${WORK_MAIL_QUERY} after:1767225600 before:1769904000`); }); }); diff --git a/apps/api/test/mailbox-thread-writer-preresolved.spec.ts b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts index 5b9c9df22..178bbc857 100644 --- a/apps/api/test/mailbox-thread-writer-preresolved.spec.ts +++ b/apps/api/test/mailbox-thread-writer-preresolved.spec.ts @@ -61,9 +61,19 @@ async function clean() { await db.emailThread.deleteMany({ where: { rootMessageId: { startsWith: ` { await clean(); - await db.user.create({ data: { id: userId, name: "Test Rep", email: mailbox } }); + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); row = await db.mailboxSync.create({ data: { userId, source: "gmail", autoCreate: false }, }); From e507553f5eb809c9772794185e638d2b11e6d9b7 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 08:52:37 +0200 Subject: [PATCH 24/36] fix(anti-slop): rename error to cause in ContactHistoryBackfillService.failed `no-unknown-parameters` allows an explicit `unknown` parameter only when named `cause` -- the convention every other error-normalizing method in this repo already follows (companies.service.ts, contacts.service.ts, deals.service.ts, fields.ts, main.ts, settings.service.ts all name it `cause`). This was the sole outlier, still named `error`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- .../src/contacts/contact-history-backfill.service.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/api/src/contacts/contact-history-backfill.service.ts b/apps/api/src/contacts/contact-history-backfill.service.ts index 209143d7b..49c73a776 100644 --- a/apps/api/src/contacts/contact-history-backfill.service.ts +++ b/apps/api/src/contacts/contact-history-backfill.service.ts @@ -60,7 +60,7 @@ export class ContactHistoryBackfillService { after, before, preresolved, - ).catch((error: unknown) => this.failed("gmail", input.contactId, error)), + ).catch((cause) => this.failed("gmail", input.contactId, cause)), this.calendarSync .backfillForParticipant({ userId: input.userId, @@ -71,9 +71,7 @@ export class ContactHistoryBackfillService { before, maxResults: BACKFILL_CALENDAR_MAX_RESULTS, }) - .catch((error: unknown) => - this.failed("calendar", input.contactId, error), - ), + .catch((cause) => this.failed("calendar", input.contactId, cause)), ]); return { gmail, calendar }; @@ -152,12 +150,12 @@ export class ContactHistoryBackfillService { private failed( source: "gmail" | "calendar", contactId: string, - error: unknown, + cause: unknown, ): SourceOutcome { - const reason = error instanceof Error ? error.message : String(error); + const reason = cause instanceof Error ? cause.message : String(cause); this.logger.error( { message: "Contact history backfill source failed", source, contactId }, - error instanceof Error ? error.stack : undefined, + cause instanceof Error ? cause.stack : undefined, ); return { status: "skipped", written: 0, reason }; } From 8d4bb3f9ccfdf4c40108adc20cc9688c46cf45c3 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 08:52:46 +0200 Subject: [PATCH 25/36] fix(anti-slop): let stubCapturingUrl's return type infer instead of widening `no-known-value-widening` -- `return { calls }` already carries evidence for `{ calls: URL[] }`; the explicit anonymous return-type annotation discarded it for no benefit (callers destructure `{ calls }` either way). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/test/calendar-client.spec.ts | 2 +- apps/api/test/gmail-client.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts index b25b12162..a1e91799a 100644 --- a/apps/api/test/calendar-client.spec.ts +++ b/apps/api/test/calendar-client.spec.ts @@ -8,7 +8,7 @@ afterEach(() => { globalThis.fetch = realFetch; }); -function stubCapturingUrl(): { calls: URL[] } { +function stubCapturingUrl() { const calls: URL[] = []; globalThis.fetch = (async (input: string | URL | Request) => { calls.push(new URL(input.toString())); diff --git a/apps/api/test/gmail-client.spec.ts b/apps/api/test/gmail-client.spec.ts index c76b30021..d56211763 100644 --- a/apps/api/test/gmail-client.spec.ts +++ b/apps/api/test/gmail-client.spec.ts @@ -8,7 +8,7 @@ afterEach(() => { globalThis.fetch = realFetch; }); -function stubCapturingUrl(): { calls: URL[] } { +function stubCapturingUrl() { const calls: URL[] = []; globalThis.fetch = (async (input: string | URL | Request) => { calls.push(new URL(input.toString())); From cba97d13ea82c0154b0f3a5ebfcc42c00f3dd67e Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 08:52:55 +0200 Subject: [PATCH 26/36] fix(anti-slop): use toBeFunction() instead of typeof x === "function" `no-runtime-typeof` -- bun:test ships a dedicated matcher for exactly this assertion, same intent without a bare typeof check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/agent/test/model.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/agent/test/model.test.ts b/apps/agent/test/model.test.ts index 9183a409c..2d3da5afa 100644 --- a/apps/agent/test/model.test.ts +++ b/apps/agent/test/model.test.ts @@ -6,7 +6,7 @@ describe("deepseekModel", () => { const model = deepseekModel(); expect(model.provider).toContain("deepseek"); expect(model.modelId).toBe("deepseek-v4-flash"); - expect(typeof model.doGenerate).toBe("function"); - expect(typeof model.doStream).toBe("function"); + expect(model.doGenerate).toBeFunction(); + expect(model.doStream).toBeFunction(); }); }); From b64526014419238cdd6d23ff882b50d70b379413 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 08:53:08 +0200 Subject: [PATCH 27/36] fix(anti-slop): name captured-value types by their owner, drop dead import no-known-value-widening: gmailCaptured/calendarCaptured were annotated with anonymous object-literal types instead of the named contract already defined by the stub functions that mutate them (GmailStubOptions["captured"], calendarClientStub's own parameter type via Parameters<>) -- satisfies alone doesn't work here since these objects start empty and get filled by the stub's side effect. Also drops MailboxSyncModel (aliased MailboxSync), imported but never referenced -- preexisting on release, caught by biome's noUnusedImports while touching this file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/test/contact-history-backfill.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/test/contact-history-backfill.spec.ts b/apps/api/test/contact-history-backfill.spec.ts index 465f1e241..30e4cdba2 100644 --- a/apps/api/test/contact-history-backfill.spec.ts +++ b/apps/api/test/contact-history-backfill.spec.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { db, type MailboxSyncModel as MailboxSync } from "@crm/db"; +import { db } from "@crm/db"; import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { CompanyDirectoryService } from "../src/companies/company-directory.service"; import { @@ -492,8 +492,8 @@ describe("ContactHistoryBackfillService.run()", () => { }); it("respects the Gmail and Calendar result caps", async () => { - const gmailCaptured: { maxResults?: number } = {}; - const calendarCaptured: { maxResults?: number } = {}; + const gmailCaptured: GmailStubOptions["captured"] = {}; + const calendarCaptured: Parameters[1] = {}; const svc = service( gmailStub({ captured: gmailCaptured }), tokenStub({ outcome: "ok", accessToken: "gmail-token" }), @@ -507,7 +507,7 @@ describe("ContactHistoryBackfillService.run()", () => { }); it("searches roughly the last BACKFILL_WINDOW_MONTHS months", async () => { - const calendarCaptured: { timeMin?: string } = {}; + const calendarCaptured: Parameters[1] = {}; const svc = service( gmailStub({}), tokenStub({ outcome: "ok", accessToken: "gmail-token" }), From d22d6132665c2130d3cefb6953a2f44b5eee1b48 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 09:26:43 +0200 Subject: [PATCH 28/36] fix(anti-slop): type VigieProcureEvent.payload with Prisma.InputJsonValue no-unsafe-dictionary-type -- payload and the logger.debug() parameter were Record, an unsafe unknown escape hatch. The repo already has a concrete owner type for this exact case: Prisma.InputJsonValue /InputJsonObject, used the same way for outbound JSON payloads elsewhere in apps/api/src/agent/ (agent-trigger.service.ts). No behavior change -- both are still structural JSON objects at runtime. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/src/agent/vigieprocure-bridge.ts | 6 ++++-- apps/api/test/vigieprocure-bridge.spec.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api/src/agent/vigieprocure-bridge.ts b/apps/api/src/agent/vigieprocure-bridge.ts index f36fc049c..2cd128339 100644 --- a/apps/api/src/agent/vigieprocure-bridge.ts +++ b/apps/api/src/agent/vigieprocure-bridge.ts @@ -1,3 +1,5 @@ +import type { Prisma } from "@crm/db"; + const HEADER_SIGNATURE = "X-VigieProcure-Signature"; export interface VigieProcureBridge { @@ -37,7 +39,7 @@ async function hmacSha256Hex(secret: string, data: string): Promise { export type VigieProcureEvent = { eventId: string; kind: string; - payload: Record; + payload: Prisma.InputJsonValue; }; /** @@ -49,7 +51,7 @@ export type VigieProcureEvent = { */ export async function envoyerEvenementVigieProcure( event: VigieProcureEvent, - logger: { debug: (obj: Record) => void }, + logger: { debug: (obj: Prisma.InputJsonObject) => void }, ): Promise { const target = vigieProcureBridge(); if (!target) return false; diff --git a/apps/api/test/vigieprocure-bridge.spec.ts b/apps/api/test/vigieprocure-bridge.spec.ts index 8cc628f3a..f18446df0 100644 --- a/apps/api/test/vigieprocure-bridge.spec.ts +++ b/apps/api/test/vigieprocure-bridge.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Prisma } from "@crm/db"; import { envoyerEvenementVigieProcure, vigieProcureBridge, @@ -50,7 +51,7 @@ describe("vigieProcureBridge", () => { describe("envoyerEvenementVigieProcure", () => { it("returns false silently when no bridge is configured -- best effort, never throws", async () => { - const logged: Record[] = []; + const logged: Prisma.InputJsonObject[] = []; const result = await envoyerEvenementVigieProcure( { eventId: "task-1", kind: "deal.stage.changed", payload: {} }, { debug: (obj) => logged.push(obj) }, From 9061103e247fa8fd713fb686ac6e26f4589a604b Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 05:53:08 +0200 Subject: [PATCH 29/36] feat(db): colonne siren sur Company, unicite scopee archivedAt Rapproche la fiche CRM du referentiel SIRENE cote VigieProcure (GET /api/v1/companies/resolve, api_v2). Nullable -- la plupart des fiches n'ont pas encore ete resolues, et les comptes non francais n'en auront jamais. Meme patron que le champ `domain` deja en place : CHAR(9) format SIRENE standard, contrainte unique scopee sur les fiches actives (`archivedAt IS NULL`) pour permettre la reutilisation d'un SIREN apres archivage. Aucun seed/backfill : la migration ajoute la colonne vide, ne resout pas retroactivement les fiches existantes. Cf. plan/CR reports/2026-09/CR/CR-RESOLUTION-SIREN-CRM-20260904.md (repo vigieprocure) pour le contexte complet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- packages/db/prisma/schema.prisma | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7413d878f..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]) From aec33d59927dddf32855b8524ba0d548a937d49e Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 05:54:01 +0200 Subject: [PATCH 30/36] feat(db): migration SQL add_company_siren ALTER TABLE company ADD COLUMN siren CHAR(9) + index unique partiel sur (siren) WHERE archivedAt IS NULL. Meme forme que la migration 20260820161500_archive_scoped_uniqueness (patron domain) deja en prod. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- .../20260904000000_add_company_siren/migration.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/db/prisma/migrations/20260904000000_add_company_siren/migration.sql 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); From cfdb64141ff58d29eeadf5d48d57a75e353c29ed Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 15:24:25 +0200 Subject: [PATCH 31/36] feat(companies): resolve and set SIREN via VigieProcure Ajoute deux procedures tRPC (companies.resolveSiren, companies.setSiren) qui appellent GET /api/v1/companies/resolve cote api_v2 avec un JWT de service (VIGIEPROCURE_API_JWT/VIGIEPROCURE_API_URL, meme doctrine que VIGIEPROCURE_WEBHOOK_URL/_SECRET -- absent = fonctionnalite degradee, jamais un appel non authentifie). Sur la fiche company : affiche le SIREN existant, ou propose de le resoudre sur clic explicite. Un seul candidat "exact" ecrit automatiquement (exception ciblee et reversible, validee par Franck -- jamais depuis un effet au chargement ni un backfill de masse). Sinon, liste les candidats pour choix manuel. Conflit d'unicite (P2002 sur Company.siren) traduit en erreur utilisateur nommant la fiche en conflit, jamais un 500 nu. Inclut la regeneration de apps/api/src/generated/server.ts, qui corrige au passage une dette preexistante (excludeEmail/restoreEmail declares dans activities.router.ts depuis le commit 044e350 mais jamais regeneres). A provisionner separement (hors perimetre de ce chantier) : VIGIEPROCURE_API_URL, VIGIEPROCURE_API_JWT sur crm-api en production. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- .env.example | 10 + apps/api/src/companies/companies.contracts.ts | 45 ++++ apps/api/src/companies/companies.router.ts | 22 ++ apps/api/src/companies/companies.service.ts | 69 ++++++ .../vigieprocure-companies.client.ts | 126 +++++++++++ apps/api/src/config/env.validation.ts | 19 ++ apps/api/src/generated/server.ts | 20 +- .../vigieprocure-companies-client.spec.ts | 119 ++++++++++ .../components/crm/company-siren-field.tsx | 206 ++++++++++++++++++ .../crm/record-sheet/company-sheet.tsx | 2 + 10 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/companies/vigieprocure-companies.client.ts create mode 100644 apps/api/test/vigieprocure-companies-client.spec.ts create mode 100644 apps/app/components/crm/company-siren-field.tsx diff --git a/.env.example b/.env.example index 341991cbb..4570aeb2f 100644 --- a/.env.example +++ b/.env.example @@ -122,6 +122,16 @@ GOOGLE_CLIENT_SECRET="" # 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/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index d2157334e..35c2c66d0 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,47 @@ 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().length(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(), + conflictingCompanyId: z.string(), + conflictingCompanyName: 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..de75f793c 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,73 @@ 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.", + conflictingCompanyId: conflicting?.id ?? "", + conflictingCompanyName: conflicting?.name ?? "Another company", + }; + } + 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 ba6effdd8..8eb776e75 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -140,6 +140,25 @@ export class EnvironmentVariables { @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/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/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/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 })} /> + Date: Fri, 4 Sep 2026 15:32:28 +0200 Subject: [PATCH 32/36] fix(companies): validate SIREN digits, drop unused conflict fields - companySetSirenInput.siren: .length(9) laissait passer 9 caracteres non numeriques alors que le message annoncait "9 digits" -- .regex le fait respecter reellement. - companySetSirenOutput (conflict): conflictingCompanyId/Name n'etaient consommes nulle part cote frontend (seul `reason` est lu), et le fallback `conflicting?.id ?? ""` etait un `z.string()` non-nullable rempli d'une chaine vide dans un cas quasi mort (le findFirst est scope par la meme contrainte unique partielle que le P2002 qui declenche ce chemin). Retire les deux champs plutot que de les rendre nullable pour un consommateur qui n'existe pas. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/src/companies/companies.contracts.ts | 6 +++--- apps/api/src/companies/companies.service.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index 35c2c66d0..41d7f4d1a 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -288,7 +288,9 @@ export const companySirenResolveOutput = z.discriminatedUnion("outcome", [ export const companySetSirenInput = z.object({ id: z.string(), - siren: z.string().length(9, "A SIREN is 9 digits."), + siren: z + .string() + .regex(/^\d{9}$/, "A SIREN is 9 digits."), }); export const companySetSirenOutput = z.discriminatedUnion("outcome", [ @@ -296,7 +298,5 @@ export const companySetSirenOutput = z.discriminatedUnion("outcome", [ z.object({ outcome: z.literal("conflict"), reason: z.string(), - conflictingCompanyId: z.string(), - conflictingCompanyName: z.string(), }), ]); diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index de75f793c..f88f297ba 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -777,8 +777,6 @@ export class CompaniesService { reason: conflicting ? `Ce SIREN est deja rattache a la fiche ${conflicting.name}.` : "Ce SIREN est deja rattache a une autre fiche.", - conflictingCompanyId: conflicting?.id ?? "", - conflictingCompanyName: conflicting?.name ?? "Another company", }; } throw this.translate(cause, id); From 9402fb91a9a58352e7a1a9548c6f563b414d46d4 Mon Sep 17 00:00:00 2001 From: Franck H Date: Fri, 4 Sep 2026 15:33:00 +0200 Subject: [PATCH 33/36] style(companies): apply biome formatting to siren input schema Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- apps/api/src/companies/companies.contracts.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/src/companies/companies.contracts.ts b/apps/api/src/companies/companies.contracts.ts index 41d7f4d1a..c776ec3ac 100644 --- a/apps/api/src/companies/companies.contracts.ts +++ b/apps/api/src/companies/companies.contracts.ts @@ -288,9 +288,7 @@ export const companySirenResolveOutput = z.discriminatedUnion("outcome", [ export const companySetSirenInput = z.object({ id: z.string(), - siren: z - .string() - .regex(/^\d{9}$/, "A SIREN is 9 digits."), + siren: z.string().regex(/^\d{9}$/, "A SIREN is 9 digits."), }); export const companySetSirenOutput = z.discriminatedUnion("outcome", [ From 49707d44aac1d4ea89d2720a45d72e4b13b27b16 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sat, 5 Sep 2026 10:26:23 +0200 Subject: [PATCH 34/36] ci: build et push les 3 images CRM vers ghcr.io sur push release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déplace le build Docker des 3 services (api/app/agent) de vigiep1 vers GitHub Actions. Sur vigiep1, bun install (1771+ paquets) prenait 36-41min par service faute de cache incrémental (Dockerfile réinstalle à froid volontairement, cf. incident disque plein du 29/08) et de disque I/O lent sur /mnt/hermes-extra -- un rebuild complet des 3 services dépassait 2h. Le cache GitHub Actions (type=gha) devrait ramener ça à quelques dizaines de secondes sur les runs suivants, sur des runners dédiés (pas de contention RAM/IO avec dagster/n8n/ vigieproc-api qui tournent sur le même host). vigiep1 passera de `docker compose build` à `docker compose pull` -- changement du docker-compose.yml de production (non versionné dans ce repo) à faire séparément, avec provisionnement d'un PAT read:packages pour l'authentification à ghcr.io (registre privé). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH --- .github/workflows/build-images.yml | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/build-images.yml 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 }} From 650d3647b7790f8008a41cc17b9462caef4c9679 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sat, 12 Sep 2026 18:17:28 +0200 Subject: [PATCH 35/36] fix(tracking): utilise APP_URL au lieu de l'origine derivee de la requete Le tracker JS servi par /t/[site] embarquait une URL d'ingestion cassee (https://0.0.0.0:3000/api/t/e au lieu de https://crm.vigieproc.fr/api/t/e), donc aucun evenement de tracking n'atteignait jamais crm-api en production. `new URL(request.url).origin` reflete le socket brut vu par le serveur Next.js sous `next start`, pas le Host public transmis par nginx -- confirme en forcant Host/X-Forwarded-* directement contre le conteneur sans effet. APP_URL existe deja dans l'environnement du conteneur (compose vigiep1) et sert deja le meme role (session cote serveur, allowedDevOrigins) mais n'avait pas d'export dans lib/env.ts, contrairement a API_URL. Ajoute cet export (lecture directe, sans indirection NEXT_PUBLIC_ -- ce fichier est un route handler server-side, jamais expose au bundle client) et l'utilise pour construire l'URL d'ingestion du tracker. Diagnostic : crm-trycompai-orchestrator + devops-orchestrator (nginx ecarte par test direct), confirme independamment ici par lecture du Dockerfile/compose/build-images.yml. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P6yNGEKWRMLZrVyjCrD61G --- apps/app/app/t/[site]/route.ts | 12 +++++++++--- apps/app/lib/env.ts | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) 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/lib/env.ts b/apps/app/lib/env.ts index bd02b96a8..20fa14d98 100644 --- a/apps/app/lib/env.ts +++ b/apps/app/lib/env.ts @@ -1,6 +1,12 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +// Server-only: never exposed to the client bundle (no NEXT_PUBLIC_ prefix). +// Read directly from the container environment, same as the Dockerfile's +// runtime stage -- route handlers execute server-side, so this needs no +// build-time injection through next.config.ts's `env:` block. +export const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; + export function isMarketing(): boolean { return process.env.IS_MARKETING === "true"; } From e6b20bb9f6bba0a704de5ddd06f863745c073832 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:15:44 +0000 Subject: [PATCH 36/36] chore(main): release 1.16.0 --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 49 +++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) 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/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/package.json b/package.json index 534ec9d00..41b56f96c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.15.3", + "version": "1.16.0", "scripts": { "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build",