# API Rules ## Logging `apps/api/src/logging`. `new Logger(Thing.name)` picks up `ContextLogger`. **Never `console.log`.** Format follows `NODE_ENV` and is not configurable. - **One object, not extra arguments** — `logger.log({ message: "Saved", userId })`; Nest prints a line per argument. - **Errors pass the stack second** — `logger.error({ message }, err.stack)`. Passing the error object drops the trace. - **Never log headers, query strings, or bodies** — cookies and personal data. - **`LoggingModule` stays first in `AppModule`'s imports**, and Better Auth routes log via its own `middleware` option — it mounts before `MiddlewareConsumer`, so `/api/auth/*` never reaches ours. - `requestId` from `RequestLoggerMiddleware` via `AsyncLocalStorage`; `UserContextInterceptor` adds `userId`. Prisma statements are opt-in (`PRISMA_LOG_QUERIES`). ## Intelligence never lives in the API The API serves HTTP, auth, tRPC and the mailbox sync. It does **not** research, enrich, score, summarise, match identities or decide anything about a person or company — not as a fallback, not behind a flag. That is the eve agent in `apps/agent`, which owns the vendor clients, the confidence model and the writes. Nest's half is to report *that something happened*: `AgentTriggerService` writes an `AgentTask` row. A row, not an HTTP call — the agent already leases from that table, so the row survives the agent being down. About to add a vendor client to `apps/api`? You want `apps/agent/agent/lib`. One documented exception, for timing: the exchange-rate fetcher, below. ## One organization, and it is not a tenancy boundary Single tenant. No org header, no org interceptor, no org-scoped cache keys, **no `organizationId` on any CRM record.** A **singleton workspace** exists — Better Auth's `organization` plugin, one row with id `WORKSPACE_ID` (the literal `workspace`, in `@crm/db`, re-exported by `@crm/auth` so the agent needn't depend on it). It answers only: what are we called, who works here, what do we sell. - **The id is a constant, never a parameter.** A function taking an `organizationId` has turned the plugin into tenancy plumbing. - **Signing in is the join; no invite flow.** `ensureWorkspaceMembership` runs in `databaseHooks.session.create.before` and **degrades, never throws** — a throw fails the session create and locks everyone out. The plugin's `invitation` table is unused. - **First account is owner**, and the hook enrols pre-existing users, oldest first. - **Permissions come from `@crm/auth`** — `canRenameWorkspace`, `canChangeRole`, `canConfigureSso`, `canManageCurrency` — enforced by the service *and* used to disable the UI control, so the button and the 403 cannot disagree. `WorkspaceService` adds one invariant: **the last owner cannot be demoted**, with `FOR UPDATE` on the owner rows before counting. - **Reads and writes go through tRPC**, not `authClient.organization.*`. - **Name and website are required at onboarding and cannot be skipped**, in the form *and* in `updateWorkspaceInput`, posting the same `workspace.update` as settings. - **Onboarded state is `onboardedAt` inside the plugin's `metadata` blob**, not a column; `isOnboarded`/`markOnboarded` (`@crm/db/workspace`) are the only accessors, and `markOnboarded` preserves every other key. - **The name starts as `DEFAULT_WORKSPACE_NAME` (`CRM`), a placeholder not an answer.** The header renders ` CRM`, so `workspaceLabel` tests the name rather than comparing to the default. - **The website queues the agent's `workspace-profile` task** and goes through `normalizeDomain`, rejecting null. Stored canonical, so re-saving uncanonically counts as a change and re-queues research. ### Gates in `proxy.ts` Onboarding, then `/onboarding/research` for the Context key. Asked server-side every request. - **`getSessionCookie()` decides signed-in**; pages still resolve the real session via `requireMailboxAccess()`. - **Nothing is cached in a cookie** — both facts revert on a database reset while a year-long marker insists the gate passed. Cache in the API if cost ever matters. - **Both reads run concurrently**, but order decides which is *asked* — the research read is never made while onboarding is open. - **An unreachable API fails open** (`unknown` lets the request through). - **`/sign-in`, `/grant-access`, `/eve` are ungated.** `/sign-in` is the only path a stranger may read; `/` joins it only when `IS_MARKETING` is set. - **There is no way past the key gate but to answer** — Skip stranded installs, every later company sitting `PENDING` with nothing saying so. ### The name is also the URL Served under the workspace slug (`/comp-ai/companies`). **Cosmetic, not tenancy** — every query still resolves through `WORKSPACE_ID`. - **The slug is the plugin's column**, written by `workspaceSlug(name)` (`@crm/db/workspace`) on rename and create. **Never derive it on read.** - `ensureWorkspaceMembership` reconciles it; `RESERVED_SLUGS` prevents collision with a real route (a collision gets `-crm`). - **The proxy is the only thing that puts the slug on.** Missing or stale slugs are redirected with the query string intact, not 404'd; `[slug]/layout.tsx` is the backstop. - `appPath` in `proxy.ts` is the one place `/` resolves for a signed-in rep, which keeps every `callbackURL` correct without knowing about slugs. - **Renaming moves the URL**, so `workspace-form.tsx` replaces the location onto the slug `workspace.update` returns. ## SSO is a row, not a deployment An `ssoProvider` row via Better Auth's `sso` plugin, on Settings → SSO, because a self-hoster's admin cannot redeploy. - **OpenID Connect only** — issuer, client id, secret; endpoints from discovery. No SAML UI: it needs an X.509 cert and SP signing key we have nowhere to keep. - `SsoService` passes `WORKSPACE_ID`, never an input. - **Management is tRPC (`sso.*`); signing in is `authClient.signIn.sso()`.** - **`sso.signInOptions` is the one public procedure in the app.** Every other `sso.*` takes `AuthMiddleware` at the *method*, which is what leaves it open. A client secret is never read back out. - **It is the API's answer, not the app's** — the API serves `/api/auth/*`. - **An install with no Google, no Microsoft and no provider says so**, naming the variables; a read that *fails* falls back to offering Google. - **A provider hides the social buttons, it does not disable them** — `/sign-in?method=google` and `?method=microsoft` still work, so a mistyped issuer cannot lock an admin out. - **Signing in with an IdP does not cost you Gmail.** `needsMailboxGrant` (`@crm/auth`) walls only an account whose sign-in rows are *all* mailbox providers — Google, Microsoft, or both — and none of them granted. `mailboxGrantsNeeded` returns which, so `/grant-access` offers the button they can actually use. - `ALLOWED_SIGN_IN` still decides who gets an account, in `databaseHooks.user.create.before`, for SSO sign-ups too. - `organizationProvisioning: { disabled: true }` — `ensureWorkspaceMembership` already does the join. ## tRPC is the data surface; REST is auth and health only - **One router per module**, `*.router.ts` (the codegen glob), with `@Router({ alias })` and `@UseMiddlewares(AuthMiddleware)`. **No `AuthMiddleware` means public — there is no other guard.** - **Routers are thin**: zod in, service call out; Prisma lives in `*.service.ts`. - Services throw Nest's `HttpException` family; `DomainErrorMiddleware` maps them. - **Filter, sort and paginate in Prisma.** List procedures take `listInput` and return `{ rows, total, facetCounts }`. Never filter a whole table in the browser; never interpolate `sort` into a field name — use `resolveOrderBy`. - **`src/generated/server.ts` is generated *and committed*, and `build` must never regenerate it** — the generator needs GLIBC 2.39, newer than Vercel's build image. Only `check-types` and `dev` run it. If the app cannot see a new procedure, it has not run. ## Two mail providers, one pipeline `apps/api/src/mailbox` is everything neither Google nor Microsoft owns: `MailboxApiClient` (bearer GET, and the one place a status code becomes an outcome), `SyncStateService` (the `MailboxSync` row), `MailboxTokenService`, `MailboxMatchService`, `participants.ts`, `message-text.ts`, and `ThreadWriterService`. - **`ThreadWriterService.store` is the only writer of `EmailThread`, `EmailMessage` and the `EMAIL` activity.** Gmail and Outlook each parse their own wire format down to one `IncomingMessage` and hand it over; matching, threading, counting and stamping happen once. A second copy of that is how a rule like *reply before you create a company* comes to be true in one inbox and not the other. - **A thread is keyed by RFC message id, not by the provider's thread id.** Root comes from `References` → `In-Reply-To` → own `Message-ID`, so a rep on Gmail and a rep on Outlook land on the same `EmailThread` for the same conversation. Graph only returns `internetMessageHeaders` when `$select`ed and not for every message, so Outlook falls back to `outlook-conversation:` — threading that still holds inside Outlook, just not across to Gmail. - **`MailboxSync.source` is the discriminator** — `calendar`, `gmail`, `outlook`. Each provider's module only ever sees its own, and `sync/mailbox-sync.service.ts` is the one place that dispatches. One cron, one budget: `POST /internal/sync/mailboxes` (`/google` is kept as an alias so an existing deployment's cron keeps working). - **Gmail is forward-only from a `historyId`, Outlook from a timestamp.** Graph has no mailbox-wide delta, so the Outlook cursor is the last `receivedDateTime` seen, re-read with a one-second overlap; `rfcMessageId` is unique, so the overlap costs a duplicate fetch and never a duplicate row. - **Microsoft has no token-revocation endpoint.** `revoke` clears the columns and the UI says the consent itself is removed in the user's Microsoft account. Google's still posts to `oauth2.googleapis.com/revoke` and refuses to clear if that fails. ## Not every address on a thread is a person `externalParticipants` (`mailbox/participants.ts`) is the one gate, discarding **us** (allow-list domains, `User` table), **rep decisions** (`SuppressedContact`, `SuppressedDomain`), and **addresses no human reads**. - **`isMachineDomain` (`companies/domain.ts`) sits beside `FREE_EMAIL_DOMAINS`**; `domainFromEmail` returns null for both, and `companyForEmail` is the only path from address to company — so a caller ignorant of the rule still cannot create one. `.calendar.google.com` covers shared calendars, rooms and ICS feeds. - **Matches the host, never a substring** — `calendar.acme.com` is a real company. - **`isMachineAddress` also catches opaque local parts** (24 hex chars, UUIDs), deliberately narrow: a false positive is a real customer never filed. - **It leaves no row** — a rep may still type these into quick-add; only the *inbox* is barred from deciding. `syncAttendees` filters the same addresses beside `attendee.resource`. - **`isAutomatedAddress` is a separate list about the local part** (`sales@`, `noreply@`), which is why `support@acme.com` never becomes a lead. ## People on a deal `DealContact` is the join, and `deals.attachContact` / `detachContact` / `setContactRole` are the only ways to write it. `deals.contactOptions` is what the picker reads. - **A contact on a deal works at that deal's company**, enforced in the service and not merely by the picker — the same rule as `companies.setPrimaryContact`. - **Attaching is an upsert and re-attaching keeps the role already there**, so a double click cannot blank what somebody typed. - **Detaching removes the row, never the contact.** They stay in the CRM, on the company, with their history. - **`role` is blanked to null, never stored as `""`** — `blankToNull`, as everywhere else. ## Deleting a record `contacts.delete`, `companies.delete`, `deals.delete`. No soft delete, no archive. - **A deleted contact is suppressed by address**, or the sync recreates them from the next thread. `ContactsService.delete` writes `SuppressedContact`, and `externalParticipants` drops it like a `SuppressedDomain` — one filter covering contact creation, company auto-creation and attribution. - **Keyed lower case.** `normalizeEmail` (`crm/values.ts`) is the one canonicaliser, on `contacts.create`, `.update` and the suppression; conflict checks and `allowAgain` match case-insensitively. - **The address comes from the delete itself** (`tx.contact.delete({ select: { email: true } })`), not a read before it — and the 404 is that statement's own `P2025` through `translate`. - **Adding them back lifts the suppression** via `allowAgain` **inside the write's transaction**. Never automatic. - **Deleting a company does not suppress its domain** — its people survive with no company, and domain suppression stays the explicit Settings → Connections control. - **Clear `AgentTask` and `AgentEvent` yourself** — they carry `contactId`/`companyId` with no foreign key, so nothing cascades. - **Recompute `lastActivityAt` on exactly the records the delete reached.** `ActivityStampService.targetsOf(where)` collects them *inside* the transaction (the evidence is what gets deleted); `recomputeMany` restamps. A company's `where` must follow its deals: `{ OR: [{ companyId }, { deal: { companyId } }] }`. `recomputeAll()` is for a purge only. - **Recompute after commit, logging rather than throwing** — the row is already gone, and a raised error makes the browser skip invalidation and retry into a 404. ## Money A deal is sold in one currency and reported in another, and **only `baseAmount` may ever be summed**. The rules — `baseCurrency`, `countedWhere`/`pendingWhere`, frozen rates, the supported currencies, the keyless feed, and why the fetcher is the one documented exception to *no intelligence in the API* — are in **`docs/currency.md`**. Read it before touching any amount, total, chart or rate. ## Freshness: invalidate the query, don't disable the cache - **Invalidate in `onSuccess` through `useCrmCache()`** (`lib/trpc/cache.ts`), never by listing keys at the call site. Say what changed — `cache.deal(id)`, `cache.company(id)`, `cache.contact(id)`, `cache.activity()`. **A new mutation adds a call there, not a new list of keys.** - **A deletion is `cache.removed(ref)`** — one wide fan-out, and the only place `refetchType: "none"` is right: the deleted record's `byId` query is still mounted while the sheet animates shut, so refetching reads a 404 into the closing sheet, while leaving it alone serves 30s of a dead record from cache. - **`{ settle: "record" }`** for inline editors, so the field's spinner clears without waiting for the table. - **Infinite queries need `pathKey()`, not `queryKey()`** — the latter stamps `{ type: "query" }` and silently cannot match `{ type: "infinite" }`. `activities.timeline` is read both ways. - **`cache-manager` is per-value and opt-in**, not an interceptor; `AuthService.getProfile` is the model. - **Background writes need polling, not invalidation** — `refetchInterval` while `PENDING`/`RUNNING`, via `isEnriching()` and `ENRICHMENT_POLL_MS`. **Lists poll too, not just the sheet.**