fullstackgtm 0.34.0 → 0.37.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.
@@ -13,6 +13,7 @@ import type {
13
13
  RecordProvenance,
14
14
  CanonicalGtmSnapshot,
15
15
  CanonicalUser,
16
+ CreateRecordPayload,
16
17
  GtmConnector,
17
18
  GtmObjectType,
18
19
  PatchOperation,
@@ -59,6 +60,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
59
60
  // search API is eventually consistent, so a just-created company is
60
61
  // invisible to search — this map is the authoritative same-run record.
61
62
  const createdCompaniesByName = new Map<string, string>();
63
+ // Same-run dedup for `create_record` contact creates, keyed by lowercased
64
+ // match value (email): HubSpot search is eventually consistent, so a contact
65
+ // created earlier in this apply run is invisible to a later search.
66
+ const createdContactsByMatch = new Map<string, string>();
62
67
 
63
68
  async function request(path: string, init: RequestInit = {}): Promise<any> {
64
69
  const token = await options.getAccessToken();
@@ -220,6 +225,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
220
225
  email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
221
226
  phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
222
227
  title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
228
+ linkedin: stringOrUndefined(readMapped(props, "contacts", "linkedin", "hs_linkedin_url")),
223
229
  ownerId: stringOrUndefined(
224
230
  readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
225
231
  ),
@@ -513,6 +519,138 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
513
519
  return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
514
520
  }
515
521
 
522
+ /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
523
+ async function searchContactsBy(property: string, value: string): Promise<string[]> {
524
+ const data = await request(`/crm/v3/objects/contacts/search`, {
525
+ method: "POST",
526
+ body: JSON.stringify({
527
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
528
+ properties: [property],
529
+ limit: 3,
530
+ }),
531
+ });
532
+ return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
533
+ }
534
+
535
+ /**
536
+ * Resolve a company by exact name, creating it on a confirmed miss. Returns
537
+ * the company id, or null on ambiguity (≥2 existing) so the caller skips the
538
+ * association rather than guessing. Same-run safe via createdCompaniesByName.
539
+ */
540
+ async function resolveOrCreateCompanyByName(name: string, opId: string): Promise<string | null> {
541
+ const nameKey = name.toLowerCase();
542
+ const already = createdCompaniesByName.get(nameKey);
543
+ if (already) return already;
544
+ const matches = await searchCompaniesByName(name);
545
+ if (matches.length > 1) return null;
546
+ if (matches.length === 1) {
547
+ createdCompaniesByName.set(nameKey, matches[0]);
548
+ return matches[0];
549
+ }
550
+ let created;
551
+ try {
552
+ created = await request(`/crm/v3/objects/companies`, {
553
+ method: "POST",
554
+ body: JSON.stringify({
555
+ properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
556
+ }),
557
+ });
558
+ } catch {
559
+ created = await request(`/crm/v3/objects/companies`, {
560
+ method: "POST",
561
+ body: JSON.stringify({ properties: { name } }),
562
+ });
563
+ }
564
+ const id = String(created.id);
565
+ createdCompaniesByName.set(nameKey, id);
566
+ return id;
567
+ }
568
+
569
+ /**
570
+ * Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
571
+ * dedupe key against the live CRM (the plan-time snapshot can be stale) and
572
+ * creates ONLY on a confirmed miss — a record a concurrent writer already
573
+ * added is returned as `skipped`, never duplicated. Contacts can be linked
574
+ * to a resolved-or-created company in the same step.
575
+ */
576
+ async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
577
+ const payload = operation.afterValue as CreateRecordPayload | undefined;
578
+ if (!payload || typeof payload !== "object" || !payload.properties) {
579
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
580
+ }
581
+ const objectPath = OBJECT_PATHS[operation.objectType];
582
+ if (operation.objectType !== "contact" && operation.objectType !== "account") {
583
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
584
+ }
585
+ const matchValue = String(payload.matchValue ?? "").trim();
586
+ if (!matchValue) {
587
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
588
+ }
589
+
590
+ if (operation.objectType === "account") {
591
+ const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
592
+ if (id === null) {
593
+ return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
594
+ }
595
+ return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
596
+ }
597
+
598
+ // contact — resolve-first on the dedupe key (email by default)
599
+ const matchKey = payload.matchKey || "email";
600
+ const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
601
+ if (createdContactsByMatch.has(matchKeyLower)) {
602
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
603
+ }
604
+ const existing = await searchContactsBy(matchKey, matchValue);
605
+ if (existing.length > 0) {
606
+ createdContactsByMatch.set(matchKeyLower, existing[0]);
607
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
608
+ }
609
+
610
+ const properties: Record<string, string> = { ...payload.properties };
611
+ let created;
612
+ try {
613
+ created = await request(`/crm/v3/objects/${objectPath}`, {
614
+ method: "POST",
615
+ body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm acquire (${operation.id})` } }),
616
+ });
617
+ } catch {
618
+ // Some portals reject writes to source-detail properties — the provenance
619
+ // stamp is best-effort, the create is not.
620
+ created = await request(`/crm/v3/objects/${objectPath}`, {
621
+ method: "POST",
622
+ body: JSON.stringify({ properties }),
623
+ });
624
+ }
625
+ const contactId = String(created.id);
626
+ createdContactsByMatch.set(matchKeyLower, contactId);
627
+
628
+ let companyNote = "";
629
+ if (payload.associateCompanyName) {
630
+ try {
631
+ const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
632
+ if (companyId) {
633
+ await request(
634
+ `/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
635
+ { method: "PUT" },
636
+ );
637
+ companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
638
+ } else {
639
+ companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
640
+ }
641
+ } catch (error) {
642
+ // Association is best-effort — the contact create already succeeded.
643
+ companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
644
+ }
645
+ }
646
+ return {
647
+ operationId: operation.id,
648
+ status: "applied",
649
+ detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
650
+ providerData: { id: contactId, created: true },
651
+ };
652
+ }
653
+
516
654
  async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
517
655
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
518
656
  if (associationTypeId === undefined) {
@@ -707,6 +845,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
707
845
  return await linkRecord(operation);
708
846
  case "create_task":
709
847
  return await createTask(operation);
848
+ case "create_record":
849
+ return await createRecord(operation);
710
850
  case "merge_records":
711
851
  return await mergeRecords(operation);
712
852
  case "archive_record":
@@ -0,0 +1,324 @@
1
+ /**
2
+ * API prospect sources for `enrich acquire` — net-new lead discovery + email
3
+ * resolution, behind one normalized shape the acquire builder consumes.
4
+ *
5
+ * Two confirmed providers (others slot in the same way):
6
+ * - Explorium — net-new discovery (POST /v1/prospects). Returns names, titles,
7
+ * company + website, LinkedIn; the email it returns is HASHED, so it is a
8
+ * discovery source, not an email source.
9
+ * - pipe0 — work-email resolution (POST /v1/pipes/run/sync with the
10
+ * `person:workemail:waterfall@1` block). Turns {name, company_domain} into a
11
+ * real work email. This is the email leg for Explorium-discovered people.
12
+ *
13
+ * Zero runtime deps: global fetch only, injectable for tests. Keys arrive via
14
+ * env/credential store, never argv.
15
+ */
16
+ import type { CanonicalGtmSnapshot } from "../types.ts";
17
+
18
+ export type Prospect = {
19
+ firstName?: string;
20
+ lastName?: string;
21
+ fullName?: string;
22
+ jobTitle?: string;
23
+ /** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
24
+ jobLevel?: string;
25
+ /** normalized department, e.g. "sales" (Explorium job_department_main) */
26
+ jobDepartment?: string;
27
+ companyName?: string;
28
+ /** bare domain, e.g. "microsoft.com" — feeds pipe0's company_domain */
29
+ companyDomain?: string;
30
+ linkedin?: string;
31
+ /** real work email once resolved (pipe0); never the hashed value */
32
+ email?: string;
33
+ /** ICP fit score 0..1, set by the acquire scorer */
34
+ fitScore?: number;
35
+ /** provider-native id for traceability */
36
+ sourceId?: string;
37
+ };
38
+
39
+ function splitName(full: string | undefined): { firstName?: string; lastName?: string } {
40
+ if (!full) return {};
41
+ const parts = full.trim().split(/\s+/);
42
+ if (parts.length === 1) return { firstName: parts[0] };
43
+ return { firstName: parts[0], lastName: parts.slice(1).join(" ") };
44
+ }
45
+
46
+ type FetchImpl = typeof fetch;
47
+
48
+ function bareDomain(value: string | undefined): string | undefined {
49
+ if (!value) return undefined;
50
+ return value
51
+ .trim()
52
+ .replace(/^https?:\/\//i, "")
53
+ .replace(/^www\./i, "")
54
+ .replace(/\/.*$/, "")
55
+ .toLowerCase() || undefined;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Explorium — net-new discovery
60
+
61
+ export type ExploriumFilters = Record<string, { values?: string[]; value?: boolean }>;
62
+
63
+ export async function fetchExploriumProspects(opts: {
64
+ apiKey: string;
65
+ filters: ExploriumFilters;
66
+ size?: number;
67
+ apiBaseUrl?: string;
68
+ fetchImpl?: FetchImpl;
69
+ }): Promise<Prospect[]> {
70
+ const fetchImpl = opts.fetchImpl ?? fetch;
71
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
72
+ const size = Math.min(opts.size ?? 25, 100);
73
+ const response = await fetchImpl(`${base}/v1/prospects`, {
74
+ method: "POST",
75
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
76
+ body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
77
+ });
78
+ if (!response.ok) {
79
+ throw new Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
80
+ }
81
+ const data = (await response.json()) as { data?: ExploriumRow[] };
82
+ return (data.data ?? []).map((row) => ({
83
+ firstName: row.first_name,
84
+ lastName: row.last_name,
85
+ fullName: row.full_name ?? ([row.first_name, row.last_name].filter(Boolean).join(" ") || undefined),
86
+ jobTitle: row.job_title,
87
+ jobLevel: row.job_level_main,
88
+ jobDepartment: row.job_department_main,
89
+ companyName: row.company_name,
90
+ companyDomain: bareDomain(row.company_website),
91
+ linkedin: normalizeLinkedin(row.linkedin),
92
+ sourceId: row.prospect_id,
93
+ }));
94
+ }
95
+
96
+ type ExploriumRow = {
97
+ prospect_id?: string;
98
+ first_name?: string;
99
+ last_name?: string;
100
+ full_name?: string;
101
+ job_title?: string;
102
+ job_level_main?: string;
103
+ job_department_main?: string;
104
+ company_name?: string;
105
+ company_website?: string;
106
+ linkedin?: string;
107
+ };
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // pipe0 / Crustdata — net-new discovery (search). Crustdata is built into pipe0
111
+ // (no separate connection needed). Returns profiles; pair with
112
+ // pipe0ResolveWorkEmails to get real emails.
113
+
114
+ export async function fetchPipe0CrustdataProspects(opts: {
115
+ apiKey: string;
116
+ filters: Record<string, unknown>;
117
+ limit?: number;
118
+ apiBaseUrl?: string;
119
+ fetchImpl?: FetchImpl;
120
+ }): Promise<Prospect[]> {
121
+ const fetchImpl = opts.fetchImpl ?? fetch;
122
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
123
+ const limit = Math.min(opts.limit ?? 25, 100);
124
+ const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
125
+ method: "POST",
126
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
127
+ body: JSON.stringify({
128
+ searches: [{ search_id: "people:profiles:crustdata@1", config: { limit, filters: opts.filters } }],
129
+ }),
130
+ });
131
+ if (!response.ok) {
132
+ throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
133
+ }
134
+ const body = (await response.json()) as {
135
+ results?: Array<Record<string, { value?: unknown }>>;
136
+ search_statuses?: Array<{ errors?: Array<{ code?: string; message?: string }> }>;
137
+ };
138
+ // Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
139
+ // silently returning [] — a throttle/credit failure must not look like "no ICP matches".
140
+ const upstreamErrors = (body.search_statuses ?? []).flatMap((s) => s.errors ?? []);
141
+ if (upstreamErrors.length > 0 && !(body.results ?? []).length) {
142
+ throw new Error(
143
+ `pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
144
+ );
145
+ }
146
+ return (body.results ?? []).map((row) => {
147
+ const fullName = strField(row.name);
148
+ const { firstName, lastName } = splitName(fullName);
149
+ return {
150
+ firstName,
151
+ lastName,
152
+ fullName,
153
+ jobTitle: strField(row.job_title),
154
+ companyDomain: bareDomain(strField(row.company_website_url)),
155
+ linkedin: normalizeLinkedin(strField(row.profile_url)),
156
+ sourceId: strField(row.profile_url) ?? fullName,
157
+ };
158
+ });
159
+ }
160
+
161
+ function strField(field: { value?: unknown } | undefined): string | undefined {
162
+ const v = field?.value;
163
+ return typeof v === "string" && v.trim() ? v : undefined;
164
+ }
165
+
166
+ function normalizeLinkedin(value: string | undefined): string | undefined {
167
+ if (!value) return undefined;
168
+ const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
169
+ return v ? `https://${v}` : undefined;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // pipe0 — work-email resolution (waterfall)
174
+
175
+ /**
176
+ * Resolve real work emails for people via pipe0's waterfall. Input rows need a
177
+ * full name + a company domain (or name). Returns the prospects with `email`
178
+ * filled where the waterfall found one; rows with no hit are returned unchanged.
179
+ */
180
+ export async function pipe0ResolveWorkEmails(opts: {
181
+ apiKey: string;
182
+ prospects: Prospect[];
183
+ apiBaseUrl?: string;
184
+ fetchImpl?: FetchImpl;
185
+ /** Rows per pipe0 call. Small chunks are resilient to the waterfall's
186
+ * batch rate-limit (a throttled chunk returns work_email status "failed"
187
+ * for every row, so one big batch is all-or-nothing). Default 3. */
188
+ chunkSize?: number;
189
+ }): Promise<Prospect[]> {
190
+ const fetchImpl = opts.fetchImpl ?? fetch;
191
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
192
+ const chunkSize = Math.max(1, opts.chunkSize ?? 3);
193
+
194
+ // Only rows with the waterfall's required inputs (name + company_domain|name).
195
+ const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
196
+ if (resolvable.length === 0) return opts.prospects;
197
+
198
+ const emailByKey = new Map<string, string>();
199
+ for (let i = 0; i < resolvable.length; i += chunkSize) {
200
+ const chunk = resolvable.slice(i, i + chunkSize);
201
+ const input = chunk.map((p) => ({
202
+ name: p.fullName,
203
+ ...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
204
+ }));
205
+ let body: Pipe0RunResponse;
206
+ try {
207
+ const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
208
+ method: "POST",
209
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
210
+ body: JSON.stringify({ pipes: [{ pipe_id: "person:workemail:waterfall@1" }], input }),
211
+ });
212
+ if (!response.ok) continue; // throttled/5xx chunk — skip, keep the rest
213
+ body = (await response.json()) as Pipe0RunResponse;
214
+ } catch {
215
+ continue; // network hiccup on one chunk must not sink the whole run
216
+ }
217
+ for (const recordId of body.order ?? []) {
218
+ const fields = body.records?.[recordId]?.fields ?? {};
219
+ const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
220
+ if (email) {
221
+ const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
222
+ emailByKey.set(personKey(fieldValue(fields.name), domain), email);
223
+ }
224
+ }
225
+ }
226
+ return opts.prospects.map((p) => {
227
+ const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
228
+ return email ? { ...p, email } : p;
229
+ });
230
+ }
231
+
232
+ function personKey(name: string | undefined, company: string | undefined): string {
233
+ return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
234
+ }
235
+
236
+ type Pipe0Field = { value?: unknown; status?: string };
237
+ type Pipe0RunResponse = {
238
+ order?: string[];
239
+ records?: Record<string, { fields?: Record<string, Pipe0Field> }>;
240
+ };
241
+
242
+ function fieldValue(field: Pipe0Field | undefined): string | undefined {
243
+ const v = field?.value;
244
+ return typeof v === "string" && v.trim() ? v : undefined;
245
+ }
246
+
247
+ async function safeText(response: Response): Promise<string> {
248
+ try {
249
+ return (await response.text()).slice(0, 300);
250
+ } catch {
251
+ return "";
252
+ }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
257
+ // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
258
+
259
+ function normName(value: string | undefined): string {
260
+ return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
261
+ }
262
+
263
+ /**
264
+ * Stable identity keys for a prospect (prefixed by kind). A match on ANY key
265
+ * means "same person". LinkedIn is strongest; name+domain is the only key
266
+ * shareable with the canonical CRM snapshot (which has no LinkedIn); email is
267
+ * available only after resolution (so it can't pre-filter, but it strengthens
268
+ * the seen cache).
269
+ */
270
+ export function prospectIdentityKeys(p: Prospect): string[] {
271
+ const keys: string[] = [];
272
+ if (p.linkedin) keys.push(`li:${p.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
273
+ const name = normName(p.fullName ?? [p.firstName, p.lastName].filter(Boolean).join(" "));
274
+ const domain = bareDomain(p.companyDomain);
275
+ if (name && domain) keys.push(`nd:${name}|${domain}`);
276
+ if (p.email) keys.push(`em:${p.email.trim().toLowerCase()}`);
277
+ return keys;
278
+ }
279
+
280
+ /** Identity keys for every contact already in the CRM snapshot (email + name|domain). */
281
+ export function crmContactKeys(snapshot: CanonicalGtmSnapshot): Set<string> {
282
+ const domainByAccount = new Map<string, string>();
283
+ for (const account of snapshot.accounts ?? []) {
284
+ const d = bareDomain(account.domain);
285
+ if (d) domainByAccount.set(account.id, d);
286
+ }
287
+ const set = new Set<string>();
288
+ for (const contact of snapshot.contacts ?? []) {
289
+ if (contact.linkedin) set.add(`li:${contact.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
290
+ if (contact.email) set.add(`em:${contact.email.trim().toLowerCase()}`);
291
+ const name = normName([contact.firstName, contact.lastName].filter(Boolean).join(" "));
292
+ const domain = contact.accountId ? domainByAccount.get(contact.accountId) : undefined;
293
+ if (name && domain) set.add(`nd:${name}|${domain}`);
294
+ }
295
+ return set;
296
+ }
297
+
298
+ /**
299
+ * Split prospects into those worth paying to enrich vs. drop. A prospect is
300
+ * dropped if any identity key is already in the CRM (`crmKeys`) or in the
301
+ * cross-run `seen` cache. Pure + deterministic.
302
+ */
303
+ export function partitionFreshProspects(
304
+ prospects: Prospect[],
305
+ crmKeys: Set<string>,
306
+ seen: Set<string>,
307
+ ): { fresh: Prospect[]; skippedCrm: number; skippedSeen: number } {
308
+ const fresh: Prospect[] = [];
309
+ let skippedCrm = 0;
310
+ let skippedSeen = 0;
311
+ for (const p of prospects) {
312
+ const keys = prospectIdentityKeys(p);
313
+ if (keys.some((k) => crmKeys.has(k))) {
314
+ skippedCrm += 1;
315
+ continue;
316
+ }
317
+ if (keys.some((k) => seen.has(k))) {
318
+ skippedSeen += 1;
319
+ continue;
320
+ }
321
+ fresh.push(p);
322
+ }
323
+ return { fresh, skippedCrm, skippedSeen };
324
+ }