@classytic/crm
v1.1.1
Published
Engine-factory CRM package — leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.
Readme
@classytic/crm
Engine-factory CRM package for the Classytic commerce stack — leads, accounts, contacts, opportunities, pipelines, campaigns, double-opt-in subscriptions, polymorphic activities + notes, email-engagement events, and conversation/message threads. Built on **Mongoose + @classytic/mongokit
- @classytic/repo-core + @classytic/primitives**, PACKAGE_RULES compliant.
Architecture (0.4.0+): this is an engine-factory mongokit package, NOT the pure-TS port-based design earlier docs described.
createCrm({ connection })returns Mongoose models,Repositorysubclasses (domain verbs live on the repos), and anEventTransport. The ports / adapters / in-memory-repo layer is gone.
- Repositories ARE the API. Each repo
extends Repository<TDoc>from mongokit and inherits CRUD, pagination, query parsing, aggregation, and transactions. Domain verbs (qualify,convert,win,subscribe,confirm,merge, …) live on the same class. - Atomic state transitions. Every lifecycle move goes through
assertAndClaim(state-machine legality check +repo.claim()CAS). Concurrent qualifiers / stage-moves / activations can't both win; race-losses surface as typed transition errors. - Index-backed dedup. Lead email, campaign name, and live
(contact, campaign)subscriptions are guarded by partial-unique indexes; the residualE11000is caught viaisDuplicateKeyErrorand re-thrown as the same typed domain error the pre-check emits — no check-then-save TOCTOU window. - Multi-tenant by default.
multiTenantPluginis wired on every repo withrequired: true; tenant scope is injected at POLICY priority and cannot be forgotten. A read with noorganizationIdthrows. - Event-driven extension plane. Every lifecycle change publishes a
crm:<resource>.<verb>event through an arc-compatibleEventTransport. Automation (outreach, AI follow-up, analytics) subscribes with globs; none of it lives in this package.
Install
npm install @classytic/crmPeers: @classytic/contact >=0.1.0, @classytic/mongokit >=3.30.0,
@classytic/repo-core >=0.13.0, @classytic/primitives >=0.14.0,
mongoose >=9.4.1, zod >=4.0.0.
@classytic/contact is the email/phone identity kernel. It is a peer, not a
dependency, so npm cannot nest a second copy and break instanceof across the
boundary — the same reason @classytic/party declares it that way. This package
imports @classytic/contact/email directly at one place, the suppression list.
The suppression list is keyed CANONICALLY
SuppressionRepository is the org-wide do-not-contact list a host consults before
every outbound email. Its key is whatever normalizeEmail from
@classytic/contact/email returns: trimmed, both sides lowercased, and the
domain IDNA-folded to punycode. ada@bücher.de is stored, matched and returned
as [email protected].
Two consequences for a host:
import { suppressionEmailKey } from '@classytic/crm';
// filterSuppressed() returns CANONICAL keys — canonicalise your own recipient
// before testing membership. Matching on the raw spelling is the original bug.
const blocked = await crm.repositories.suppression.filterSuppressed(recipients, ctx);
const sendable = recipients.filter((e) => !blocked.has(suppressionEmailKey(e) ?? e));- Writes refuse what they cannot canonicalise.
suppress()andunsuppress()throwSuppressionEmailFormatError(400). A malformed address stored verbatim is a do-not-contact record that can never match a send — it suppresses nothing while reporting success. - Reads fail closed.
isSuppressed()answerstrue, andfilterSuppressed()reports as blocked, for an address with no canonical form. The permissive answer would send mail to an address we could not parse; the conservative answer costs one undeliverable message. They do not throw, because one malformed recipient must not abort a fanout to thousands of valid ones.suppressionEmailKey()returnsnullfor such an address if you need to tell it apart from a real suppression.
Upgrading from a version before this rule: rows written under the old
trim().toLowerCase() key may no longer be reachable. Report — do not migrate —
with the read-only script:
node node_modules/@classytic/crm/scripts/suppression-key-drift.mjs "$MONGO_URI"It classifies every row as unchanged / rekey / collision / uncanonicalisable
and writes nothing. collision rows cannot be moved by an updateOne at all — the
(organizationId, email) index is unique and another row already holds the target
key, so which reason and suppressedAt survive is a human decision.
Quick start
import mongoose from 'mongoose';
import { createCrm, LeadService } from '@classytic/crm';
const crm = await createCrm({
connection: mongoose.connection,
tenant: { fieldType: 'objectId' }, // matches Better Auth orgs; 'string' for UUID hosts
});
const ctx = { organizationId: orgId };
// Pipeline setup.
const pipeline = await crm.repositories.pipeline.create(
{
name: 'New Business',
stages: [
{ name: 'Prospecting', sequence: 1, defaultProbability: 0.1 },
{ name: 'Discovery', sequence: 2, defaultProbability: 0.3 },
{ name: 'Proposal', sequence: 3, defaultProbability: 0.6 },
{ name: 'Negotiation', sequence: 4, defaultProbability: 0.8 },
],
},
ctx,
);
// Inbound lead — auto-scores + emits crm:lead.created.
const lead = await crm.repositories.lead.createLead(
{
email: '[email protected]',
firstName: 'Alex',
lastName: 'Rivera',
companyName: 'Acme',
jobTitle: 'Director',
source: 'web-form',
},
ctx,
);
const leadId = String(lead._id);
// Qualify, then convert — spawns Account + Contact + Opportunity.
await crm.repositories.lead.markContacted(leadId, {}, ctx);
await crm.repositories.lead.qualify(leadId, {}, ctx);
const leads = LeadService.fromEngine(crm);
const { opportunityId } = await leads.convert(
leadId,
{ pipelineId: String(pipeline._id), amount: 120_000, currency: 'USD' },
ctx,
);
// Move through the pipeline (atomic CAS on stageId).
const stages = pipeline.stages;
await crm.repositories.opportunity.moveToStage(opportunityId, { stageId: String(stages[1]._id) }, ctx);
await crm.repositories.opportunity.moveToStage(opportunityId, { stageId: String(stages[2]._id) }, ctx);
// Close as won — terminal, immutable.
await crm.repositories.opportunity.win(opportunityId, { amount: 125_000 }, ctx);
// React to events from anywhere.
crm.events.subscribe('crm:opportunity.*', (e) => console.log(e.type));Atomic conversion under a transaction
LeadService.convert() writes across four repositories (Account, Contact,
Opportunity, Lead). Wrap the call in connection.transaction() and thread
the session through ctx.session so all four writes commit or roll back
together (requires a replica set):
await mongoose.connection.transaction(async (session) => {
await leads.convert(leadId, { pipelineId, amount: 120_000 }, { ...ctx, session });
});State machines
Lead: new → contacted → qualified → converted
new/contacted/qualified → disqualified | nurturing
nurturing → contacted | qualified
Opportunity: open → won | lost | abandoned (terminal; stage moves while open)
Campaign: draft → active ⇄ paused → completed → archived
Subscription: pending → confirmed → unsubscribed | bounced | complained
Activity: planned → in_progress → completed
planned/in_progress → cancelledEvery transition is CAS-guarded and appends to the record's
statusHistory. Invalid transitions throw typed errors;
*_TRANSITIONS maps are the canonical source of truth.
Lead scoring, forecast, conversion analytics
import { composeScorers, forecast, pipelineConversion } from '@classytic/crm';
// Custom scorer (replaces defaultLeadScorer at repo construction):
const scorer = composeScorers(
(l) => (l.email ? 20 : 0),
(l) => (l.source === 'referral' ? 30 : 0),
(l) => (/ceo|cto|founder/i.test(l.jobTitle ?? '') ? 40 : 0),
);
// Pure analytics over opportunity lists (no I/O):
const open = await crm.repositories.opportunity.findAll({ status: 'open' }, ctx);
const rows = forecast(open); // ISO-month buckets: committed / weighted / count
const conv = pipelineConversion(open, pipeline.stages.map((s) => String(s._id)));Events catalog (rule 18)
Every CRM_EVENTS constant has a Zod definition in crmEventDefinitions,
exported from the package root and the ./events subpath:
import { crmEventDefinitions, CRM_EVENTS } from '@classytic/crm/events';
for (const def of crmEventDefinitions) registry.register(def); // arc EventRegistryHosts wire these into arc's EventRegistry for publish-time validation and
OpenAPI introspection. Subscribe glob-style with any arc transport (Memory,
Redis, Kafka, BullMQ):
crm.events.subscribe('crm:lead.*', (e) => routeLead(e));
crm.events.subscribe('crm:opportunity.won', (e) => createOrder(e));Event families: crm:lead.*, crm:account.*, crm:contact.*,
crm:opportunity.*, crm:pipeline.*, crm:campaign.*,
crm:subscription.*, crm:activity.*, crm:note.*, crm:email_event.*,
crm:conversation.*, crm:message.*.
Production boot
// Build indexes from a deploy script, not on cold boot (rule 35):
const crm = await createCrm({ connection, autoIndex: false });
// once per deploy:
await crm.syncIndexes();The engine asserts the backend's required capabilities at boot
(upsert, getOrCreate, duplicateKeyError, and transactions unless
allowNonTransactional: true) so a misconfigured deployment fails loud
instead of mid-convert.
What this package does NOT do
- No HTTP routes / auth / RBAC — the host (arc) owns those.
- No calendar / inbox integration — bridge at the host.
- No outreach sending — subscribe to
crm:activity.plannedand call your own provider, thenactivities.complete(id). - No custom-field validation —
metadataisRecord<string, unknown>. - Cross-entity re-pointing on contact merge is the host's job (subscribe to
crm:contact.merged).
Tests
npm run test:unit # pure: scoring, forecast, state machines, event-catalog no-drift
npm test # unit + integration (mongodb-memory-server replica set)Integration coverage includes the lead → opportunity → customer scenario,
opportunity pipeline, subscription DOI, conversation/message threads,
agentic verbs, multi-tenant isolation, and invariants.test.ts (tenant
probe + lead/subscription dedup races + atomic owner reassignment).
License
MIT
