Skip to content

Latest commit

 

History

History
282 lines (240 loc) · 16.2 KB

File metadata and controls

282 lines (240 loc) · 16.2 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 requireSession().
  • 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 and /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/*.
  • 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_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.

External API

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/me returns the token owner.
  • GET|POST|PATCH|DELETE /api/v1/companies and /api/v1/companies/:id manage companies.
  • GET|POST|PATCH|DELETE /api/v1/contacts and /api/v1/contacts/:id manage contacts.
  • GET|POST|PATCH|DELETE /api/v1/deals and /api/v1/deals/:id manage deals.
  • GET|POST /api/v1/activities and PATCH /api/v1/activities/:id manage timeline tasks and activities.
  • POST /api/v1/linkedin/captures upserts a manually confirmed LinkedIn relationship, creates a Company when needed, and queues agent triage.
  • POST /api/v1/surveys/responses accepts idempotent, incremental survey events; GET /api/v1/surveys, GET /api/v1/surveys/responses, and GET /api/v1/surveys/responses/:id expose survey progress and analysis.
  • crm:read permits reads; crm:write permits 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.

Two mail providers, one pipeline

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.store is the only writer of EmailThread, EmailMessage and the EMAIL activity. Gmail, Outlook and Zoho each parse their 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, zoho. authAccountId selects the linked OAuth account and externalId selects the provider mailbox or calendar. 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 and Zoho from timestamps. 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.
  • 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.

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.