Logging lives in apps/api/src/logging. ContextLogger extends Nest's
ConsoleLogger and is installed in main.ts, so new Logger(Thing.name)
anywhere in the app picks it up. Format and level are not configurable —
they follow NODE_ENV: JSON at log and above in production, colourised with
debug/verbose locally.
- Attach data as one object, not extra arguments. Nest prints one line per
argument, so
logger.log("Saved", { userId })emits two lines. Writelogger.log({ message: "Saved", userId })instead — the fields are hoisted to the top level in JSON and rendered after the text in development. - Errors pass the stack as the second argument:
logger.error({ message: "…" }, error instanceof Error ? error.stack : String(error)). Handing Nest the error object instead prints it as a second message and drops the trace. - Every request carries a
requestId, generated (or taken from an inboundx-request-id) byRequestLoggerMiddleware, returned on the response header, included in error bodies, and stored inAsyncLocalStorageso every log line during that request is correlated.UserContextInterceptoraddsuserIdonce the auth guard has resolved the session. - Better Auth routes are logged through its
middlewareoption (BetterAuthModule.forRoot({ auth, middleware: logAuthRoute })). It mounts its handler straight onto the HTTP adapter during module configuration, before Nest applies anything fromMiddlewareConsumer, so/api/auth/*would otherwise never reach the middleware.LoggingModulemust stay first inAppModule's imports for the same reason. - Never log headers, query strings, or request bodies. They routinely carry session cookies and personal data. Log the specific fields a handler knows are safe.
- Prisma statement logging is opt-in via
PRISMA_LOG_QUERIES=true, and is emitted atdebug. It is off by default because it buries every other line under a wall ofSELECTs. Bound parameters are dropped even when it is on. Prisma warnings and errors always flow through the app logger viaPrismaLogBridge; outside the API (seeds, scripts, the Next.js app) they fall back to the console sink inpackages/db/src/client.ts.
This is an agentic-first platform. The API serves HTTP, auth, tRPC and the
Google sync. It does not research, enrich, score, summarise, match identities or
decide anything about a person or a company — not as a fallback, not "just the
cheap bit", not behind a flag. That work belongs to the eve agent in
apps/agent, which owns the vendor clients, the confidence model and the
writes.
Nest's half of the contract is to report that something happened — a thread
was ingested, a company was created, an attendee is unknown — and let the agent
decide what it means. A Nest service that calls an enrichment API is a bug, and
the reason is in the tree: two identity matchers were copied across apps/api
and apps/agent, and the copies silently drifted until one of them matched
every employer on earth. See
docs/plan/contact-intelligence-agent.md.
apps/api/src/enrichment/ is gone. What replaced it is
apps/api/src/agent/agent-trigger.service.ts — one service with one verb, which
writes an AgentTask row saying this happened and why it might matter. A row
rather than an HTTP call: the agent leases work from that table already, so the
row is the message, and it survives the agent being down, redeployed, or
slower than the request that produced it.
If you are about to add a vendor client to apps/api, you want
apps/agent/agent/lib instead.
This is an internal tool behind Google sign-in, and it is single tenant.
There is no Organization model, no x-organization-slug header, no org
context interceptor, and no org-scoped cache keys. "Signed in" is the entire
authorisation model.
If you are porting something from the Comp AI MVP, delete the org plumbing
rather than stubbing it — an organizationId that is always the same value is
a column, an index and a where clause that buy nothing, and a permissions
check that always returns true reads like a real one at review time.
Everything the app reads or writes goes through nestjs-trpc routers under
/api/trpc, wired in apps/api/src/trpc. The remaining REST controllers are
/api/auth/* (Better Auth) and /health.
- One router per module, named
*.router.tsso the codegen glob finds it, carrying@Router({ alias: "…" })and@UseMiddlewares(AuthMiddleware). A router with noAuthMiddlewareis public — there is no other guard. - Routers are thin. They validate input with zod and call a service; the
Prisma work lives in
*.service.ts, which is also where a REST controller or a background job would call in. - Services throw Nest's
HttpExceptionfamily (NotFoundException,BadRequestException, …).DomainErrorMiddlewaremaps those onto tRPC error codes, so a service does not need to know it is being called over tRPC. - Filtering, sorting and pagination happen in Prisma. List procedures take
the shared
listInput(apps/api/src/trpc/list-input.ts) and return{ rows, total, facetCounts }. Never return a whole table and filter in the browser, and never interpolatesortinto a Prisma field name — resolve it throughresolveOrderByagainst the columns that module allows. - The router type is generated, not hand-written:
bun run --filter=api trpc:generatewritessrc/generated/server.ts, which the app imports astype { AppRouter } from "api/app-router".bun run devkeeps it in watch mode. If the app cannot see a new procedure, the generator has not run. src/generated/server.tsis committed, andbuildmust never regenerate it. The generator ships a native binary that needs GLIBC 2.39 — newer than Vercel's build image — so abuildtask that depends ontrpc:generatefails every deploy. That is whyapps/api/.gitignoreignoressrc/generated/*with a!src/generated/server.tsexception, and why onlycheck-typesanddevrun the generator. Regenerate locally and commit the result with the router change that caused it.
There is no HTTP response cache in front of tRPC. Freshness is TanStack Query's job: a mutation invalidates the query keys it affected, and the list refetches.
- Invalidate on the client, in the mutation's
onSuccess— but throughuseCrmCache()(apps/app/lib/trpc/cache.ts), not by listing keys at the call site. Say what changed (cache.deal(id),cache.company(id),cache.contact(id),cache.activity()) and the module owns the fan-out. Twelve hand-written key lists is how they drifted: a stage change did not refresh the timeline entry it writes, creating a deal did not refresh the board, and nothing refreshed the overview, so a rep could close a deal and watch their own numbers not move. A new mutation adds a call there, not a new list of keys. - Pass
{ settle: "record" }when the caller is an inline editor. The default waits for every affected view to refetch, which is right when the point of the action is the view changing (a card moving between board columns);"record"waits only for the edited record so the field's spinner clears as soon as the value under it is right, and lets the table behind the sheet catch up on its own. - An infinite query needs
pathKey(), notqueryKey(). tRPC stamps the query type into the key, soqueryKey()yields{ type: "query" }andinfiniteQueryOptionscaches under{ type: "infinite" }— the two cannot partially match, and invalidating with the wrong one is silent: it reports success, refetches the sibling non-infinite queries, and leaves the infinite one stale until a reload.pathKey()carries no type and matches both, which is what you want whenever a procedure is read both ways (activities.timelineis, as a paged history and as a pinned top-ten). cache-manageris still there (apps/api/src/cache, Redis whenREDIS_URLis set) but it is used deliberately, per value, by services that want it —AuthService.getProfileis the model: read through, write on miss, and an explicitinvalidateProfileon change. It is not a global interceptor, so nothing is cached unless a service asks for it.- Background writes the browser cannot see — enrichment finishing, most
obviously — are not invalidations at all, because no client action caused
them. Poll for those:
refetchIntervalwhile the record's status isPENDING/RUNNING, and stop once it settles. UseisEnriching()andENRICHMENT_POLL_MSfromcomponents/crm/enrichment-statusso the rule is one definition. A list polls too, not just the record sheet — the company sheet polled and the companies table did not, so a newly added company's logo and industry appeared in the sheet and stayed blank in the table behind it until a reload.