@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/CHANGELOG.md +363 -335
- package/dist/index.d.ts +36 -9
- package/dist/index.js +156 -99
- package/package.json +73 -73
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
|
-
*
|
|
762
|
-
* +20
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
2246
|
-
* +20
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2249
|
-
*
|
|
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
|
|
2477
|
+
const updated = await this.applyTransition(id, LEAD_MACHINE, {
|
|
2413
2478
|
from: existing.status,
|
|
2414
2479
|
to: target,
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
2742
|
+
return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
|
|
2679
2743
|
from: existing.status,
|
|
2680
2744
|
to: target,
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
3095
|
+
return await this.applyTransition(id, CAMPAIGN_MACHINE, {
|
|
3034
3096
|
from: existing.status,
|
|
3035
3097
|
to: target,
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
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
|
-
|
|
3255
|
+
return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
|
|
3196
3256
|
from: existing.status,
|
|
3197
3257
|
to: target,
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3384
|
-
})
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
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
|
-
|
|
3750
|
+
return await this.applyTransition(id, SEQUENCE_MACHINE, {
|
|
3693
3751
|
from: existing.status,
|
|
3694
3752
|
to: target,
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
})
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
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
|
|
3849
|
+
const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3791
3850
|
from,
|
|
3792
3851
|
to: "exited",
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
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
|
-
|
|
3922
|
+
return await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3865
3923
|
from: existing.status,
|
|
3866
3924
|
to: target,
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
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
|