@classytic/crm
v0.9.0
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/mongokit >=3.16.0, @classytic/repo-core >=0.6.0,
@classytic/primitives >=0.7.1, mongoose >=9.4.1, zod >=4.0.0.
Quick start
import mongoose from 'mongoose';
import { createCrm, LeadService } from '@classytic/crm';
const crm = await createCrm({
connection: mongoose.connection,
tenantFieldType: '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
