Skip to content

Latest commit

 

History

History
265 lines (227 loc) · 15.1 KB

File metadata and controls

265 lines (227 loc) · 15.1 KB

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 argumentslogger.log({ message: "Saved", userId }); Nest prints a line per argument.
  • Errors pass the stack secondlogger.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/authcanRenameWorkspace, 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 <name> 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 ReferencesIn-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 $selected and not for every message, so Outlook falls back to outlook-conversation:<conversationId> — threading that still holds inside Outlook, just not across to Gmail.
  • MailboxSync.source is the discriminatorcalendar, 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 substringcalendar.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 invalidationrefetchInterval while PENDING/RUNNING, via isEnriching() and ENRICHMENT_POLL_MS. Lists poll too, not just the sheet.