@sonordev/contracts 2.16.0 → 2.18.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 +12 -0
- package/README.md +119 -0
- package/dist/crm-disposition.d.ts +87 -0
- package/dist/crm-disposition.js +79 -0
- package/dist/crm-disposition.mjs +69 -0
- package/dist/decision-roles.d.ts +39 -0
- package/dist/decision-roles.js +31 -0
- package/dist/decision-roles.mjs +28 -0
- package/package.json +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.18.0
|
|
4
|
+
|
|
5
|
+
A minor release: one new entry, `@sonordev/contracts/decision-roles`. Every other entry is unchanged.
|
|
6
|
+
|
|
7
|
+
- **What a job title says about a buying decision.** `DECISION_ROLES` is the four parts a person can play (`owner`, `decision_maker`, `influencer`, `gatekeeper`), strongest first, and `decisionRoleForTitle(title)` returns the one a title names, or `null` when it names none. The role comes from the title alone, by eight rules read in order with the first match deciding, so "Vice President of Sales" is a decision maker, "Executive Assistant to the CEO" is a gatekeeper and "Marketing Coordinator" is an influencer. "Principal" is an owner unless it names a job, so "Principal Engineer" has no role. A title is read whole (it isn't split on "&" or "/") and only its first 200 characters count. `DecisionRole` is the type. Anything that finds the people on a company's website can import it, so two scans of the same page can't give one person two roles.
|
|
8
|
+
|
|
9
|
+
## 2.17.0
|
|
10
|
+
|
|
11
|
+
A minor release: one new entry, `@sonordev/contracts/crm-disposition`. Every other entry is unchanged.
|
|
12
|
+
|
|
13
|
+
- **What a rep's call outcome changes.** `SALES_DISPOSITIONS` is the three outcomes a rep taps after a call (`positive`, `negative`, `voicemail`), `NEGATIVE_REASONS` the five reasons a negative one carries (`not_a_fit`, `no_budget`, `has_agency`, `wrong_number`, `do_not_call`) and `SALES_DISPOSITION_LABELS` / `NEGATIVE_REASON_LABELS` what each button says. `dispositionTransitions(disposition, { reason? })` returns what the outcome does to the call (`outcomeType`, `sentiment`), the contact (`contactStage`), the company (`companyStatus`), today's reminder (`reminder`) and outreach (`stopsOutreach`, `suppresses`). A caller makes the moves; the contract only names them, so the API, the dashboard and the sales extension read one map. `dispositionProblem(disposition, { reason? })` says in a sentence why an outcome can't be applied, such as a negative one with no reason. `isSalesDisposition`, `isNegativeReason` and `CRM_DISPOSITION_CONTRACT_VERSION` (1) complete it.
|
|
14
|
+
|
|
3
15
|
## 2.16.0
|
|
4
16
|
|
|
5
17
|
A minor release: `@sonordev/contracts/portfolio` gains the verification, a proof-stage section for builds a Lighthouse run can't honestly score. Every other entry is unchanged. Nothing was removed or renamed, but three things behave differently. `PortfolioSectionType` has a new member, so a `Record<PortfolioSectionType, ...>` you keep needs an entry for `portfolioVerification` before it type-checks. `caseStudyStageStatus` reads each section's `data`. A reported number's date has to be a real day.
|
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ pnpm add @sonordev/contracts
|
|
|
40
40
|
| `@sonordev/contracts/public-url-host` | Which literal hosts are refused as public fetch destinations (`isBlockedPublicUrlHost`) | the Sonor API and Signal's URL validators and network fetchers |
|
|
41
41
|
| `@sonordev/contracts/funnels` | Incomplete drafts, launch definitions, command/readiness/receipt schemas, bounded public pages and presentation-only brand overrides | Sites, APIs, UI and agent adapters share the same rules |
|
|
42
42
|
| `@sonordev/contracts/inbox` | Which inbox cards are decisions a bulk action leaves alone (`isDecisionCard`, `DECISION_INSIGHT_TYPES`) | Signal's inbox decides what "Mark all read" and "Dismiss all" may touch, the dashboard previews the same result before the write |
|
|
43
|
+
| `@sonordev/contracts/crm-disposition` | What a rep's call outcome (positive, negative or left voicemail) changes on a prospect: the call's outcome and sentiment, the contact's stage, the company's status, today's reminder and outreach (`dispositionTransitions`), and the reasons a negative outcome carries | the Sonor API applies an outcome, the dashboard's calling screen offers the same three buttons and reasons, the sales extension stops flipping a company on dial |
|
|
44
|
+
| `@sonordev/contracts/decision-roles` | The part a job title says a person plays in a buying decision (`DECISION_ROLES`: owner, decision maker, influencer, gatekeeper), read from the title alone by `decisionRoleForTitle` | the Sonor API's site-people scan and Signal's site-people scout read the same map |
|
|
43
45
|
|
|
44
46
|
## Focus closing artwork
|
|
45
47
|
|
|
@@ -90,6 +92,123 @@ list (a database filter that leaves them out of a bulk write, for one).
|
|
|
90
92
|
key, a JSON `null` and a `null` `data` mean no proposal, and any other value,
|
|
91
93
|
an empty string included, means one.
|
|
92
94
|
|
|
95
|
+
## Call dispositions
|
|
96
|
+
|
|
97
|
+
After a sales call a rep taps one of three outcomes. What each one changes on
|
|
98
|
+
the prospect is written here once, so the API that applies it, the calling
|
|
99
|
+
screen that offers it and the browser extension that dials from a list can't
|
|
100
|
+
disagree about what "negative" moves.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { dispositionProblem, dispositionTransitions } from '@sonordev/contracts/crm-disposition'
|
|
104
|
+
|
|
105
|
+
dispositionProblem('negative', { reason: undefined })
|
|
106
|
+
// 'A negative outcome needs a reason: not_a_fit, no_budget, has_agency, wrong_number, do_not_call.'
|
|
107
|
+
|
|
108
|
+
dispositionTransitions('negative', { reason: 'do_not_call' })
|
|
109
|
+
// {
|
|
110
|
+
// outcomeType: 'connected', sentiment: 'negative',
|
|
111
|
+
// contactStage: 'lost', companyStatus: 'not_a_fit',
|
|
112
|
+
// reminder: 'complete', stopsOutreach: false, suppresses: true,
|
|
113
|
+
// }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Outcome | Call | Contact stage | Company status | Today's reminder | Outreach |
|
|
117
|
+
|---|---|---|---|---|---|
|
|
118
|
+
| `positive` | `connected`, positive | `contacted` | `contacted` | `complete` | the drip stops (`stopsOutreach`) |
|
|
119
|
+
| `negative` | `connected`, negative | `lost` | `not_a_fit` | `complete` | nothing, unless the reason is `do_not_call` (`suppresses`) |
|
|
120
|
+
| `voicemail` | `left_voicemail`, no sentiment | unchanged | unchanged | `snooze_next_business_day` | nothing |
|
|
121
|
+
|
|
122
|
+
- **The contract names the moves; the caller makes them.** `contactStage` is a
|
|
123
|
+
meaning (`contacted` or `lost`), not a project's stage key. `reminder:
|
|
124
|
+
'snooze_next_business_day'` leaves the instant to the caller, who knows the
|
|
125
|
+
project's time zone. A `null` in any field means leave that thing as it is.
|
|
126
|
+
A stage a rep picks by hand afterwards, such as `qualified`, is a separate
|
|
127
|
+
move that no outcome makes.
|
|
128
|
+
- **A voicemail isn't a conversation.** It leaves the contact and company
|
|
129
|
+
alone, and a company only moves to `contacted` when someone answered. Dialing
|
|
130
|
+
a number moves nothing either; the outcome does.
|
|
131
|
+
- **A negative outcome needs a reason.** `NEGATIVE_REASONS` is the five, in the
|
|
132
|
+
order a screen offers them: `not_a_fit`, `no_budget`, `has_agency`,
|
|
133
|
+
`wrong_number` and `do_not_call`. `NEGATIVE_REASON_LABELS` and
|
|
134
|
+
`SALES_DISPOSITION_LABELS` say what each button reads.
|
|
135
|
+
- **`suppresses` is for `do_not_call` only.** It means write a suppression for
|
|
136
|
+
the prospect, which blocks every send on its own. It's separate from
|
|
137
|
+
`stopsOutreach`, which stops a drip the prospect is enrolled in, so a caller
|
|
138
|
+
that wants both for an "asked not to call" outcome stops the drip as well.
|
|
139
|
+
- **Check an untrusted outcome first.** `dispositionProblem(disposition,
|
|
140
|
+
{ reason })` returns one sentence about why an outcome can't be applied, or
|
|
141
|
+
`null`: a word that isn't one of the three, a negative outcome with no reason
|
|
142
|
+
or one that isn't a listed reason, or a reason on an outcome that takes none.
|
|
143
|
+
`isSalesDisposition` and `isNegativeReason` are the plain guards.
|
|
144
|
+
`dispositionTransitions` throws a `RangeError` for something that isn't a
|
|
145
|
+
disposition, and ignores a reason on any outcome but a negative one.
|
|
146
|
+
- **Fresh each time.** `dispositionTransitions` returns a new object per call,
|
|
147
|
+
so changing one caller's copy never changes the next.
|
|
148
|
+
|
|
149
|
+
`CRM_DISPOSITION_CONTRACT_VERSION` is bumped when an outcome's transitions
|
|
150
|
+
change incompatibly. The wire values (the three outcomes and five reasons) are
|
|
151
|
+
stored and sent, so they don't change.
|
|
152
|
+
|
|
153
|
+
## Decision roles
|
|
154
|
+
|
|
155
|
+
When a company's website lists its people, what each job title says about a
|
|
156
|
+
buying decision has to be read the same way wherever the list is built.
|
|
157
|
+
`decisionRoleForTitle` is that rule, written once, so a person can't be an
|
|
158
|
+
influencer in one scan and an owner in another. The role comes from the title
|
|
159
|
+
the page gives and never from a model's opinion: a model can find the people,
|
|
160
|
+
and code decides the role.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { DECISION_ROLES, decisionRoleForTitle } from '@sonordev/contracts/decision-roles'
|
|
164
|
+
|
|
165
|
+
DECISION_ROLES // ['owner', 'decision_maker', 'influencer', 'gatekeeper']
|
|
166
|
+
decisionRoleForTitle('Vice President of Sales') // 'decision_maker'
|
|
167
|
+
decisionRoleForTitle('Executive Assistant to the CEO') // 'gatekeeper'
|
|
168
|
+
decisionRoleForTitle('Marketing Coordinator') // 'influencer'
|
|
169
|
+
decisionRoleForTitle('Principal Engineer') // null
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The first rule that matches the title decides, in this order:
|
|
173
|
+
|
|
174
|
+
| Order | The title says | Role |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| 1 | an executive, administrative, office or personal assistant, "assistant to", secretary, receptionist, front desk | `gatekeeper` |
|
|
177
|
+
| 2 | vice president (VP, SVP, EVP, AVP) | `decision_maker` |
|
|
178
|
+
| 3 | owner, co-owner, founder, co-founder, proprietor, president, CEO, chief executive, managing, senior, name or equity partner, a lone "Partner", or "Principal" that isn't a job | `owner` |
|
|
179
|
+
| 4 | a chief of something else (COO, CFO, CTO, CMO, "Chief ... Officer") | `decision_maker` |
|
|
180
|
+
| 5 | general manager (or GM), director, operations manager, estimator, estimating manager, project manager, superintendent | `decision_maker` |
|
|
181
|
+
| 6 | office manager, administrator, administrative, admin | `gatekeeper` |
|
|
182
|
+
| 7 | marketing, HR, human resources, recruiting, talent acquisition, sales, business development | `influencer` |
|
|
183
|
+
| 8 | coordinator | `gatekeeper` |
|
|
184
|
+
|
|
185
|
+
- **Order is the meaning.** "Vice President of Sales" is a decision maker, not
|
|
186
|
+
an owner because it says president and not an influencer because it says
|
|
187
|
+
sales. "Marketing Director" is a decision maker, because director comes
|
|
188
|
+
before marketing. "Marketing Coordinator" is an influencer, because
|
|
189
|
+
marketing comes before coordinator. "Executive Assistant to the CEO" is a
|
|
190
|
+
gatekeeper, because the assistant rule comes before the CEO rule.
|
|
191
|
+
- **"Principal" is an owner unless it names a job.** "Principal" alone is an
|
|
192
|
+
owner. "Principal Engineer", "Principal Architect", "Principal Consultant",
|
|
193
|
+
"Principal Designer", "Principal Developer" and "Principal Analyst" say
|
|
194
|
+
nothing about ownership, so they have no role.
|
|
195
|
+
- **A title is read whole.** A title that names two jobs isn't split, and the
|
|
196
|
+
strongest role doesn't win; the first rule above that matches does. "Owner &
|
|
197
|
+
Office Manager" is an owner. "Receptionist & Owner" is a gatekeeper.
|
|
198
|
+
- **"Assistant" alone isn't a gatekeeper.** "Assistant Project Manager" and
|
|
199
|
+
"Assistant General Manager" are decision makers, as the manager rules read
|
|
200
|
+
them. "Assistant to the Project Manager" is a gatekeeper.
|
|
201
|
+
- **No title or no match is `null`.** "Technician", a bare "Manager",
|
|
202
|
+
"Accounting Manager", an empty string, whitespace, `null`, `undefined` and
|
|
203
|
+
anything that isn't a string all say no role. The map doesn't guess.
|
|
204
|
+
- **Case, `&` and periods don't matter.** "C.E.O.", "FOUNDER & CEO" and
|
|
205
|
+
"Vice-President" read the same as their plain spellings.
|
|
206
|
+
- **Only the start of a title is read.** Titles come off scraped pages, so
|
|
207
|
+
the first 200 characters decide and the rest is ignored.
|
|
208
|
+
|
|
209
|
+
The four role values are stored and sent, so they don't change. `DECISION_ROLES`
|
|
210
|
+
lists them strongest first, which is the order a screen should rank them in.
|
|
211
|
+
|
|
93
212
|
## Schema placeholders
|
|
94
213
|
|
|
95
214
|
`schema-placeholders` finds the parts of a JSON-LD value that a template left
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a rep's three call outcomes do to a prospect. Single source of truth for
|
|
3
|
+
* the Sales Mode disposition: the Sonor API applies it when a rep taps an
|
|
4
|
+
* outcome, the dashboard shows the same three buttons and reasons, and the
|
|
5
|
+
* sales extension stops guessing at 'contacted' when a number is dialed. One
|
|
6
|
+
* map, so the three can't disagree about what "negative" moves.
|
|
7
|
+
*
|
|
8
|
+
* positive a connected call that went well. The contact is `contacted`,
|
|
9
|
+
* the company is `contacted`, today's reminder is done and the
|
|
10
|
+
* prospect's drip stops, since they're now in a conversation.
|
|
11
|
+
* negative a connected call that ended the lead. The contact is `lost`
|
|
12
|
+
* (a reason is required), the company is `not_a_fit`, today's
|
|
13
|
+
* reminder is done. Only "asked not to call" suppresses outreach.
|
|
14
|
+
* voicemail nobody picked up. Nothing about the contact or company changes,
|
|
15
|
+
* because a voicemail isn't a conversation, and today's reminder
|
|
16
|
+
* moves to the next business day.
|
|
17
|
+
*
|
|
18
|
+
* The contract names the moves; the caller makes them. Which stage key
|
|
19
|
+
* `contacted` or `lost` is on a given project, which instant "the next business
|
|
20
|
+
* day" is in the project's time zone, and how a suppression row is written all
|
|
21
|
+
* stay with the Sonor API. A stage the rep picks by hand afterwards (qualified,
|
|
22
|
+
* say) is a separate move that no disposition makes.
|
|
23
|
+
*
|
|
24
|
+
* Pure data and a few small functions, no dependencies. The wire values (the
|
|
25
|
+
* three dispositions, the five reasons) are stored and sent, so they never
|
|
26
|
+
* change; a different map is a new CRM_DISPOSITION_CONTRACT_VERSION.
|
|
27
|
+
*/
|
|
28
|
+
/** Bumped when a disposition's transitions change incompatibly. */
|
|
29
|
+
export declare const CRM_DISPOSITION_CONTRACT_VERSION = 1;
|
|
30
|
+
/** The three words a rep taps after a call, in button order. */
|
|
31
|
+
export declare const SALES_DISPOSITIONS: readonly ['positive', 'negative', 'voicemail'];
|
|
32
|
+
export type SalesDisposition = (typeof SALES_DISPOSITIONS)[number];
|
|
33
|
+
/** What each button says. */
|
|
34
|
+
export declare const SALES_DISPOSITION_LABELS: Record<SalesDisposition, string>;
|
|
35
|
+
/** The one-tap reasons a negative outcome must carry, in the order they're offered. */
|
|
36
|
+
export declare const NEGATIVE_REASONS: readonly ['not_a_fit', 'no_budget', 'has_agency', 'wrong_number', 'do_not_call'];
|
|
37
|
+
export type NegativeReason = (typeof NEGATIVE_REASONS)[number];
|
|
38
|
+
/** What each reason says on its button. `do_not_call` is the one that suppresses outreach. */
|
|
39
|
+
export declare const NEGATIVE_REASON_LABELS: Record<NegativeReason, string>;
|
|
40
|
+
/** Whether a value is one of the three dispositions, spelled exactly. */
|
|
41
|
+
export declare function isSalesDisposition(value: unknown): value is SalesDisposition;
|
|
42
|
+
/** Whether a value is one of the five negative reasons, spelled exactly. */
|
|
43
|
+
export declare function isNegativeReason(value: unknown): value is NegativeReason;
|
|
44
|
+
/** What a disposition changes. A null leaves that thing as it is. */
|
|
45
|
+
export interface DispositionTransitions {
|
|
46
|
+
/** The call row's `outcome_type`. */
|
|
47
|
+
outcomeType: 'connected' | 'left_voicemail';
|
|
48
|
+
/** The call row's `sentiment`. */
|
|
49
|
+
sentiment: 'positive' | 'negative' | null;
|
|
50
|
+
/** The contact's stage, by meaning rather than by a project's stage key. */
|
|
51
|
+
contactStage: 'contacted' | 'lost' | null;
|
|
52
|
+
/** The target company's `status`. */
|
|
53
|
+
companyStatus: 'contacted' | 'not_a_fit' | null;
|
|
54
|
+
/**
|
|
55
|
+
* What happens to the prospect's reminder due today: finish it, or push it to
|
|
56
|
+
* 09:00 on the project's next business day. No disposition leaves it alone
|
|
57
|
+
* today; a caller still handles null so that never has to be a breaking change.
|
|
58
|
+
*/
|
|
59
|
+
reminder: 'complete' | 'snooze_next_business_day' | null;
|
|
60
|
+
/** Stop the prospect's enrolled drip or sequence. */
|
|
61
|
+
stopsOutreach: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Write an outreach suppression for the prospect. That blocks every send on
|
|
64
|
+
* its own, so it isn't also a `stopsOutreach`. Only a negative outcome whose
|
|
65
|
+
* reason is `do_not_call` suppresses.
|
|
66
|
+
*/
|
|
67
|
+
suppresses: boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface DispositionOptions {
|
|
70
|
+
/** The negative outcome's reason. Read for a negative outcome only. */
|
|
71
|
+
reason?: NegativeReason | null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* What a disposition changes. Returns a fresh object each call, so a caller can
|
|
75
|
+
* adjust its copy without touching the next caller's. Throws a RangeError for a
|
|
76
|
+
* value that isn't a disposition: check an untrusted one with
|
|
77
|
+
* `dispositionProblem` first.
|
|
78
|
+
*/
|
|
79
|
+
export declare function dispositionTransitions(disposition: SalesDisposition, opts?: DispositionOptions): DispositionTransitions;
|
|
80
|
+
/**
|
|
81
|
+
* Why a disposition and its reason can't be applied, in one sentence, or null
|
|
82
|
+
* when they can. A negative outcome needs a reason and no other outcome takes
|
|
83
|
+
* one. A null or undefined reason means none was given.
|
|
84
|
+
*/
|
|
85
|
+
export declare function dispositionProblem(disposition: unknown, opts?: {
|
|
86
|
+
reason?: unknown;
|
|
87
|
+
}): string | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/crm-disposition.ts
|
|
4
|
+
var CRM_DISPOSITION_CONTRACT_VERSION = 1;
|
|
5
|
+
var SALES_DISPOSITIONS = ["positive", "negative", "voicemail"];
|
|
6
|
+
var SALES_DISPOSITION_LABELS = {
|
|
7
|
+
positive: "Positive",
|
|
8
|
+
negative: "Negative",
|
|
9
|
+
voicemail: "Left voicemail"
|
|
10
|
+
};
|
|
11
|
+
var NEGATIVE_REASONS = ["not_a_fit", "no_budget", "has_agency", "wrong_number", "do_not_call"];
|
|
12
|
+
var NEGATIVE_REASON_LABELS = {
|
|
13
|
+
not_a_fit: "Not a fit",
|
|
14
|
+
no_budget: "No budget",
|
|
15
|
+
has_agency: "Has an agency",
|
|
16
|
+
wrong_number: "Wrong number",
|
|
17
|
+
do_not_call: "Asked not to call"
|
|
18
|
+
};
|
|
19
|
+
function isSalesDisposition(value) {
|
|
20
|
+
return typeof value === "string" && SALES_DISPOSITIONS.includes(value);
|
|
21
|
+
}
|
|
22
|
+
function isNegativeReason(value) {
|
|
23
|
+
return typeof value === "string" && NEGATIVE_REASONS.includes(value);
|
|
24
|
+
}
|
|
25
|
+
var TRANSITIONS = {
|
|
26
|
+
positive: {
|
|
27
|
+
outcomeType: "connected",
|
|
28
|
+
sentiment: "positive",
|
|
29
|
+
contactStage: "contacted",
|
|
30
|
+
companyStatus: "contacted",
|
|
31
|
+
reminder: "complete",
|
|
32
|
+
stopsOutreach: true
|
|
33
|
+
},
|
|
34
|
+
negative: {
|
|
35
|
+
outcomeType: "connected",
|
|
36
|
+
sentiment: "negative",
|
|
37
|
+
contactStage: "lost",
|
|
38
|
+
companyStatus: "not_a_fit",
|
|
39
|
+
reminder: "complete",
|
|
40
|
+
stopsOutreach: false
|
|
41
|
+
},
|
|
42
|
+
voicemail: {
|
|
43
|
+
outcomeType: "left_voicemail",
|
|
44
|
+
sentiment: null,
|
|
45
|
+
contactStage: null,
|
|
46
|
+
companyStatus: null,
|
|
47
|
+
reminder: "snooze_next_business_day",
|
|
48
|
+
stopsOutreach: false
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function dispositionTransitions(disposition, opts = {}) {
|
|
52
|
+
if (!isSalesDisposition(disposition)) {
|
|
53
|
+
throw new RangeError(`Not a sales disposition: ${String(disposition)}`);
|
|
54
|
+
}
|
|
55
|
+
return { ...TRANSITIONS[disposition], suppresses: disposition === "negative" && opts.reason === "do_not_call" };
|
|
56
|
+
}
|
|
57
|
+
function dispositionProblem(disposition, opts = {}) {
|
|
58
|
+
if (!isSalesDisposition(disposition)) {
|
|
59
|
+
return `The outcome has to be one of ${SALES_DISPOSITIONS.join(", ")}.`;
|
|
60
|
+
}
|
|
61
|
+
const reason = opts.reason;
|
|
62
|
+
const given = reason !== null && reason !== void 0;
|
|
63
|
+
if (disposition === "negative") {
|
|
64
|
+
if (!given) return `A negative outcome needs a reason: ${NEGATIVE_REASONS.join(", ")}.`;
|
|
65
|
+
if (!isNegativeReason(reason)) return `The reason has to be one of ${NEGATIVE_REASONS.join(", ")}.`;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return given ? `Only a negative outcome takes a reason, and this one is ${disposition}.` : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
exports.CRM_DISPOSITION_CONTRACT_VERSION = CRM_DISPOSITION_CONTRACT_VERSION;
|
|
72
|
+
exports.NEGATIVE_REASONS = NEGATIVE_REASONS;
|
|
73
|
+
exports.NEGATIVE_REASON_LABELS = NEGATIVE_REASON_LABELS;
|
|
74
|
+
exports.SALES_DISPOSITIONS = SALES_DISPOSITIONS;
|
|
75
|
+
exports.SALES_DISPOSITION_LABELS = SALES_DISPOSITION_LABELS;
|
|
76
|
+
exports.dispositionProblem = dispositionProblem;
|
|
77
|
+
exports.dispositionTransitions = dispositionTransitions;
|
|
78
|
+
exports.isNegativeReason = isNegativeReason;
|
|
79
|
+
exports.isSalesDisposition = isSalesDisposition;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/contracts/crm-disposition.ts
|
|
2
|
+
var CRM_DISPOSITION_CONTRACT_VERSION = 1;
|
|
3
|
+
var SALES_DISPOSITIONS = ["positive", "negative", "voicemail"];
|
|
4
|
+
var SALES_DISPOSITION_LABELS = {
|
|
5
|
+
positive: "Positive",
|
|
6
|
+
negative: "Negative",
|
|
7
|
+
voicemail: "Left voicemail"
|
|
8
|
+
};
|
|
9
|
+
var NEGATIVE_REASONS = ["not_a_fit", "no_budget", "has_agency", "wrong_number", "do_not_call"];
|
|
10
|
+
var NEGATIVE_REASON_LABELS = {
|
|
11
|
+
not_a_fit: "Not a fit",
|
|
12
|
+
no_budget: "No budget",
|
|
13
|
+
has_agency: "Has an agency",
|
|
14
|
+
wrong_number: "Wrong number",
|
|
15
|
+
do_not_call: "Asked not to call"
|
|
16
|
+
};
|
|
17
|
+
function isSalesDisposition(value) {
|
|
18
|
+
return typeof value === "string" && SALES_DISPOSITIONS.includes(value);
|
|
19
|
+
}
|
|
20
|
+
function isNegativeReason(value) {
|
|
21
|
+
return typeof value === "string" && NEGATIVE_REASONS.includes(value);
|
|
22
|
+
}
|
|
23
|
+
var TRANSITIONS = {
|
|
24
|
+
positive: {
|
|
25
|
+
outcomeType: "connected",
|
|
26
|
+
sentiment: "positive",
|
|
27
|
+
contactStage: "contacted",
|
|
28
|
+
companyStatus: "contacted",
|
|
29
|
+
reminder: "complete",
|
|
30
|
+
stopsOutreach: true
|
|
31
|
+
},
|
|
32
|
+
negative: {
|
|
33
|
+
outcomeType: "connected",
|
|
34
|
+
sentiment: "negative",
|
|
35
|
+
contactStage: "lost",
|
|
36
|
+
companyStatus: "not_a_fit",
|
|
37
|
+
reminder: "complete",
|
|
38
|
+
stopsOutreach: false
|
|
39
|
+
},
|
|
40
|
+
voicemail: {
|
|
41
|
+
outcomeType: "left_voicemail",
|
|
42
|
+
sentiment: null,
|
|
43
|
+
contactStage: null,
|
|
44
|
+
companyStatus: null,
|
|
45
|
+
reminder: "snooze_next_business_day",
|
|
46
|
+
stopsOutreach: false
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
function dispositionTransitions(disposition, opts = {}) {
|
|
50
|
+
if (!isSalesDisposition(disposition)) {
|
|
51
|
+
throw new RangeError(`Not a sales disposition: ${String(disposition)}`);
|
|
52
|
+
}
|
|
53
|
+
return { ...TRANSITIONS[disposition], suppresses: disposition === "negative" && opts.reason === "do_not_call" };
|
|
54
|
+
}
|
|
55
|
+
function dispositionProblem(disposition, opts = {}) {
|
|
56
|
+
if (!isSalesDisposition(disposition)) {
|
|
57
|
+
return `The outcome has to be one of ${SALES_DISPOSITIONS.join(", ")}.`;
|
|
58
|
+
}
|
|
59
|
+
const reason = opts.reason;
|
|
60
|
+
const given = reason !== null && reason !== void 0;
|
|
61
|
+
if (disposition === "negative") {
|
|
62
|
+
if (!given) return `A negative outcome needs a reason: ${NEGATIVE_REASONS.join(", ")}.`;
|
|
63
|
+
if (!isNegativeReason(reason)) return `The reason has to be one of ${NEGATIVE_REASONS.join(", ")}.`;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return given ? `Only a negative outcome takes a reason, and this one is ${disposition}.` : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { CRM_DISPOSITION_CONTRACT_VERSION, NEGATIVE_REASONS, NEGATIVE_REASON_LABELS, SALES_DISPOSITIONS, SALES_DISPOSITION_LABELS, dispositionProblem, dispositionTransitions, isNegativeReason, isSalesDisposition };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a job title says about a person's part in a buying decision. Single
|
|
3
|
+
* source of truth for the site-people scan: the Sonor API's deterministic scan
|
|
4
|
+
* (JSON-LD persons) and Signal's site-people scout both read this map, so the
|
|
5
|
+
* same person can't be an influencer on one side and an owner on the other.
|
|
6
|
+
*
|
|
7
|
+
* The role is never a model's opinion. A model finds the people; the title the
|
|
8
|
+
* page gives them decides the role, by the rules below, in this order (first
|
|
9
|
+
* match wins):
|
|
10
|
+
*
|
|
11
|
+
* gatekeeper someone who works for a person, whatever that person's
|
|
12
|
+
* title is: "Executive Assistant to the President",
|
|
13
|
+
* "Receptionist"
|
|
14
|
+
* decision_maker a vice president, matched before the president rule can
|
|
15
|
+
* claim the word
|
|
16
|
+
* owner owner, founder, president, CEO, a managing, senior, name
|
|
17
|
+
* or equity partner, a lone "Partner", and "Principal"
|
|
18
|
+
* unless it names a job ("Principal Engineer")
|
|
19
|
+
* decision_maker a chief of something that isn't the CEO, a general
|
|
20
|
+
* manager (or GM), a director, an operations or project
|
|
21
|
+
* manager, an estimator, a superintendent
|
|
22
|
+
* gatekeeper an office manager, an administrator
|
|
23
|
+
* influencer marketing, HR, recruiting, sales, business development
|
|
24
|
+
* gatekeeper a coordinator, last of all
|
|
25
|
+
*
|
|
26
|
+
* So "Vice President of Sales" is a decision maker (not an owner because it
|
|
27
|
+
* says president, not an influencer because it says sales), "Marketing
|
|
28
|
+
* Director" is a decision maker (director beats marketing) and "Marketing
|
|
29
|
+
* Coordinator" is an influencer. A title that names none of these
|
|
30
|
+
* ("Technician", a bare "Manager") has no role: no guess.
|
|
31
|
+
*
|
|
32
|
+
* Pure data and one small function, no dependencies. The four role values are
|
|
33
|
+
* stored and sent, so they never change.
|
|
34
|
+
*/
|
|
35
|
+
/** The four parts a person can play in a buying decision, strongest first. */
|
|
36
|
+
export declare const DECISION_ROLES: readonly ['owner', 'decision_maker', 'influencer', 'gatekeeper'];
|
|
37
|
+
export type DecisionRole = (typeof DECISION_ROLES)[number];
|
|
38
|
+
/** The buying role a job title says, or null when it says none ("Technician", "Manager", no title). */
|
|
39
|
+
export declare function decisionRoleForTitle(title: string | null | undefined): DecisionRole | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/decision-roles.ts
|
|
4
|
+
var DECISION_ROLES = ["owner", "decision_maker", "influencer", "gatekeeper"];
|
|
5
|
+
var MAX_TITLE_LENGTH = 200;
|
|
6
|
+
var RULES = [
|
|
7
|
+
// Someone who works for a person, whatever that person's title is.
|
|
8
|
+
[/\b(?:executive|administrative|admin|office|personal)\s+assistant\b|\bassistant to\b|\bsecretary\b|\breceptionist\b|\bfront desk\b/, "gatekeeper"],
|
|
9
|
+
// A vice president before the president rule below can claim the word.
|
|
10
|
+
[/\b(?:vice[ -]president|vp|svp|evp|avp)\b/, "decision_maker"],
|
|
11
|
+
[
|
|
12
|
+
/\b(?:owner|co-?owner|founder|co-?founder|proprietor|president|ceo|chief executive(?: officer)?|managing partner|senior partner|name partner|equity partner)\b|\bprincipal\b(?!\s+(?:engineer|architect|consultant|designer|developer|analyst))|^partner$/,
|
|
13
|
+
"owner"
|
|
14
|
+
],
|
|
15
|
+
// Chief of something else (not the CEO, matched above).
|
|
16
|
+
[/\bchief\b.*\bofficer\b|\b(?:coo|cfo|cto|cmo)\b/, "decision_maker"],
|
|
17
|
+
[/\b(?:general manager|gm|director|operations manager|estimator|estimating manager|project manager|superintendent)\b/, "decision_maker"],
|
|
18
|
+
[/\boffice manager\b|\badministrat(?:or|ive)\b|\badmin\b/, "gatekeeper"],
|
|
19
|
+
[/\b(?:marketing|hr|human resources|recruit(?:er|ing)|talent acquisition|sales|business development)\b/, "influencer"],
|
|
20
|
+
[/\bcoordinator\b/, "gatekeeper"]
|
|
21
|
+
];
|
|
22
|
+
function decisionRoleForTitle(title) {
|
|
23
|
+
if (typeof title !== "string") return null;
|
|
24
|
+
const t = title.slice(0, MAX_TITLE_LENGTH).toLowerCase().replace(/&/g, " and ").replace(/\./g, "").replace(/\s+/g, " ").trim();
|
|
25
|
+
if (!t) return null;
|
|
26
|
+
for (const [pattern, role] of RULES) if (pattern.test(t)) return role;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exports.DECISION_ROLES = DECISION_ROLES;
|
|
31
|
+
exports.decisionRoleForTitle = decisionRoleForTitle;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/contracts/decision-roles.ts
|
|
2
|
+
var DECISION_ROLES = ["owner", "decision_maker", "influencer", "gatekeeper"];
|
|
3
|
+
var MAX_TITLE_LENGTH = 200;
|
|
4
|
+
var RULES = [
|
|
5
|
+
// Someone who works for a person, whatever that person's title is.
|
|
6
|
+
[/\b(?:executive|administrative|admin|office|personal)\s+assistant\b|\bassistant to\b|\bsecretary\b|\breceptionist\b|\bfront desk\b/, "gatekeeper"],
|
|
7
|
+
// A vice president before the president rule below can claim the word.
|
|
8
|
+
[/\b(?:vice[ -]president|vp|svp|evp|avp)\b/, "decision_maker"],
|
|
9
|
+
[
|
|
10
|
+
/\b(?:owner|co-?owner|founder|co-?founder|proprietor|president|ceo|chief executive(?: officer)?|managing partner|senior partner|name partner|equity partner)\b|\bprincipal\b(?!\s+(?:engineer|architect|consultant|designer|developer|analyst))|^partner$/,
|
|
11
|
+
"owner"
|
|
12
|
+
],
|
|
13
|
+
// Chief of something else (not the CEO, matched above).
|
|
14
|
+
[/\bchief\b.*\bofficer\b|\b(?:coo|cfo|cto|cmo)\b/, "decision_maker"],
|
|
15
|
+
[/\b(?:general manager|gm|director|operations manager|estimator|estimating manager|project manager|superintendent)\b/, "decision_maker"],
|
|
16
|
+
[/\boffice manager\b|\badministrat(?:or|ive)\b|\badmin\b/, "gatekeeper"],
|
|
17
|
+
[/\b(?:marketing|hr|human resources|recruit(?:er|ing)|talent acquisition|sales|business development)\b/, "influencer"],
|
|
18
|
+
[/\bcoordinator\b/, "gatekeeper"]
|
|
19
|
+
];
|
|
20
|
+
function decisionRoleForTitle(title) {
|
|
21
|
+
if (typeof title !== "string") return null;
|
|
22
|
+
const t = title.slice(0, MAX_TITLE_LENGTH).toLowerCase().replace(/&/g, " and ").replace(/\./g, "").replace(/\s+/g, " ").trim();
|
|
23
|
+
if (!t) return null;
|
|
24
|
+
for (const [pattern, role] of RULES) if (pattern.test(t)) return role;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { DECISION_ROLES, decisionRoleForTitle };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonordev/contracts",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, what a publication's branding may hold, which literal hosts are refused as public fetch destinations,
|
|
3
|
+
"version": "2.18.0",
|
|
4
|
+
"description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, what a publication's branding may hold, which literal hosts are refused as public fetch destinations, which inbox cards are decisions a bulk action leaves alone, what a rep's call outcome (positive, negative or left voicemail) changes on a prospect, and which buying role a job title says (owner, decision maker, influencer or gatekeeper). Pure functions, no dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -155,6 +155,16 @@
|
|
|
155
155
|
"types": "./dist/funnels.d.ts",
|
|
156
156
|
"import": "./dist/funnels.mjs",
|
|
157
157
|
"require": "./dist/funnels.js"
|
|
158
|
+
},
|
|
159
|
+
"./crm-disposition": {
|
|
160
|
+
"types": "./dist/crm-disposition.d.ts",
|
|
161
|
+
"import": "./dist/crm-disposition.mjs",
|
|
162
|
+
"require": "./dist/crm-disposition.js"
|
|
163
|
+
},
|
|
164
|
+
"./decision-roles": {
|
|
165
|
+
"types": "./dist/decision-roles.d.ts",
|
|
166
|
+
"import": "./dist/decision-roles.mjs",
|
|
167
|
+
"require": "./dist/decision-roles.js"
|
|
158
168
|
}
|
|
159
169
|
},
|
|
160
170
|
"typesVersions": {
|