Skip to content

Latest commit

 

History

History
153 lines (135 loc) · 9.19 KB

File metadata and controls

153 lines (135 loc) · 9.19 KB

API Rules - Always review when working on our API

Logging: use the Nest logger, attach fields, never console.log

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. Write logger.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 inbound x-request-id) by RequestLoggerMiddleware, returned on the response header, included in error bodies, and stored in AsyncLocalStorage so every log line during that request is correlated. UserContextInterceptor adds userId once the auth guard has resolved the session.
  • Better Auth routes are logged through its middleware option (BetterAuthModule.forRoot({ auth, middleware: logAuthRoute })). It mounts its handler straight onto the HTTP adapter during module configuration, before Nest applies anything from MiddlewareConsumer, so /api/auth/* would otherwise never reach the middleware. LoggingModule must stay first in AppModule'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 at debug. It is off by default because it buries every other line under a wall of SELECTs. Bound parameters are dropped even when it is on. Prisma warnings and errors always flow through the app logger via PrismaLogBridge; outside the API (seeds, scripts, the Next.js app) they fall back to the console sink in packages/db/src/client.ts.

Intelligence never lives in the API

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.

There are no organizations

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.

tRPC is the data surface; REST is for auth and health

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.ts so the codegen glob finds it, carrying @Router({ alias: "…" }) and @UseMiddlewares(AuthMiddleware). A router with no AuthMiddleware is 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 HttpException family (NotFoundException, BadRequestException, …). DomainErrorMiddleware maps 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 interpolate sort into a Prisma field name — resolve it through resolveOrderBy against the columns that module allows.
  • The router type is generated, not hand-written: bun run --filter=api trpc:generate writes src/generated/server.ts, which the app imports as type { AppRouter } from "api/app-router". bun run dev keeps it in watch mode. If the app cannot see a new procedure, the generator has not run.
  • src/generated/server.ts is committed, and build must never regenerate it. The generator ships a native binary that needs GLIBC 2.39 — newer than Vercel's build image — so a build task that depends on trpc:generate fails every deploy. That is why apps/api/.gitignore ignores src/generated/* with a !src/generated/server.ts exception, and why only check-types and dev run the generator. Regenerate locally and commit the result with the router change that caused it.

Freshness: invalidate the query, don't disable the cache

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 through useCrmCache() (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(), not queryKey(). tRPC stamps the query type into the key, so queryKey() yields { type: "query" } and infiniteQueryOptions caches 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.timeline is, as a paged history and as a pinned top-ten).
  • cache-manager is still there (apps/api/src/cache, Redis when REDIS_URL is set) but it is used deliberately, per value, by services that want it — AuthService.getProfile is the model: read through, write on miss, and an explicit invalidateProfile on 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: refetchInterval while the record's status is PENDING/RUNNING, and stop once it settles. Use isEnriching() and ENRICHMENT_POLL_MS from components/crm/enrichment-status so 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.