@classytic/crm 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -124,6 +124,19 @@ interface ILeadStatusEntry {
124
124
  by?: string;
125
125
  note?: string;
126
126
  }
127
+ /**
128
+ * Behavioural engagement counters — fed by the host from CRM events
129
+ * (email opens/clicks, inbound replies, logged activities) via
130
+ * `LeadRepository.recordEngagement`. Read by the lead scorer alongside
131
+ * the firmographic fields. Zero-valued on old rows (nested default).
132
+ */
133
+ interface ILeadEngagement {
134
+ opens: number;
135
+ clicks: number;
136
+ replies: number;
137
+ activities: number;
138
+ lastEngagedAt?: Date;
139
+ }
127
140
  interface ILeadDocument {
128
141
  _id: _$mongoose.Types.ObjectId;
129
142
  organizationId: _$mongoose.Types.ObjectId | string;
@@ -137,6 +150,7 @@ interface ILeadDocument {
137
150
  source?: string;
138
151
  campaignRef?: string;
139
152
  score: number;
153
+ engagement: ILeadEngagement;
140
154
  status: LeadStatus;
141
155
  statusHistory: ILeadStatusEntry[];
142
156
  ownerId?: string;
@@ -751,24 +765,29 @@ declare class PipelineRepository extends Repository<IPipelineDocument> {
751
765
  }
752
766
  //#endregion
753
767
  //#region src/shared/calculators/scoring.d.ts
754
- /** Minimal Lead shape the scorer reads. */
755
- type LeadInput = Pick<ILeadDocument, 'email' | 'phone' | 'companyName' | 'tags' | 'jobTitle'>;
768
+ /** Minimal Lead shape the scorer reads — firmographic + engagement counters. */
769
+ type LeadInput = Pick<ILeadDocument, 'email' | 'phone' | 'companyName' | 'tags' | 'jobTitle' | 'engagement'>;
756
770
  type LeadScorer = (lead: LeadInput) => number;
757
771
  /**
758
772
  * Reference implementation. Intentionally simple — hosts read it and
759
- * replace it.
773
+ * replace it. Deterministic + pure (no I/O, no wall-clock).
760
774
  *
761
- * +20 if email is present
762
- * +20 if phone is present
763
- * +20 if companyName is present
764
- * +10 per populated tag up to 40
765
- * +10 if jobTitle hints senior (director / manager / head / chief / vp / founder)
775
+ * Firmographic fit (max 110):
776
+ * +20 email · +20 phone · +20 companyName
777
+ * +10 per populated tag, up to +40
778
+ * +10 if jobTitle hints senior (director/manager/head/chief/vp/founder)
779
+ *
780
+ * Behavioural engagement, capped at +80 so a run of opens can't dominate a
781
+ * genuine reply: reply ×25 · click ×8 · open ×2 · activity ×5. Counters are
782
+ * fed by the host via `LeadRepository.recordEngagement`; absent on old rows.
766
783
  */
767
784
  declare function defaultLeadScorer(lead: LeadInput): number;
768
785
  /** Compose multiple small rules into one scorer. */
769
786
  declare function composeScorers(...rules: readonly LeadScorer[]): LeadScorer;
770
787
  //#endregion
771
788
  //#region src/repositories/lead.repository.d.ts
789
+ /** Behavioural engagement signals fed via `recordEngagement`. */
790
+ type LeadEngagementSignal = 'open' | 'click' | 'reply' | 'activity';
772
791
  interface LeadRepositoryDeps {
773
792
  events: CrmEventDispatcher;
774
793
  /** Override the default lead scorer. */
@@ -813,6 +832,14 @@ declare class LeadRepository extends Repository<ILeadDocument> {
813
832
  * the previous owner so audit consumers can compute hand-off chains.
814
833
  */
815
834
  assign(id: string, ownerId: string, ctx?: CrmContext): Promise<ILeadDocument>;
835
+ /**
836
+ * Record a behavioural engagement signal, then re-score. The host feeds
837
+ * these off CRM events (`crm:message.received` → reply, `crm:activity.*` →
838
+ * activity, and email open/click tracking → open/click). Atomic `$inc`
839
+ * on the counter (single round-trip, no lost-update window under
840
+ * concurrent signals), then `recomputeScore` re-reads the fresh counters.
841
+ */
842
+ recordEngagement(id: string, signal: LeadEngagementSignal, ctx?: CrmContext): Promise<ILeadDocument>;
816
843
  /**
817
844
  * Recompute + persist the lead's score. Called from `createLead`
818
845
  * automatically; hosts also call this after enrichment writes.
@@ -2799,4 +2826,4 @@ declare namespace index_d_exports {
2799
2826
  export { AddNoteDto, CancelActivityDto, CloseLossDto, CloseWinDto, CompleteCampaignDto, ConfirmSubscriptionDto, ConvertLeadDto, CreateAccountDto, CreateCampaignDto, CreateContactDto, CreateLeadDto, CreateLossReasonDto, CreatePipelineDto, CreateSequenceInput, LeadFilterDto, MoveStageDto, OpenOpportunityDto, OpportunityFilterDto, PlanActivityDto, RecordEmailEventDto, SequenceFilterInput, StageInputDto, SubscribeDto, SuppressDto, UnsubscribeDto, UnsuppressDto, UpdateAccountDto, UpdateCampaignDto, UpdateContactDto, UpdateLeadDto, UpdateLossReasonDto, UpdateNoteDto, UpdatePipelineDto, UpdateSequenceInput, accountFilterSchema, activityFilterSchema, addNoteSchema, campaignFilterSchema, cancelActivitySchema, closeLossSchema, closeWinSchema, completeCampaignSchema, confirmSubscriptionSchema, contactFilterSchema, convertLeadSchema, createAccountSchema, createCampaignSchema, createContactSchema, createLeadSchema, createLossReasonSchema, createPipelineSchema, createSequenceSchema, currencyCodeSchema, emailEventFilterSchema, leadFilterSchema, lossReasonFilterSchema, metadataSchema, moneyAmountSchema, moveStageSchema, noteFilterSchema, objectIdLike, openOpportunitySchema, opportunityFilterSchema, pipelineFilterSchema, planActivitySchema, recordEmailEventSchema, sequenceFilterSchema, sequenceStepSchema, stageInputSchemaExport, subscribeSchema, subscriptionFilterSchema, suppressSchema, suppressionFilterSchema, tagsSchema, unsubscribeSchema, unsuppressSchema, updateAccountSchema, updateActivitySchema, updateCampaignSchema, updateContactSchema, updateLeadSchema, updateLossReasonSchema, updateNoteSchema, updatePipelineSchema, updateSequenceSchema };
2800
2827
  }
2801
2828
  //#endregion
2802
- export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountRepositoryDeps, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRepositoryDeps, ActivityRescheduled, type ActivityStatus, type ActivityType, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignRepositoryDeps, CampaignResumed, type CampaignStatus, CloseInput, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactRepositoryDeps, ContactUpdated, ConversationAssigned, type ConversationChannel, ConversationCreated, type ConversationParticipantKind, ConversationReopened, ConversationRepository, ConversationRepositoryDeps, ConversationResolved, type ConversationStatus, ConversionRow, ConvertLeadInput, ConvertResult, CreateCrmRepositoriesDeps, CreateModelsOptions, CrmConfig, CrmContext, CrmEngine, CrmError, CrmEventDefinition, CrmEventDispatcher, type CrmEventDispatcherOptions, CrmEventName, CrmEventSchema, CrmLogger, CrmModels, CrmRepositories, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, type EmailEventKind, EmailEventOpened, EmailEventRepository, EmailEventRepositoryDeps, EmailEventSubjectKind, EmailEventUnsubscribed, EmptyPipelineError, EnrollInput, EnrollmentCompleted, EnrollmentCreated, EnrollmentExitReason, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentRepositoryDeps, EnrollmentResumed, type EnrollmentStatus, EnrollmentStepSent, EnrollmentSubjectKind, ForecastOptions, ForecastRow, type IAccountDocument, type IActivityDocument, type ICampaignDocument, ICampaignStatusEntry, type IContactDocument, IContactInfoEmbedded, IContactName, type IConversationDocument, IDelayStep, type IEmailEventDocument, type IEnrollmentDocument, IEnrollmentStatusEntry, type ILeadDocument, ILeadStatusEntry, type ILossReasonDocument, IMessageAttachment, type IMessageDocument, type INoteDocument, type IOpportunityDocument, IOpportunityStatusEntry, type IPipelineDocument, ISendEmailStep, type ISequenceDocument, ISequenceStatusEntry, IStageDocument, IStepResult, type ISubscriptionDocument, ISubscriptionStatusEntry, type ISuppressionDocument, InProcessCrmBus, type InternalCrmContext, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadInput, LeadRepository, LeadRepositoryDeps, LeadScored, LeadScorer, LeadService, LeadServiceDeps, type LeadStatus, LeadStatusChanged, LossReasonRepository, LossReasonRepositoryDeps, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MergeAccountInput, MergeContactInput, type MessageDeliveryStatus, type MessageDirection, MessageReceived, MessageRepository, MessageRepositoryDeps, MessageSent, MoveStageInput, NotFoundError, NoteAdded, NoteRepository, NoteRepositoryDeps, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpenConversationInput, OpenOpportunityInput, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityForecast, OpportunityLost, OpportunityRepository, OpportunityRepositoryDeps, OpportunityStageAdvanced, OpportunityStageRegressed, type OpportunityStatus, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, PipelineRepositoryDeps, RecordEmailEventInput, RecordMessageInput, ReplySubject, ResolvedCrmConfig, type RunUnitConfig, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SchemaBuildConfig, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, SequenceRepositoryDeps, type SequenceStatus, SequenceStep, StageNotInPipelineError, type StatusChangeEntry, type StatusHistory, StepResultStatus, type SubjectKind, SubscribeInput, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionRepositoryDeps, type SubscriptionStatus, SubscriptionUnsubscribed, SuppressInput, SuppressionAdded, type SuppressionReason, SuppressionRemoved, SuppressionRepository, SuppressionRepositoryDeps, TenantFieldType, UnmanagedSessionError, _ModelTypes, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, runUnit, tenantFieldDef, timeInStatus, index_d_exports as validators };
2829
+ export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountRepositoryDeps, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRepositoryDeps, ActivityRescheduled, type ActivityStatus, type ActivityType, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignRepositoryDeps, CampaignResumed, type CampaignStatus, CloseInput, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactRepositoryDeps, ContactUpdated, ConversationAssigned, type ConversationChannel, ConversationCreated, type ConversationParticipantKind, ConversationReopened, ConversationRepository, ConversationRepositoryDeps, ConversationResolved, type ConversationStatus, ConversionRow, ConvertLeadInput, ConvertResult, CreateCrmRepositoriesDeps, CreateModelsOptions, CrmConfig, CrmContext, CrmEngine, CrmError, CrmEventDefinition, CrmEventDispatcher, type CrmEventDispatcherOptions, CrmEventName, CrmEventSchema, CrmLogger, CrmModels, CrmRepositories, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, type EmailEventKind, EmailEventOpened, EmailEventRepository, EmailEventRepositoryDeps, EmailEventSubjectKind, EmailEventUnsubscribed, EmptyPipelineError, EnrollInput, EnrollmentCompleted, EnrollmentCreated, EnrollmentExitReason, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentRepositoryDeps, EnrollmentResumed, type EnrollmentStatus, EnrollmentStepSent, EnrollmentSubjectKind, ForecastOptions, ForecastRow, type IAccountDocument, type IActivityDocument, type ICampaignDocument, ICampaignStatusEntry, type IContactDocument, IContactInfoEmbedded, IContactName, type IConversationDocument, IDelayStep, type IEmailEventDocument, type IEnrollmentDocument, IEnrollmentStatusEntry, type ILeadDocument, ILeadEngagement, ILeadStatusEntry, type ILossReasonDocument, IMessageAttachment, type IMessageDocument, type INoteDocument, type IOpportunityDocument, IOpportunityStatusEntry, type IPipelineDocument, ISendEmailStep, type ISequenceDocument, ISequenceStatusEntry, IStageDocument, IStepResult, type ISubscriptionDocument, ISubscriptionStatusEntry, type ISuppressionDocument, InProcessCrmBus, type InternalCrmContext, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEngagementSignal, LeadEnriched, LeadInput, LeadRepository, LeadRepositoryDeps, LeadScored, LeadScorer, LeadService, LeadServiceDeps, type LeadStatus, LeadStatusChanged, LossReasonRepository, LossReasonRepositoryDeps, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MergeAccountInput, MergeContactInput, type MessageDeliveryStatus, type MessageDirection, MessageReceived, MessageRepository, MessageRepositoryDeps, MessageSent, MoveStageInput, NotFoundError, NoteAdded, NoteRepository, NoteRepositoryDeps, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpenConversationInput, OpenOpportunityInput, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityForecast, OpportunityLost, OpportunityRepository, OpportunityRepositoryDeps, OpportunityStageAdvanced, OpportunityStageRegressed, type OpportunityStatus, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, PipelineRepositoryDeps, RecordEmailEventInput, RecordMessageInput, ReplySubject, ResolvedCrmConfig, type RunUnitConfig, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SchemaBuildConfig, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, SequenceRepositoryDeps, type SequenceStatus, SequenceStep, StageNotInPipelineError, type StatusChangeEntry, type StatusHistory, StepResultStatus, type SubjectKind, SubscribeInput, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionRepositoryDeps, type SubscriptionStatus, SubscriptionUnsubscribed, SuppressInput, SuppressionAdded, type SuppressionReason, SuppressionRemoved, SuppressionRepository, SuppressionRepositoryDeps, TenantFieldType, UnmanagedSessionError, _ModelTypes, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, runUnit, tenantFieldDef, timeInStatus, index_d_exports as validators };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./chunk.js";
2
2
  import { Schema } from "mongoose";
3
- import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
3
+ import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
4
4
  import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
5
- import { IllegalTransitionError, assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
5
+ import { IllegalTransitionError, defineStateMachine } from "@classytic/primitives/state-machine";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  import { civilDateOf } from "@classytic/primitives/timezone";
@@ -292,6 +292,25 @@ const statusEntrySchema$5 = new Schema({
292
292
  by: { type: String },
293
293
  note: { type: String }
294
294
  }, { _id: false });
295
+ const engagementSchema = new Schema({
296
+ opens: {
297
+ type: Number,
298
+ default: 0
299
+ },
300
+ clicks: {
301
+ type: Number,
302
+ default: 0
303
+ },
304
+ replies: {
305
+ type: Number,
306
+ default: 0
307
+ },
308
+ activities: {
309
+ type: Number,
310
+ default: 0
311
+ },
312
+ lastEngagedAt: { type: Date }
313
+ }, { _id: false });
295
314
  function buildLeadSchema(config) {
296
315
  const schema = new Schema({
297
316
  organizationId: tenantFieldDef(config),
@@ -346,6 +365,10 @@ function buildLeadSchema(config) {
346
365
  type: Number,
347
366
  default: 0
348
367
  },
368
+ engagement: {
369
+ type: engagementSchema,
370
+ default: () => ({})
371
+ },
349
372
  status: {
350
373
  type: String,
351
374
  enum: LEAD_STATUSES,
@@ -2240,13 +2263,16 @@ const ENROLLMENT_MACHINE = defineStateMachine({
2240
2263
  //#region src/shared/calculators/scoring.ts
2241
2264
  /**
2242
2265
  * Reference implementation. Intentionally simple — hosts read it and
2243
- * replace it.
2266
+ * replace it. Deterministic + pure (no I/O, no wall-clock).
2244
2267
  *
2245
- * +20 if email is present
2246
- * +20 if phone is present
2247
- * +20 if companyName is present
2248
- * +10 per populated tag up to 40
2249
- * +10 if jobTitle hints senior (director / manager / head / chief / vp / founder)
2268
+ * Firmographic fit (max 110):
2269
+ * +20 email · +20 phone · +20 companyName
2270
+ * +10 per populated tag, up to +40
2271
+ * +10 if jobTitle hints senior (director/manager/head/chief/vp/founder)
2272
+ *
2273
+ * Behavioural engagement, capped at +80 so a run of opens can't dominate a
2274
+ * genuine reply: reply ×25 · click ×8 · open ×2 · activity ×5. Counters are
2275
+ * fed by the host via `LeadRepository.recordEngagement`; absent on old rows.
2250
2276
  */
2251
2277
  function defaultLeadScorer(lead) {
2252
2278
  let score = 0;
@@ -2255,6 +2281,11 @@ function defaultLeadScorer(lead) {
2255
2281
  if (lead.companyName) score += 20;
2256
2282
  score += Math.min(40, (lead.tags?.length ?? 0) * 10);
2257
2283
  if (lead.jobTitle && /(director|manager|head|chief|vp|founder)/i.test(lead.jobTitle)) score += 10;
2284
+ const e = lead.engagement;
2285
+ if (e) {
2286
+ const engagementPoints = (e.replies ?? 0) * 25 + (e.clicks ?? 0) * 8 + (e.opens ?? 0) * 2 + (e.activities ?? 0) * 5;
2287
+ score += Math.min(80, engagementPoints);
2288
+ }
2258
2289
  return score;
2259
2290
  }
2260
2291
  /** Compose multiple small rules into one scorer. */
@@ -2262,7 +2293,27 @@ function composeScorers(...rules) {
2262
2293
  return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
2263
2294
  }
2264
2295
  //#endregion
2296
+ //#region src/repositories/transition-support.ts
2297
+ /**
2298
+ * mongokit `applyTransition()` race-outcome detection — pure races /
2299
+ * vanished rows remap to each verb's historical error (typed transition
2300
+ * error built from a fresh read, or NotFound where that was the
2301
+ * contract); now-illegal races throw the machine's errorFactory error
2302
+ * with the on-disk status natively.
2303
+ */
2304
+ function isTransitionRace(err) {
2305
+ const code = err?.code;
2306
+ return code === "TRANSITION_RACE_LOST" || code === "TRANSITION_TARGET_MISSING";
2307
+ }
2308
+ //#endregion
2265
2309
  //#region src/repositories/lead.repository.ts
2310
+ /** signal → engagement counter field. */
2311
+ const ENGAGEMENT_COUNTER = {
2312
+ open: "opens",
2313
+ click: "clicks",
2314
+ reply: "replies",
2315
+ activity: "activities"
2316
+ };
2266
2317
  var LeadRepository = class extends Repository {
2267
2318
  events;
2268
2319
  scorer;
@@ -2383,6 +2434,20 @@ var LeadRepository = class extends Repository {
2383
2434
  return await this.getById(id, ctx) ?? before;
2384
2435
  }
2385
2436
  /**
2437
+ * Record a behavioural engagement signal, then re-score. The host feeds
2438
+ * these off CRM events (`crm:message.received` → reply, `crm:activity.*` →
2439
+ * activity, and email open/click tracking → open/click). Atomic `$inc`
2440
+ * on the counter (single round-trip, no lost-update window under
2441
+ * concurrent signals), then `recomputeScore` re-reads the fresh counters.
2442
+ */
2443
+ async recordEngagement(id, signal, ctx) {
2444
+ if (!await this.findOneAndUpdate({ _id: id }, {
2445
+ $inc: { [`engagement.${ENGAGEMENT_COUNTER[signal]}`]: 1 },
2446
+ $set: { "engagement.lastEngagedAt": /* @__PURE__ */ new Date() }
2447
+ }, ctx)) throw new NotFoundError("Lead", id);
2448
+ return this.recomputeScore(id, ctx);
2449
+ }
2450
+ /**
2386
2451
  * Recompute + persist the lead's score. Called from `createLead`
2387
2452
  * automatically; hosts also call this after enrichment writes.
2388
2453
  */
@@ -2409,19 +2474,19 @@ var LeadRepository = class extends Repository {
2409
2474
  ...extras.by !== void 0 ? { by: extras.by } : {},
2410
2475
  ...extras.note !== void 0 ? { note: extras.note } : {}
2411
2476
  };
2412
- const updated = await assertAndClaim(LEAD_MACHINE, this, id, {
2477
+ const updated = await this.applyTransition(id, LEAD_MACHINE, {
2413
2478
  from: existing.status,
2414
2479
  to: target,
2415
- patch: {
2416
- ...extras.patch ?? {},
2417
- statusHistory: [...existing.statusHistory ?? [], entry]
2418
- },
2419
- options: ctx
2420
- });
2421
- if (!updated) throw new InvalidLeadTransitionError({
2422
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2423
- to: target,
2424
- leadId: id
2480
+ set: extras.patch ?? {},
2481
+ push: { statusHistory: entry },
2482
+ history: false
2483
+ }, ctx).catch(async (err) => {
2484
+ if (!isTransitionRace(err)) throw err;
2485
+ throw new InvalidLeadTransitionError({
2486
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
2487
+ to: target,
2488
+ leadId: id
2489
+ });
2425
2490
  });
2426
2491
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
2427
2492
  leadId: id,
@@ -2667,7 +2732,6 @@ var OpportunityRepository = class extends Repository {
2667
2732
  async _closeStatus(id, target, patch, extras, ctx) {
2668
2733
  const existing = await this.getById(id, ctx);
2669
2734
  if (!existing) throw new NotFoundError("Opportunity", id);
2670
- OPPORTUNITY_MACHINE.assertTransition(id, existing.status, target);
2671
2735
  const entry = {
2672
2736
  status: target,
2673
2737
  stageId: existing.stageId,
@@ -2675,21 +2739,20 @@ var OpportunityRepository = class extends Repository {
2675
2739
  ...extras.by !== void 0 ? { by: extras.by } : {},
2676
2740
  ...extras.note !== void 0 ? { note: extras.note } : {}
2677
2741
  };
2678
- const updated = await assertAndClaim(OPPORTUNITY_MACHINE, this, id, {
2742
+ return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
2679
2743
  from: existing.status,
2680
2744
  to: target,
2681
- patch: {
2682
- ...patch,
2683
- statusHistory: [...existing.statusHistory ?? [], entry]
2684
- },
2685
- options: ctx
2686
- });
2687
- if (!updated) throw new InvalidOpportunityTransitionError({
2688
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2689
- to: target,
2690
- opportunityId: id
2745
+ set: patch,
2746
+ push: { statusHistory: entry },
2747
+ history: false
2748
+ }, ctx).catch(async (err) => {
2749
+ if (!isTransitionRace(err)) throw err;
2750
+ throw new InvalidOpportunityTransitionError({
2751
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
2752
+ to: target,
2753
+ opportunityId: id
2754
+ });
2691
2755
  });
2692
- return updated;
2693
2756
  }
2694
2757
  /** Count opportunities with status `open`. Used by dashboard KPI tiles. */
2695
2758
  async countOpen(ctx) {
@@ -3023,28 +3086,26 @@ var CampaignRepository = class extends Repository {
3023
3086
  async _transition(id, target, extras, ctx, patch = {}) {
3024
3087
  const existing = await this.getById(id, ctx);
3025
3088
  if (!existing) throw new NotFoundError("Campaign", id);
3026
- CAMPAIGN_MACHINE.assertTransition(id, existing.status, target);
3027
3089
  const entry = {
3028
3090
  status: target,
3029
3091
  occurredAt: /* @__PURE__ */ new Date(),
3030
3092
  ...extras.by !== void 0 ? { by: extras.by } : {},
3031
3093
  ...extras.note !== void 0 ? { note: extras.note } : {}
3032
3094
  };
3033
- const updated = await assertAndClaim(CAMPAIGN_MACHINE, this, id, {
3095
+ return await this.applyTransition(id, CAMPAIGN_MACHINE, {
3034
3096
  from: existing.status,
3035
3097
  to: target,
3036
- patch: {
3037
- ...patch,
3038
- statusHistory: [...existing.statusHistory ?? [], entry]
3039
- },
3040
- options: ctx
3041
- });
3042
- if (!updated) throw new InvalidCampaignTransitionError({
3043
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3044
- to: target,
3045
- campaignId: id
3098
+ set: patch,
3099
+ push: { statusHistory: entry },
3100
+ history: false
3101
+ }, ctx).catch(async (err) => {
3102
+ if (!isTransitionRace(err)) throw err;
3103
+ throw new InvalidCampaignTransitionError({
3104
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3105
+ to: target,
3106
+ campaignId: id
3107
+ });
3046
3108
  });
3047
- return updated;
3048
3109
  }
3049
3110
  };
3050
3111
  //#endregion
@@ -3185,28 +3246,26 @@ var SubscriptionRepository = class extends Repository {
3185
3246
  async _transition(id, target, opts, ctx) {
3186
3247
  const existing = await this.getById(id, ctx);
3187
3248
  if (!existing) throw new NotFoundError("Subscription", id);
3188
- SUBSCRIPTION_MACHINE.assertTransition(id, existing.status, target);
3189
3249
  const entry = {
3190
3250
  status: target,
3191
3251
  occurredAt: /* @__PURE__ */ new Date(),
3192
3252
  ...opts.by !== void 0 ? { by: opts.by } : {},
3193
3253
  ...opts.note !== void 0 ? { note: opts.note } : {}
3194
3254
  };
3195
- const updated = await assertAndClaim(SUBSCRIPTION_MACHINE, this, id, {
3255
+ return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
3196
3256
  from: existing.status,
3197
3257
  to: target,
3198
- patch: {
3199
- ...opts.patch ?? {},
3200
- statusHistory: [...existing.statusHistory ?? [], entry]
3201
- },
3202
- options: ctx
3203
- });
3204
- if (!updated) throw new InvalidSubscriptionTransitionError({
3205
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3206
- to: target,
3207
- subscriptionId: id
3258
+ set: opts.patch ?? {},
3259
+ push: { statusHistory: entry },
3260
+ history: false
3261
+ }, ctx).catch(async (err) => {
3262
+ if (!isTransitionRace(err)) throw err;
3263
+ throw new InvalidSubscriptionTransitionError({
3264
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3265
+ to: target,
3266
+ subscriptionId: id
3267
+ });
3208
3268
  });
3209
- return updated;
3210
3269
  }
3211
3270
  };
3212
3271
  //#endregion
@@ -3375,19 +3434,19 @@ var ActivityRepository = class extends Repository {
3375
3434
  async _transition(id, target, patch, ctx) {
3376
3435
  const existing = await this.getById(id, ctx);
3377
3436
  if (!existing) throw new NotFoundError("Activity", id);
3378
- ACTIVITY_MACHINE.assertTransition(id, existing.status, target);
3379
- const updated = await assertAndClaim(ACTIVITY_MACHINE, this, id, {
3437
+ return await this.applyTransition(id, ACTIVITY_MACHINE, {
3380
3438
  from: existing.status,
3381
3439
  to: target,
3382
- patch,
3383
- options: ctx
3384
- });
3385
- if (!updated) throw new InvalidActivityTransitionError({
3386
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3387
- to: target,
3388
- activityId: id
3440
+ set: patch,
3441
+ history: false
3442
+ }, ctx).catch(async (err) => {
3443
+ if (!isTransitionRace(err)) throw err;
3444
+ throw new InvalidActivityTransitionError({
3445
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3446
+ to: target,
3447
+ activityId: id
3448
+ });
3389
3449
  });
3390
- return updated;
3391
3450
  }
3392
3451
  /** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
3393
3452
  async countOverduePlanned(asOf, ctx) {
@@ -3458,7 +3517,7 @@ const KIND_TO_EVENT = {
3458
3517
  var EmailEventRepository = class extends Repository {
3459
3518
  events;
3460
3519
  constructor(model, plugins, deps, pagination) {
3461
- super(model, plugins, pagination);
3520
+ super(model, [...plugins, appendOnlyPlugin({ allow: ["deleteMany"] })], pagination);
3462
3521
  this.events = deps.events;
3463
3522
  }
3464
3523
  async recordOpen(input, ctx) {
@@ -3683,24 +3742,24 @@ var SequenceRepository = class extends Repository {
3683
3742
  async _transition(id, target, ctx) {
3684
3743
  const existing = await this.getById(id, ctx);
3685
3744
  if (!existing) throw new NotFoundError("Sequence", id);
3686
- SEQUENCE_MACHINE.assertTransition(id, existing.status, target);
3687
3745
  const entry = {
3688
3746
  status: target,
3689
3747
  occurredAt: /* @__PURE__ */ new Date(),
3690
3748
  ...ctx?.actorId !== void 0 ? { by: String(ctx.actorId) } : {}
3691
3749
  };
3692
- const updated = await assertAndClaim(SEQUENCE_MACHINE, this, id, {
3750
+ return await this.applyTransition(id, SEQUENCE_MACHINE, {
3693
3751
  from: existing.status,
3694
3752
  to: target,
3695
- patch: { statusHistory: [...existing.statusHistory ?? [], entry] },
3696
- options: ctx
3697
- });
3698
- if (!updated) throw new InvalidSequenceTransitionError({
3699
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3700
- to: target,
3701
- sequenceId: id
3753
+ push: { statusHistory: entry },
3754
+ history: false
3755
+ }, ctx).catch(async (err) => {
3756
+ if (!isTransitionRace(err)) throw err;
3757
+ throw new InvalidSequenceTransitionError({
3758
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3759
+ to: target,
3760
+ sequenceId: id
3761
+ });
3702
3762
  });
3703
- return updated;
3704
3763
  }
3705
3764
  };
3706
3765
  //#endregion
@@ -3787,16 +3846,16 @@ var EnrollmentRepository = class extends Repository {
3787
3846
  occurredAt: /* @__PURE__ */ new Date(),
3788
3847
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3789
3848
  };
3790
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
3849
+ const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
3791
3850
  from,
3792
3851
  to: "exited",
3793
- patch: {
3794
- exitReason,
3795
- statusHistory: [...existing.statusHistory ?? [], entry]
3796
- },
3797
- options: ctx
3852
+ set: { exitReason },
3853
+ push: { statusHistory: entry },
3854
+ history: false
3855
+ }, ctx).catch((err) => {
3856
+ if (isTransitionRace(err)) throw new NotFoundError("Enrollment", id);
3857
+ throw err;
3798
3858
  });
3799
- if (!updated) throw new NotFoundError("Enrollment", id);
3800
3859
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_EXITED, {
3801
3860
  enrollmentId: id,
3802
3861
  exitReason
@@ -3855,27 +3914,25 @@ var EnrollmentRepository = class extends Repository {
3855
3914
  async _transition(id, target, ctx, patch = {}) {
3856
3915
  const existing = await this.getById(id, ctx);
3857
3916
  if (!existing) throw new NotFoundError("Enrollment", id);
3858
- ENROLLMENT_MACHINE.assertTransition(id, existing.status, target);
3859
3917
  const entry = {
3860
3918
  status: target,
3861
3919
  occurredAt: /* @__PURE__ */ new Date(),
3862
3920
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3863
3921
  };
3864
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
3922
+ return await this.applyTransition(id, ENROLLMENT_MACHINE, {
3865
3923
  from: existing.status,
3866
3924
  to: target,
3867
- patch: {
3868
- ...patch,
3869
- statusHistory: [...existing.statusHistory ?? [], entry]
3870
- },
3871
- options: ctx
3872
- });
3873
- if (!updated) throw new InvalidEnrollmentTransitionError({
3874
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3875
- to: target,
3876
- enrollmentId: id
3925
+ set: patch,
3926
+ push: { statusHistory: entry },
3927
+ history: false
3928
+ }, ctx).catch(async (err) => {
3929
+ if (!isTransitionRace(err)) throw err;
3930
+ throw new InvalidEnrollmentTransitionError({
3931
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3932
+ to: target,
3933
+ enrollmentId: id
3934
+ });
3877
3935
  });
3878
- return updated;
3879
3936
  }
3880
3937
  };
3881
3938
  //#endregion