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.
LoggingModulestays first inAppModule's imports, and Better Auth routes log via its ownmiddlewareoption — it mounts beforeMiddlewareConsumer, so/api/auth/*never reaches ours.requestIdfromRequestLoggerMiddlewareviaAsyncLocalStorage;UserContextInterceptoraddsuserId. Prisma statements are opt-in (PRISMA_LOG_QUERIES).
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.
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
organizationIdhas turned the plugin into tenancy plumbing. - Signing in is the join; no invite flow.
ensureWorkspaceMembershipruns indatabaseHooks.session.create.beforeand degrades, never throws — a throw fails the session create and locks everyone out. The plugin'sinvitationtable 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.WorkspaceServiceadds one invariant: the last owner cannot be demoted, withFOR UPDATEon 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 sameworkspace.updateas settings. - Onboarded state is
onboardedAtinside the plugin'smetadatablob, not a column;isOnboarded/markOnboarded(@crm/db/workspace) are the only accessors, andmarkOnboardedpreserves every other key. - The name starts as
DEFAULT_WORKSPACE_NAME(CRM), a placeholder not an answer. The header renders<name> CRM, soworkspaceLabeltests the name rather than comparing to the default. - The website queues the agent's
workspace-profiletask and goes throughnormalizeDomain, rejecting null. Stored canonical, so re-saving uncanonically counts as a change and re-queues research.
Onboarding, then /onboarding/research for the Context key. Asked server-side every
request.
getSessionCookie()decides signed-in; pages still resolve the real session viarequireSession().- 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 (
unknownlets the request through). /sign-inand/eveare ungated./sign-inis the only path a stranger may read;/joins it only whenIS_MARKETINGis set.- There is no way past the key gate but to answer — Skip stranded installs, every
later company sitting
PENDINGwith nothing saying so.
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. ensureWorkspaceMembershipreconciles it;RESERVED_SLUGSprevents 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.tsxis the backstop. appPathinproxy.tsis the one place/resolves for a signed-in rep, which keeps everycallbackURLcorrect without knowing about slugs.- Renaming moves the URL, so
workspace-form.tsxreplaces the location onto the slugworkspace.updatereturns.
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.
SsoServicepassesWORKSPACE_ID, never an input.- Management is tRPC (
sso.*); signing in isauthClient.signIn.sso(). sso.signInOptionsis the one public procedure in the app. Every othersso.*takesAuthMiddlewareat 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/*. - Local email/password is always available. SSO is optional and provider OAuth is reserved for linking mail and calendar accounts from Settings → Connections.
ALLOWED_SIGN_INstill decides who gets an account, indatabaseHooks.user.create.before, for SSO sign-ups too.organizationProvisioning: { disabled: true }—ensureWorkspaceMembershipalready does the join.
- One router per module,
*.router.ts(the codegen glob), with@Router({ alias })and@UseMiddlewares(AuthMiddleware). NoAuthMiddlewaremeans public — there is no other guard. - Routers are thin: zod in, service call out; Prisma lives in
*.service.ts. - Services throw Nest's
HttpExceptionfamily;DomainErrorMiddlewaremaps them. - Filter, sort and paginate in Prisma. List procedures take
listInputand return{ rows, total, facetCounts }. Never filter a whole table in the browser; never interpolatesortinto a field name — useresolveOrderBy. src/generated/server.tsis generated and committed, andbuildmust never regenerate it — the generator needs GLIBC 2.39, newer than Vercel's build image. Onlycheck-typesanddevrun it. If the app cannot see a new procedure, it has not run.
External integrations use the versioned REST surface at /api/v1/*. Create a
personal access token in Settings → API access, then send it as
Authorization: Bearer crm_pat_….
GET /api/v1/mereturns the token owner.GET|POST|PATCH|DELETE /api/v1/companiesand/api/v1/companies/:idmanage companies.GET|POST|PATCH|DELETE /api/v1/contactsand/api/v1/contacts/:idmanage contacts.GET|POST|PATCH|DELETE /api/v1/dealsand/api/v1/deals/:idmanage deals.GET|POST /api/v1/activitiesandPATCH /api/v1/activities/:idmanage timeline tasks and activities.POST /api/v1/linkedin/capturesupserts a manually confirmed LinkedIn relationship, creates a Company when needed, and queues agent triage.POST /api/v1/surveys/responsesaccepts idempotent, incremental survey events;GET /api/v1/surveys,GET /api/v1/surveys/responses, andGET /api/v1/surveys/responses/:idexpose survey progress and analysis.crm:readpermits reads;crm:writepermits reads and writes.
Only a hash of each token is stored. The plaintext token is shown once when it
is created and can be revoked from Settings. Tokens may have an expiry date.
The public web hostname is sufficient, for example
https://crm.example.com/api/v1; a separate api. hostname is optional. A
browser-based client on another origin must have that origin included in the
comma-separated APP_URL allow-list. Server-to-server clients do not need CORS.
apps/api/src/mailbox is everything no individual provider 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.storeis the only writer ofEmailThread,EmailMessageand theEMAILactivity. Gmail, Outlook and Zoho each parse their wire format down to oneIncomingMessageand 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→ ownMessage-ID, so a rep on Gmail and a rep on Outlook land on the sameEmailThreadfor the same conversation. Graph only returnsinternetMessageHeaderswhen$selected and not for every message, so Outlook falls back tooutlook-conversation:<conversationId>— threading that still holds inside Outlook, just not across to Gmail. MailboxSync.sourceis the discriminator —calendar,gmail,outlook,zoho.authAccountIdselects the linked OAuth account andexternalIdselects the provider mailbox or calendar. Each provider's module only ever sees its own, andsync/mailbox-sync.service.tsis the one place that dispatches. One cron, one budget:POST /internal/sync/mailboxes(/googleis kept as an alias so an existing deployment's cron keeps working).- Gmail is forward-only from a
historyId, Outlook and Zoho from timestamps. Graph has no mailbox-wide delta, so the Outlook cursor is the lastreceivedDateTimeseen, re-read with a one-second overlap;rfcMessageIdis unique, so the overlap costs a duplicate fetch and never a duplicate row. - Disconnect deletes one Better Auth account and cascades its sync rows. Existing CRM activities stay unless the user chooses delete first. Google revocation is also attempted remotely; other provider consent can be removed in the provider portal.
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 besideFREE_EMAIL_DOMAINS;domainFromEmailreturns null for both, andcompanyForEmailis the only path from address to company — so a caller ignorant of the rule still cannot create one..calendar.google.comcovers shared calendars, rooms and ICS feeds.- Matches the host, never a substring —
calendar.acme.comis a real company. isMachineAddressalso 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.
syncAttendeesfilters the same addresses besideattendee.resource. isAutomatedAddressis a separate list about the local part (sales@,noreply@), which is whysupport@acme.comnever becomes a lead.
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.
roleis blanked to null, never stored as""—blankToNull, as everywhere else.
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.deletewritesSuppressedContact, andexternalParticipantsdrops it like aSuppressedDomain— one filter covering contact creation, company auto-creation and attribution. - Keyed lower case.
normalizeEmail(crm/values.ts) is the one canonicaliser, oncontacts.create,.updateand the suppression; conflict checks andallowAgainmatch 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 ownP2025throughtranslate. - Adding them back lifts the suppression via
allowAgaininside 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
AgentTaskandAgentEventyourself — they carrycontactId/companyIdwith no foreign key, so nothing cascades. - Recompute
lastActivityAton exactly the records the delete reached.ActivityStampService.targetsOf(where)collects them inside the transaction (the evidence is what gets deleted);recomputeManyrestamps. A company'swheremust 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.
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.
- Invalidate in
onSuccessthroughuseCrmCache()(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 placerefetchType: "none"is right: the deleted record'sbyIdquery 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(), notqueryKey()— the latter stamps{ type: "query" }and silently cannot match{ type: "infinite" }.activities.timelineis read both ways. cache-manageris per-value and opt-in, not an interceptor;AuthService.getProfileis the model.- Background writes need polling, not invalidation —
refetchIntervalwhilePENDING/RUNNING, viaisEnriching()andENRICHMENT_POLL_MS. Lists poll too, not just the sheet.