@deployfoundation/foundation-deploy 0.2.50 → 0.2.52
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/README.md +4 -1
- package/agent-image/Dockerfile +36 -1
- package/dist/bin/app.js +6 -3
- package/dist/bin/foundation-deploy.js +139 -305
- package/dist/bin/release-account.js +1 -0
- package/dist/chunk-3614v9rr.js +907 -0
- package/dist/{chunk-efqdhd2e.js → chunk-59dpqyap.js} +254 -90
- package/dist/chunk-5wgwscd0.js +4 -0
- package/dist/{chunk-gnz747z2.js → chunk-96fjaysn.js} +204 -884
- package/dist/chunk-9zx95xs6.js +259 -0
- package/dist/chunk-jf5rw6h4.js +1117 -0
- package/dist/chunk-m76jt5rc.js +376 -0
- package/dist/chunk-mh1206ne.js +1764 -0
- package/dist/chunk-p7zkm4pp.js +151 -0
- package/dist/chunk-pxhftba1.js +336 -0
- package/dist/chunk-rxetj2p9.js +466 -0
- package/dist/chunk-sgdcmme0.js +301 -0
- package/dist/chunk-tey0gh86.js +38 -0
- package/dist/src/index.js +17 -13
- package/package.json +3 -1
- package/src/deploy/cli.ts +71 -0
- package/src/deploy/crm.ts +491 -0
- package/src/stacks/agent-stack.ts +34 -11
- package/src/stacks/api-stack.ts +340 -87
- package/src/stacks/data-stack.ts +27 -1
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CrmIdempotencyConflictError,
|
|
3
|
+
CrmOutreachUnconfiguredError,
|
|
4
|
+
CrmRecordNotFoundError,
|
|
5
|
+
CrmStaleWriteError,
|
|
6
|
+
CrmStorageError
|
|
7
|
+
} from "./chunk-tey0gh86.js";
|
|
8
|
+
import {
|
|
9
|
+
CrmPolicyRefusedError,
|
|
10
|
+
checkDraft,
|
|
11
|
+
checkDraftedStatus,
|
|
12
|
+
checkNoteEdit,
|
|
13
|
+
checkStatusChange,
|
|
14
|
+
checkSuppressed,
|
|
15
|
+
classifyOutreach,
|
|
16
|
+
movesToDraftedStatus,
|
|
17
|
+
outreachWeekStart
|
|
18
|
+
} from "./chunk-p7zkm4pp.js";
|
|
19
|
+
import {
|
|
20
|
+
CRM_MAX_ACTIVITY_PAGE_SIZE,
|
|
21
|
+
CRM_MAX_CURSOR_CHARS,
|
|
22
|
+
CRM_MAX_OUTREACH_QUEUE_ITEMS,
|
|
23
|
+
CRM_MAX_SEARCH_LIMIT,
|
|
24
|
+
CRM_NOTE_ACTIVITY,
|
|
25
|
+
CrmInputError,
|
|
26
|
+
crmResponseFitsBudget,
|
|
27
|
+
parseCrmGetRecordResponse,
|
|
28
|
+
parseCrmOperationAck,
|
|
29
|
+
parseCrmOutreachQueueResponse,
|
|
30
|
+
parseCrmSearchResponse,
|
|
31
|
+
parseCrmTimestamp
|
|
32
|
+
} from "./chunk-3614v9rr.js";
|
|
33
|
+
import"./chunk-96fjaysn.js";
|
|
34
|
+
import"./chunk-5wgwscd0.js";
|
|
35
|
+
|
|
36
|
+
// ../capability-crm/src/proxy/repository.ts
|
|
37
|
+
import {
|
|
38
|
+
BatchGetCommand,
|
|
39
|
+
BatchWriteCommand,
|
|
40
|
+
GetCommand as GetCommand2,
|
|
41
|
+
PutCommand as PutCommand2,
|
|
42
|
+
QueryCommand,
|
|
43
|
+
TransactWriteCommand as TransactWriteCommand2
|
|
44
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
45
|
+
|
|
46
|
+
// ../capability-crm/src/proxy/hash.ts
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
function hashPayload(operation, payload) {
|
|
49
|
+
return hashText(stableJson({ operation, payload }));
|
|
50
|
+
}
|
|
51
|
+
function hashText(value) {
|
|
52
|
+
return createHash("sha256").update(value).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
function stableJson(value) {
|
|
55
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
56
|
+
return JSON.stringify(value);
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "number") {
|
|
59
|
+
if (!Number.isFinite(value))
|
|
60
|
+
throw new CrmInputError("idempotency payload must be JSON");
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
65
|
+
if (typeof value !== "object")
|
|
66
|
+
throw new CrmInputError("idempotency payload must be JSON");
|
|
67
|
+
const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).sort(([left], [right]) => left.localeCompare(right));
|
|
68
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`).join(",")}}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ../capability-crm/src/proxy/ledger.ts
|
|
72
|
+
import { GetCommand, PutCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
|
|
73
|
+
var RESERVATION_TTL_SECONDS = 35 * 86400;
|
|
74
|
+
function outreachLedgerPartition(tenantId) {
|
|
75
|
+
return `TENANT#${tenantId}#OUTREACH`;
|
|
76
|
+
}
|
|
77
|
+
function outreachWeekSk(weekStart) {
|
|
78
|
+
return `WEEK#${weekStart}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ../capability-crm/src/proxy/outreach-queue.ts
|
|
82
|
+
var SPARE_NEW_LEADS = 10;
|
|
83
|
+
var ORGANIZATION_COOLDOWN_DAYS = 60;
|
|
84
|
+
var DAY_MS = 86400000;
|
|
85
|
+
async function buildOutreachQueue(input) {
|
|
86
|
+
const { policy, now, counters } = input;
|
|
87
|
+
const remaining = Math.max(0, policy.weeklyLimit - counters.firstTouch);
|
|
88
|
+
const followUpsRemaining = Math.max(0, policy.followUpLimit - counters.followUp);
|
|
89
|
+
const dueBefore = now.getTime() - policy.followUpAfterDays * DAY_MS;
|
|
90
|
+
const cooldownSince = now.getTime() - ORGANIZATION_COOLDOWN_DAYS * DAY_MS;
|
|
91
|
+
const recentlyTouched = new Set;
|
|
92
|
+
const touched = (organization, at) => {
|
|
93
|
+
if (organization !== undefined && at !== undefined && Date.parse(at) >= cooldownSince) {
|
|
94
|
+
recentlyTouched.add(organization);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const candidate of input.candidates) {
|
|
98
|
+
touched(candidate.organization, candidate.touch?.lastTouchAt);
|
|
99
|
+
}
|
|
100
|
+
for (const { organization, lastTouchAt } of input.organizationTouches ?? []) {
|
|
101
|
+
touched(organization, lastTouchAt);
|
|
102
|
+
}
|
|
103
|
+
const isNewLead = (candidate) => candidate.touch === undefined && newLeadStatus(policy, candidate.status) && !suppressed(policy, candidate);
|
|
104
|
+
const isFollowUp = (candidate) => followUpDue(policy, candidate, dueBefore) && !suppressed(policy, candidate);
|
|
105
|
+
const followUpCandidates = [];
|
|
106
|
+
const newCandidates = [];
|
|
107
|
+
for (const candidate of input.candidates) {
|
|
108
|
+
if (candidate.touch === undefined) {
|
|
109
|
+
if (candidate.organization !== undefined && recentlyTouched.has(candidate.organization)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (isNewLead(candidate))
|
|
113
|
+
newCandidates.push(candidate);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (isFollowUp(candidate))
|
|
117
|
+
followUpCandidates.push(candidate);
|
|
118
|
+
}
|
|
119
|
+
followUpCandidates.sort((left, right) => (left.touch?.lastTouchAt ?? "").localeCompare(right.touch?.lastTouchAt ?? "") || left.contactId.localeCompare(right.contactId));
|
|
120
|
+
newCandidates.sort((left, right) => (right.rank ?? Number.NEGATIVE_INFINITY) - (left.rank ?? Number.NEGATIVE_INFINITY) || (left.createdAt ?? "").localeCompare(right.createdAt ?? "") || left.contactId.localeCompare(right.contactId));
|
|
121
|
+
const followUps = [];
|
|
122
|
+
for (const candidate of await chosen(followUpCandidates, Math.min(followUpsRemaining, CRM_MAX_OUTREACH_QUEUE_ITEMS), isFollowUp, input.recheck)) {
|
|
123
|
+
const touch = candidate.touch;
|
|
124
|
+
followUps.push({
|
|
125
|
+
...outreachContact(candidate),
|
|
126
|
+
lastTouchAt: touch.lastTouchAt,
|
|
127
|
+
followUpNumber: touch.followUps + 1,
|
|
128
|
+
...touch.lastThreadId === undefined ? {} : { threadId: touch.lastThreadId }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const offered = new Set;
|
|
132
|
+
const distinct = newCandidates.filter((candidate) => {
|
|
133
|
+
if (candidate.organization === undefined)
|
|
134
|
+
return true;
|
|
135
|
+
if (offered.has(candidate.organization))
|
|
136
|
+
return false;
|
|
137
|
+
offered.add(candidate.organization);
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
const newLeads = [];
|
|
141
|
+
for (const candidate of await chosen(distinct, remaining === 0 ? 0 : Math.min(remaining + SPARE_NEW_LEADS, CRM_MAX_OUTREACH_QUEUE_ITEMS), isNewLead, input.recheck)) {
|
|
142
|
+
newLeads.push({
|
|
143
|
+
...outreachContact(candidate),
|
|
144
|
+
...candidate.rank === undefined ? {} : { rank: candidate.rank }
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return parseCrmOutreachQueueResponse({
|
|
148
|
+
weekStart: outreachWeekStart(now, policy.timezone),
|
|
149
|
+
timezone: policy.timezone,
|
|
150
|
+
weeklyLimit: policy.weeklyLimit,
|
|
151
|
+
newDraftedThisWeek: counters.firstTouch,
|
|
152
|
+
remaining,
|
|
153
|
+
followUpLimit: policy.followUpLimit,
|
|
154
|
+
followUpsDraftedThisWeek: counters.followUp,
|
|
155
|
+
followUpsRemaining,
|
|
156
|
+
followUps,
|
|
157
|
+
newLeads,
|
|
158
|
+
...input.truncated === true ? { truncated: true } : {}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function chosen(candidates, cap, qualifies, recheck) {
|
|
162
|
+
const accepted = [];
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
if (accepted.length >= cap)
|
|
165
|
+
break;
|
|
166
|
+
const current = recheck === undefined ? candidate : await recheck(candidate);
|
|
167
|
+
if (current === null || !qualifies(current))
|
|
168
|
+
continue;
|
|
169
|
+
accepted.push(current);
|
|
170
|
+
}
|
|
171
|
+
return accepted;
|
|
172
|
+
}
|
|
173
|
+
function newLeadStatus(policy, status) {
|
|
174
|
+
return policy.eligibleStatuses.includes(status) && !policy.blockedStatuses.includes(status);
|
|
175
|
+
}
|
|
176
|
+
function suppressed(policy, candidate) {
|
|
177
|
+
return checkSuppressed(policy.suppressedStatuses, {
|
|
178
|
+
status: candidate.status,
|
|
179
|
+
...candidate.doNotContact === undefined ? {} : { doNotContact: candidate.doNotContact }
|
|
180
|
+
}, candidate.companyStatus === undefined ? undefined : { status: candidate.companyStatus }) !== undefined;
|
|
181
|
+
}
|
|
182
|
+
function followUpDue(policy, candidate, dueBefore) {
|
|
183
|
+
const touch = candidate.touch;
|
|
184
|
+
if (touch?.lastTouchAt === undefined || !policy.followUpStatuses.includes(candidate.status)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (touch.followUps >= policy.maxFollowUps)
|
|
188
|
+
return false;
|
|
189
|
+
const lastTouch = Date.parse(touch.lastTouchAt);
|
|
190
|
+
if (!Number.isFinite(lastTouch) || lastTouch > dueBefore)
|
|
191
|
+
return false;
|
|
192
|
+
if (touch.lastReplyAt !== undefined && Date.parse(touch.lastReplyAt) >= lastTouch)
|
|
193
|
+
return false;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
function outreachContact(candidate) {
|
|
197
|
+
return {
|
|
198
|
+
contactId: candidate.contactId,
|
|
199
|
+
email: candidate.email,
|
|
200
|
+
status: candidate.status,
|
|
201
|
+
...candidate.firstName === undefined ? {} : { firstName: candidate.firstName },
|
|
202
|
+
...candidate.lastName === undefined ? {} : { lastName: candidate.lastName },
|
|
203
|
+
...candidate.title === undefined ? {} : { title: candidate.title },
|
|
204
|
+
...candidate.companyExternalId === undefined ? {} : { companyExternalId: candidate.companyExternalId },
|
|
205
|
+
...candidate.companyId === undefined ? {} : { companyId: candidate.companyId },
|
|
206
|
+
...candidate.company === undefined ? {} : { company: candidate.company }
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ../capability-crm/src/proxy/repository.ts
|
|
211
|
+
var RECORDS_INDEX = "RecordsIndex";
|
|
212
|
+
var PROFILE_SK = "PROFILE";
|
|
213
|
+
var LIFECYCLE_SK = "LIFECYCLE";
|
|
214
|
+
var ACTIVITY_SK_PREFIX = "ACTIVITY#";
|
|
215
|
+
var DEFAULT_ACTIVITY_LIMIT = 20;
|
|
216
|
+
var MAX_WRITE_RETRIES = 4;
|
|
217
|
+
var MAX_BATCH_WRITE_RETRIES = 4;
|
|
218
|
+
var BATCH_WRITE_LIMIT = 25;
|
|
219
|
+
var ACTIVITY_DELETE_PAGE_SIZE = 100;
|
|
220
|
+
var MAX_ACTIVITY_DELETE_PAGES = 10;
|
|
221
|
+
var OUTREACH_SK_PREFIX = "OUTREACH#";
|
|
222
|
+
var MAX_QUEUE_SCAN_PAGES = 30;
|
|
223
|
+
var QUEUE_SCAN_PAGE_SIZE = 100;
|
|
224
|
+
var BATCH_GET_LIMIT = 100;
|
|
225
|
+
var MAX_BATCH_GET_RETRIES = 4;
|
|
226
|
+
var MAX_ACTIVITY_FIND_PAGES = 10;
|
|
227
|
+
var MAX_NOTE_HISTORY = 10;
|
|
228
|
+
class DynamoCrmClient {
|
|
229
|
+
documentClient;
|
|
230
|
+
tableName;
|
|
231
|
+
tenantId;
|
|
232
|
+
defaultStatuses;
|
|
233
|
+
maxBatchSize;
|
|
234
|
+
actor;
|
|
235
|
+
statusPolicy;
|
|
236
|
+
outreach;
|
|
237
|
+
countsOutreach;
|
|
238
|
+
now;
|
|
239
|
+
constructor(options) {
|
|
240
|
+
this.documentClient = options.documentClient;
|
|
241
|
+
this.tableName = options.tableName;
|
|
242
|
+
this.tenantId = options.tenantId;
|
|
243
|
+
this.defaultStatuses = {
|
|
244
|
+
company: options.defaultStatuses.company,
|
|
245
|
+
contact: options.defaultStatuses.contact
|
|
246
|
+
};
|
|
247
|
+
this.maxBatchSize = options.maxBatchSize;
|
|
248
|
+
this.actor = copyActor(options.actor);
|
|
249
|
+
this.statusPolicy = options.statusPolicy ?? {};
|
|
250
|
+
this.outreach = options.outreach;
|
|
251
|
+
this.countsOutreach = options.countsOutreach ?? true;
|
|
252
|
+
this.now = options.now ?? (() => new Date);
|
|
253
|
+
}
|
|
254
|
+
async upsertRecords(args) {
|
|
255
|
+
const count = (args.companies?.length ?? 0) + (args.contacts?.length ?? 0);
|
|
256
|
+
if (count === 0 || count > this.maxBatchSize) {
|
|
257
|
+
throw new CrmInputError(`upsert request may contain at most ${this.maxBatchSize} records`);
|
|
258
|
+
}
|
|
259
|
+
const companies = args.companies ?? [];
|
|
260
|
+
const contacts = args.contacts ?? [];
|
|
261
|
+
let claim = await this.resolveClaim("upsert_records", args.idempotencyKey, args);
|
|
262
|
+
claim ??= await this.claimWrite("upsert_records", args.idempotencyKey, args, await this.upsertTargets(companies, contacts));
|
|
263
|
+
if (claim.replay !== undefined)
|
|
264
|
+
return claim.replay;
|
|
265
|
+
for (const company of companies) {
|
|
266
|
+
const partition = this.recordPartition("company", deterministicRecordId("company", company.externalId));
|
|
267
|
+
await this.mergeProfile("company", company, claim.claimedAt, requiredTarget(claim, partition));
|
|
268
|
+
}
|
|
269
|
+
for (const contact of contacts) {
|
|
270
|
+
const partition = this.recordPartition("contact", deterministicRecordId("contact", contact.externalId));
|
|
271
|
+
await this.mergeProfile("contact", contact, claim.claimedAt, requiredTarget(claim, partition));
|
|
272
|
+
}
|
|
273
|
+
const result = { accepted: true, operation: "upsert_records", records: count };
|
|
274
|
+
await this.completeClaim(claim, result);
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
async search(args = {}) {
|
|
278
|
+
const limit = boundedLimit(args.limit, CRM_MAX_SEARCH_LIMIT, "limit");
|
|
279
|
+
const searchText = args.query === undefined ? undefined : normalizedText([args.query]);
|
|
280
|
+
const exclusiveStartKey = args.cursor === undefined ? undefined : decodeRecordsCursor(args.cursor, this.recordsPartition());
|
|
281
|
+
const output = await this.documentClient.send(new QueryCommand({
|
|
282
|
+
TableName: this.tableName,
|
|
283
|
+
IndexName: RECORDS_INDEX,
|
|
284
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk",
|
|
285
|
+
ExpressionAttributeNames: { "#gsi1pk": "gsi1pk" },
|
|
286
|
+
ExpressionAttributeValues: { ":gsi1pk": this.recordsPartition() },
|
|
287
|
+
Limit: limit,
|
|
288
|
+
...exclusiveStartKey === undefined ? {} : { ExclusiveStartKey: exclusiveStartKey }
|
|
289
|
+
}));
|
|
290
|
+
const items = output.Items ?? [];
|
|
291
|
+
const candidates = await Promise.all(items.map(async (item) => {
|
|
292
|
+
const identity = indexCandidate(item, this.recordsPartition());
|
|
293
|
+
const profile = await this.readActiveProfile(identity.recordType, identity.recordId);
|
|
294
|
+
if (profile === null || !profileMatchesSearch(profile, args, searchText))
|
|
295
|
+
return null;
|
|
296
|
+
return { item, profile };
|
|
297
|
+
}));
|
|
298
|
+
const accepted = candidates.filter((candidate) => candidate !== null);
|
|
299
|
+
const records = accepted.map(({ profile }) => searchRecord(profile));
|
|
300
|
+
const finalCursor = output.LastEvaluatedKey === undefined ? undefined : encodeRecordsCursor(output.LastEvaluatedKey, this.recordsPartition());
|
|
301
|
+
return boundedSearchResponse(accepted.map(({ item }) => item), records, finalCursor, this.recordsPartition());
|
|
302
|
+
}
|
|
303
|
+
async getRecord(args) {
|
|
304
|
+
const profile = await this.readActiveProfile(args.recordType, args.recordId);
|
|
305
|
+
if (profile === null)
|
|
306
|
+
throw new CrmRecordNotFoundError;
|
|
307
|
+
const activityLimit = boundedLimit(args.activityLimit, CRM_MAX_ACTIVITY_PAGE_SIZE, "activityLimit", DEFAULT_ACTIVITY_LIMIT);
|
|
308
|
+
const partition = this.recordPartition(args.recordType, args.recordId);
|
|
309
|
+
const prefix = activityPrefix(profile.generation);
|
|
310
|
+
const exclusiveStartKey = args.cursor === undefined ? undefined : decodeActivityCursor(args.cursor, partition, prefix);
|
|
311
|
+
const output = await this.documentClient.send(new QueryCommand({
|
|
312
|
+
TableName: this.tableName,
|
|
313
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :activityPrefix)",
|
|
314
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" },
|
|
315
|
+
ExpressionAttributeValues: { ":pk": partition, ":activityPrefix": prefix },
|
|
316
|
+
Limit: activityLimit,
|
|
317
|
+
ScanIndexForward: false,
|
|
318
|
+
ConsistentRead: true,
|
|
319
|
+
...exclusiveStartKey === undefined ? {} : { ExclusiveStartKey: exclusiveStartKey }
|
|
320
|
+
}));
|
|
321
|
+
const items = output.Items ?? [];
|
|
322
|
+
const activities = items.map(activityFromItem);
|
|
323
|
+
const finalCursor = output.LastEvaluatedKey === undefined ? undefined : encodeActivityCursor(output.LastEvaluatedKey, partition, prefix);
|
|
324
|
+
return boundedGetResponse(profileRecord(profile), items, activities, finalCursor, partition, prefix);
|
|
325
|
+
}
|
|
326
|
+
async updateStatus(args) {
|
|
327
|
+
const partition = this.recordPartition(args.recordType, args.recordId);
|
|
328
|
+
let claim = await this.resolveClaim("update_status", args.idempotencyKey, args);
|
|
329
|
+
if (claim === null) {
|
|
330
|
+
const profile = await this.readActiveProfile(args.recordType, args.recordId);
|
|
331
|
+
if (profile === null)
|
|
332
|
+
throw new CrmRecordNotFoundError;
|
|
333
|
+
this.assertStatusChangeAllowed(args, profile.status);
|
|
334
|
+
await this.assertDraftedStatusAllowed(args, partition, profile);
|
|
335
|
+
claim = await this.claimWrite("update_status", args.idempotencyKey, args, {
|
|
336
|
+
[partition]: profile.generation
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (claim.replay !== undefined)
|
|
340
|
+
return claim.replay;
|
|
341
|
+
const targetGeneration = requiredTarget(claim, partition);
|
|
342
|
+
await this.changeProfile(args.recordType, args.recordId, claim.claimedAt, targetGeneration, (profile) => {
|
|
343
|
+
this.assertStatusChangeAllowed(args, profile.status);
|
|
344
|
+
return { ...profile, status: args.status };
|
|
345
|
+
});
|
|
346
|
+
const result = {
|
|
347
|
+
accepted: true,
|
|
348
|
+
operation: "update_status",
|
|
349
|
+
recordType: args.recordType,
|
|
350
|
+
recordId: args.recordId,
|
|
351
|
+
status: args.status
|
|
352
|
+
};
|
|
353
|
+
await this.completeClaim(claim, result);
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
async logActivity(args) {
|
|
357
|
+
const partition = this.recordPartition(args.recordType, args.recordId);
|
|
358
|
+
const outreachEvent = classifyOutreach(args, this.outreach !== undefined);
|
|
359
|
+
let claim = await this.resolveClaim("log_activity", args.idempotencyKey, args);
|
|
360
|
+
if (claim === null) {
|
|
361
|
+
const initial = await this.readActiveProfile(args.recordType, args.recordId);
|
|
362
|
+
if (initial === null)
|
|
363
|
+
throw new CrmRecordNotFoundError;
|
|
364
|
+
if (outreachEvent?.type === "drafted") {
|
|
365
|
+
await this.assertNotSuppressed(initial);
|
|
366
|
+
await this.assertDraftAllowed(partition, initial.generation, outreachEvent.kind);
|
|
367
|
+
}
|
|
368
|
+
claim = await this.claimWrite("log_activity", args.idempotencyKey, args, {
|
|
369
|
+
[partition]: initial.generation
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (claim.replay !== undefined)
|
|
373
|
+
return claim.replay;
|
|
374
|
+
const targetGeneration = requiredTarget(claim, partition);
|
|
375
|
+
const profile = await this.readActiveProfile(args.recordType, args.recordId);
|
|
376
|
+
if (profile === null)
|
|
377
|
+
throw new CrmRecordNotFoundError;
|
|
378
|
+
if (profile.generation !== targetGeneration)
|
|
379
|
+
throw new CrmStorageError;
|
|
380
|
+
const occurredAt = new Date(parseCrmTimestamp(args.occurredAt ?? claim.claimedAt, "occurredAt")).toISOString();
|
|
381
|
+
const activityId = deterministicActivityId(partition, claim.key.sk);
|
|
382
|
+
const item = {
|
|
383
|
+
pk: partition,
|
|
384
|
+
sk: `${activityPrefix(targetGeneration)}${occurredAt}#${activityId}`,
|
|
385
|
+
recordType: args.recordType,
|
|
386
|
+
recordId: args.recordId,
|
|
387
|
+
generation: targetGeneration,
|
|
388
|
+
activityId,
|
|
389
|
+
activityType: args.activityType,
|
|
390
|
+
summary: args.summary,
|
|
391
|
+
occurredAt,
|
|
392
|
+
createdAt: claim.claimedAt,
|
|
393
|
+
actor: this.actor,
|
|
394
|
+
...args.attributes === undefined ? {} : { attributes: copyAttributes(args.attributes) }
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
await this.documentClient.send(new TransactWriteCommand2({
|
|
398
|
+
TransactItems: [
|
|
399
|
+
{
|
|
400
|
+
ConditionCheck: {
|
|
401
|
+
TableName: this.tableName,
|
|
402
|
+
Key: { pk: partition, sk: LIFECYCLE_SK },
|
|
403
|
+
ConditionExpression: "#state = :active AND #generation = :generation",
|
|
404
|
+
ExpressionAttributeNames: { "#state": "state", "#generation": "generation" },
|
|
405
|
+
ExpressionAttributeValues: {
|
|
406
|
+
":active": "active",
|
|
407
|
+
":generation": targetGeneration
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
{ Put: { TableName: this.tableName, Item: item } },
|
|
412
|
+
...outreachEvent === undefined ? [] : this.outreachWrites(partition, targetGeneration, outreachEvent, occurredAt)
|
|
413
|
+
]
|
|
414
|
+
}));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (!isConditionalFailure(error))
|
|
417
|
+
throw error;
|
|
418
|
+
if (outreachEvent?.type === "drafted") {
|
|
419
|
+
await this.assertDraftAllowed(partition, targetGeneration, outreachEvent.kind);
|
|
420
|
+
}
|
|
421
|
+
throw new CrmStorageError;
|
|
422
|
+
}
|
|
423
|
+
const result = {
|
|
424
|
+
accepted: true,
|
|
425
|
+
operation: "log_activity",
|
|
426
|
+
recordType: args.recordType,
|
|
427
|
+
recordId: args.recordId,
|
|
428
|
+
activityType: args.activityType
|
|
429
|
+
};
|
|
430
|
+
await this.completeClaim(claim, result);
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
async updateNote(args) {
|
|
434
|
+
const partition = this.recordPartition(args.recordType, args.recordId);
|
|
435
|
+
let claim = await this.resolveClaim("update_note", args.idempotencyKey, args);
|
|
436
|
+
if (claim === null) {
|
|
437
|
+
const profile = await this.readActiveProfile(args.recordType, args.recordId);
|
|
438
|
+
if (profile === null)
|
|
439
|
+
throw new CrmRecordNotFoundError;
|
|
440
|
+
const item2 = await this.findActivity(partition, profile.generation, args.activityId);
|
|
441
|
+
assertNoteEditable(item2, args.version);
|
|
442
|
+
claim = await this.claimWrite("update_note", args.idempotencyKey, args, {
|
|
443
|
+
[partition]: profile.generation
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (claim.replay !== undefined)
|
|
447
|
+
return claim.replay;
|
|
448
|
+
const generation = requiredTarget(claim, partition);
|
|
449
|
+
const item = await this.findActivity(partition, generation, args.activityId);
|
|
450
|
+
const result = {
|
|
451
|
+
accepted: true,
|
|
452
|
+
operation: "update_note",
|
|
453
|
+
recordType: args.recordType,
|
|
454
|
+
recordId: args.recordId,
|
|
455
|
+
activityId: args.activityId,
|
|
456
|
+
version: args.version + 1
|
|
457
|
+
};
|
|
458
|
+
if (item?.editedByClaim === claim.key.sk && activityVersion(item) === args.version + 1) {
|
|
459
|
+
await this.completeClaim(claim, result);
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
assertNoteEditable(item, args.version);
|
|
463
|
+
const current = item;
|
|
464
|
+
const replaced = {
|
|
465
|
+
summary: current.summary,
|
|
466
|
+
version: args.version,
|
|
467
|
+
replacedAt: claim.claimedAt
|
|
468
|
+
};
|
|
469
|
+
const history = [...storedNoteHistory(current.history), replaced].slice(-MAX_NOTE_HISTORY);
|
|
470
|
+
const versioned = typeof current.version === "number";
|
|
471
|
+
try {
|
|
472
|
+
await this.documentClient.send(new TransactWriteCommand2({
|
|
473
|
+
TransactItems: [
|
|
474
|
+
{
|
|
475
|
+
ConditionCheck: {
|
|
476
|
+
TableName: this.tableName,
|
|
477
|
+
Key: { pk: partition, sk: LIFECYCLE_SK },
|
|
478
|
+
ConditionExpression: "#state = :active AND #generation = :generation",
|
|
479
|
+
ExpressionAttributeNames: { "#state": "state", "#generation": "generation" },
|
|
480
|
+
ExpressionAttributeValues: { ":active": "active", ":generation": generation }
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
Update: {
|
|
485
|
+
TableName: this.tableName,
|
|
486
|
+
Key: { pk: current.pk, sk: current.sk },
|
|
487
|
+
UpdateExpression: "SET #summary = :summary, #version = :next, #editedAt = :at, #editedBy = :by, #editedByClaim = :claim, #history = :history",
|
|
488
|
+
ConditionExpression: versioned ? "#activityType = :note AND #version = :version" : "#activityType = :note AND attribute_not_exists(#version)",
|
|
489
|
+
ExpressionAttributeNames: {
|
|
490
|
+
"#summary": "summary",
|
|
491
|
+
"#version": "version",
|
|
492
|
+
"#editedAt": "editedAt",
|
|
493
|
+
"#editedBy": "editedBy",
|
|
494
|
+
"#editedByClaim": "editedByClaim",
|
|
495
|
+
"#history": "history",
|
|
496
|
+
"#activityType": "activityType"
|
|
497
|
+
},
|
|
498
|
+
ExpressionAttributeValues: {
|
|
499
|
+
":summary": args.summary,
|
|
500
|
+
":next": args.version + 1,
|
|
501
|
+
":at": claim.claimedAt,
|
|
502
|
+
":by": this.actor,
|
|
503
|
+
":claim": claim.key.sk,
|
|
504
|
+
":history": history,
|
|
505
|
+
":note": CRM_NOTE_ACTIVITY,
|
|
506
|
+
...versioned ? { ":version": args.version } : {}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
}));
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (!isConditionalFailure(error))
|
|
514
|
+
throw error;
|
|
515
|
+
throw new CrmStaleWriteError("stale_version");
|
|
516
|
+
}
|
|
517
|
+
await this.completeClaim(claim, result);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
async findActivity(partition, generation, activityId) {
|
|
521
|
+
let exclusiveStartKey;
|
|
522
|
+
for (let page = 0;page < MAX_ACTIVITY_FIND_PAGES; page += 1) {
|
|
523
|
+
const output = await this.documentClient.send(new QueryCommand({
|
|
524
|
+
TableName: this.tableName,
|
|
525
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :activityPrefix)",
|
|
526
|
+
FilterExpression: "#activityId = :activityId",
|
|
527
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#activityId": "activityId" },
|
|
528
|
+
ExpressionAttributeValues: {
|
|
529
|
+
":pk": partition,
|
|
530
|
+
":activityPrefix": activityPrefix(generation),
|
|
531
|
+
":activityId": activityId
|
|
532
|
+
},
|
|
533
|
+
ConsistentRead: true,
|
|
534
|
+
Limit: ACTIVITY_DELETE_PAGE_SIZE,
|
|
535
|
+
...exclusiveStartKey === undefined ? {} : { ExclusiveStartKey: exclusiveStartKey }
|
|
536
|
+
}));
|
|
537
|
+
const found = (output.Items ?? []).find((item) => item.activityId === activityId);
|
|
538
|
+
if (found !== undefined)
|
|
539
|
+
return found;
|
|
540
|
+
exclusiveStartKey = output.LastEvaluatedKey;
|
|
541
|
+
if (exclusiveStartKey === undefined)
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
async deleteRecord(args) {
|
|
547
|
+
const partition = this.recordPartition(args.recordType, args.recordId);
|
|
548
|
+
let claim = await this.resolveClaim("delete_record", args.idempotencyKey, args);
|
|
549
|
+
if (claim === null) {
|
|
550
|
+
const lifecycle = await this.readLifecycle(partition);
|
|
551
|
+
const targets = lifecycle === null ? {} : { [partition]: lifecycle.generation };
|
|
552
|
+
claim = await this.claimWrite("delete_record", args.idempotencyKey, args, targets);
|
|
553
|
+
}
|
|
554
|
+
const result = {
|
|
555
|
+
accepted: true,
|
|
556
|
+
operation: "delete_record",
|
|
557
|
+
recordType: args.recordType,
|
|
558
|
+
recordId: args.recordId
|
|
559
|
+
};
|
|
560
|
+
const targetGeneration = claim.targets[partition];
|
|
561
|
+
if (claim.replay === undefined) {
|
|
562
|
+
await this.fenceDelete(partition, claim, result, targetGeneration);
|
|
563
|
+
}
|
|
564
|
+
if (targetGeneration !== undefined) {
|
|
565
|
+
await this.deleteActivityGeneration(partition, targetGeneration);
|
|
566
|
+
}
|
|
567
|
+
return claim.replay ?? result;
|
|
568
|
+
}
|
|
569
|
+
async outreachQueue() {
|
|
570
|
+
const policy = this.outreach;
|
|
571
|
+
if (policy === undefined)
|
|
572
|
+
throw new CrmOutreachUnconfiguredError;
|
|
573
|
+
const now = this.now();
|
|
574
|
+
const [counters, scan] = await Promise.all([
|
|
575
|
+
this.readWeekCounter(outreachWeekStart(now, policy.timezone)),
|
|
576
|
+
this.scanProfiles()
|
|
577
|
+
]);
|
|
578
|
+
const companies = new Map;
|
|
579
|
+
const contacts = [];
|
|
580
|
+
for (const profile of scan.profiles) {
|
|
581
|
+
if (profile.recordType === "company")
|
|
582
|
+
companies.set(profile.externalId, profile);
|
|
583
|
+
else if (profile.email !== undefined)
|
|
584
|
+
contacts.push(profile);
|
|
585
|
+
}
|
|
586
|
+
const touches = await this.readOutreachStates(contacts);
|
|
587
|
+
const candidate = (profile) => outreachCandidate(profile, companies, touches.get(profile.recordId), policy.rankAttribute);
|
|
588
|
+
return buildOutreachQueue({
|
|
589
|
+
policy,
|
|
590
|
+
now,
|
|
591
|
+
counters,
|
|
592
|
+
candidates: contacts.map(candidate),
|
|
593
|
+
...scan.truncated ? { truncated: true } : {},
|
|
594
|
+
recheck: async ({ contactId }) => {
|
|
595
|
+
const current = await this.readActiveProfile("contact", contactId);
|
|
596
|
+
return current === null || current.email === undefined ? null : candidate(current);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
async scanProfiles() {
|
|
601
|
+
const identities = [];
|
|
602
|
+
let exclusiveStartKey;
|
|
603
|
+
let truncated = false;
|
|
604
|
+
for (let page = 0;page < MAX_QUEUE_SCAN_PAGES; page += 1) {
|
|
605
|
+
const output = await this.documentClient.send(new QueryCommand({
|
|
606
|
+
TableName: this.tableName,
|
|
607
|
+
IndexName: RECORDS_INDEX,
|
|
608
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk",
|
|
609
|
+
ExpressionAttributeNames: { "#gsi1pk": "gsi1pk" },
|
|
610
|
+
ExpressionAttributeValues: { ":gsi1pk": this.recordsPartition() },
|
|
611
|
+
Limit: QUEUE_SCAN_PAGE_SIZE,
|
|
612
|
+
...exclusiveStartKey === undefined ? {} : { ExclusiveStartKey: exclusiveStartKey }
|
|
613
|
+
}));
|
|
614
|
+
for (const item of output.Items ?? []) {
|
|
615
|
+
identities.push(indexCandidate(item, this.recordsPartition()));
|
|
616
|
+
}
|
|
617
|
+
exclusiveStartKey = output.LastEvaluatedKey;
|
|
618
|
+
if (exclusiveStartKey === undefined)
|
|
619
|
+
break;
|
|
620
|
+
if (page === MAX_QUEUE_SCAN_PAGES - 1)
|
|
621
|
+
truncated = true;
|
|
622
|
+
}
|
|
623
|
+
const keys = [];
|
|
624
|
+
for (const { recordType, recordId } of identities) {
|
|
625
|
+
const pk = this.recordPartition(recordType, recordId);
|
|
626
|
+
keys.push({ pk, sk: PROFILE_SK }, { pk, sk: LIFECYCLE_SK });
|
|
627
|
+
}
|
|
628
|
+
const items = await this.batchGet(keys);
|
|
629
|
+
const profiles = [];
|
|
630
|
+
for (const { recordType, recordId } of identities) {
|
|
631
|
+
const pk = this.recordPartition(recordType, recordId);
|
|
632
|
+
const profileItem = items.get(itemKey(pk, PROFILE_SK));
|
|
633
|
+
const lifecycleItem = items.get(itemKey(pk, LIFECYCLE_SK));
|
|
634
|
+
if (profileItem === undefined || lifecycleItem === undefined)
|
|
635
|
+
continue;
|
|
636
|
+
const profile = profileFromItem(profileItem);
|
|
637
|
+
this.assertProfileStorageKey(profileItem, profile);
|
|
638
|
+
if (lifecycleItem.state !== "active" || lifecycleItem.generation !== profile.generation || profile.recordType !== recordType || profile.recordId !== recordId) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
profiles.push(profile);
|
|
642
|
+
}
|
|
643
|
+
return { profiles, truncated };
|
|
644
|
+
}
|
|
645
|
+
async readOutreachStates(contacts) {
|
|
646
|
+
const items = await this.batchGet(contacts.map((profile) => ({
|
|
647
|
+
pk: this.recordPartition("contact", profile.recordId),
|
|
648
|
+
sk: outreachSk(profile.generation)
|
|
649
|
+
})));
|
|
650
|
+
const states = new Map;
|
|
651
|
+
for (const profile of contacts) {
|
|
652
|
+
const item = items.get(itemKey(this.recordPartition("contact", profile.recordId), outreachSk(profile.generation)));
|
|
653
|
+
if (item !== undefined)
|
|
654
|
+
states.set(profile.recordId, outreachStateFromItem(item));
|
|
655
|
+
}
|
|
656
|
+
return states;
|
|
657
|
+
}
|
|
658
|
+
async batchGet(keys) {
|
|
659
|
+
const found = new Map;
|
|
660
|
+
const chunks = [];
|
|
661
|
+
for (let offset = 0;offset < keys.length; offset += BATCH_GET_LIMIT) {
|
|
662
|
+
chunks.push(keys.slice(offset, offset + BATCH_GET_LIMIT));
|
|
663
|
+
}
|
|
664
|
+
for (const chunk of chunks) {
|
|
665
|
+
let pending = chunk;
|
|
666
|
+
for (let attempt = 0;pending.length > 0 && attempt < MAX_BATCH_GET_RETRIES; attempt += 1) {
|
|
667
|
+
const output = await this.documentClient.send(new BatchGetCommand({
|
|
668
|
+
RequestItems: { [this.tableName]: { Keys: pending, ConsistentRead: true } }
|
|
669
|
+
}));
|
|
670
|
+
for (const item of output.Responses?.[this.tableName] ?? []) {
|
|
671
|
+
if (typeof item.pk === "string" && typeof item.sk === "string") {
|
|
672
|
+
found.set(itemKey(item.pk, item.sk), item);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
pending = output.UnprocessedKeys?.[this.tableName]?.Keys ?? [];
|
|
676
|
+
}
|
|
677
|
+
if (pending.length > 0)
|
|
678
|
+
throw new CrmStorageError;
|
|
679
|
+
}
|
|
680
|
+
return found;
|
|
681
|
+
}
|
|
682
|
+
assertStatusChangeAllowed(args, currentStatus) {
|
|
683
|
+
const refusal = checkStatusChange(this.statusPolicy, args.recordType, { status: currentStatus, setBy: "agent" }, args.status);
|
|
684
|
+
if (refusal !== undefined)
|
|
685
|
+
throw new CrmPolicyRefusedError(refusal);
|
|
686
|
+
}
|
|
687
|
+
async assertNotSuppressed(contact) {
|
|
688
|
+
const policy = this.outreach;
|
|
689
|
+
if (policy === undefined)
|
|
690
|
+
return;
|
|
691
|
+
const company = contact.companyExternalId === undefined ? null : await this.readActiveProfile("company", deterministicRecordId("company", contact.companyExternalId));
|
|
692
|
+
const refusal = checkSuppressed(policy.suppressedStatuses, { status: contact.status }, company === null ? undefined : { status: company.status });
|
|
693
|
+
if (refusal !== undefined)
|
|
694
|
+
throw new CrmPolicyRefusedError(refusal);
|
|
695
|
+
}
|
|
696
|
+
async assertDraftAllowed(partition, generation, kind) {
|
|
697
|
+
const policy = this.outreach;
|
|
698
|
+
if (policy === undefined)
|
|
699
|
+
return;
|
|
700
|
+
if (!this.countsOutreach) {
|
|
701
|
+
const touch2 = await this.readOutreachState(partition, generation);
|
|
702
|
+
const uncapped = {
|
|
703
|
+
...policy,
|
|
704
|
+
weeklyLimit: Number.POSITIVE_INFINITY,
|
|
705
|
+
followUpLimit: Number.POSITIVE_INFINITY
|
|
706
|
+
};
|
|
707
|
+
const refusal2 = checkDraft(uncapped, touch2, { firstTouch: 0, followUp: 0 }, kind);
|
|
708
|
+
if (refusal2 !== undefined)
|
|
709
|
+
throw new CrmPolicyRefusedError(refusal2);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const [touch, counters] = await Promise.all([
|
|
713
|
+
this.readOutreachState(partition, generation),
|
|
714
|
+
this.readWeekCounter(outreachWeekStart(this.now(), policy.timezone))
|
|
715
|
+
]);
|
|
716
|
+
const refusal = checkDraft(policy, touch, counters, kind);
|
|
717
|
+
if (refusal !== undefined)
|
|
718
|
+
throw new CrmPolicyRefusedError(refusal);
|
|
719
|
+
}
|
|
720
|
+
async assertDraftedStatusAllowed(args, partition, profile) {
|
|
721
|
+
const policy = this.outreach;
|
|
722
|
+
if (policy === undefined || !this.countsOutreach || !movesToDraftedStatus(policy, args.recordType, profile.status, args.status)) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const touched = await this.readOutreachState(partition, profile.generation) !== null;
|
|
726
|
+
if (touched)
|
|
727
|
+
return;
|
|
728
|
+
const counters = await this.readWeekCounter(outreachWeekStart(this.now(), policy.timezone));
|
|
729
|
+
const refusal = checkDraftedStatus(policy, args.recordType, profile.status, args.status, touched, counters);
|
|
730
|
+
if (refusal !== undefined)
|
|
731
|
+
throw new CrmPolicyRefusedError(refusal);
|
|
732
|
+
}
|
|
733
|
+
outreachWrites(partition, generation, event, occurredAt) {
|
|
734
|
+
const policy = this.outreach;
|
|
735
|
+
if (policy === undefined)
|
|
736
|
+
return [];
|
|
737
|
+
const key = { pk: partition, sk: outreachSk(generation) };
|
|
738
|
+
if (event.type === "sent") {
|
|
739
|
+
const withThread = event.threadId !== undefined;
|
|
740
|
+
return [
|
|
741
|
+
{
|
|
742
|
+
Update: {
|
|
743
|
+
TableName: this.tableName,
|
|
744
|
+
Key: key,
|
|
745
|
+
UpdateExpression: `SET #lastTouchAt = :at, #firstTouchAt = if_not_exists(#firstTouchAt, :at), #followUps = if_not_exists(#followUps, :zero)${withThread ? ", #lastThreadId = :thread" : ""}`,
|
|
746
|
+
ExpressionAttributeNames: {
|
|
747
|
+
"#lastTouchAt": "lastTouchAt",
|
|
748
|
+
"#firstTouchAt": "firstTouchAt",
|
|
749
|
+
"#followUps": "followUps",
|
|
750
|
+
...withThread ? { "#lastThreadId": "lastThreadId" } : {}
|
|
751
|
+
},
|
|
752
|
+
ExpressionAttributeValues: {
|
|
753
|
+
":at": occurredAt,
|
|
754
|
+
":zero": 0,
|
|
755
|
+
...withThread ? { ":thread": event.threadId } : {}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
];
|
|
760
|
+
}
|
|
761
|
+
if (event.type === "reply") {
|
|
762
|
+
return [
|
|
763
|
+
{
|
|
764
|
+
Update: {
|
|
765
|
+
TableName: this.tableName,
|
|
766
|
+
Key: key,
|
|
767
|
+
UpdateExpression: "SET #lastReplyAt = :at, #followUps = if_not_exists(#followUps, :zero)",
|
|
768
|
+
ExpressionAttributeNames: { "#lastReplyAt": "lastReplyAt", "#followUps": "followUps" },
|
|
769
|
+
ExpressionAttributeValues: { ":at": occurredAt, ":zero": 0 }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
];
|
|
773
|
+
}
|
|
774
|
+
const firstTouch = event.kind === "first_touch";
|
|
775
|
+
const counterField = firstTouch ? "firstTouch" : "followUp";
|
|
776
|
+
const counterWrite = {
|
|
777
|
+
Update: {
|
|
778
|
+
TableName: this.tableName,
|
|
779
|
+
Key: {
|
|
780
|
+
pk: this.outreachPartition(),
|
|
781
|
+
sk: outreachWeekSk(outreachWeekStart(this.now(), policy.timezone))
|
|
782
|
+
},
|
|
783
|
+
UpdateExpression: "ADD #count :one",
|
|
784
|
+
ConditionExpression: "attribute_not_exists(#count) OR #count < :limit",
|
|
785
|
+
ExpressionAttributeNames: { "#count": counterField },
|
|
786
|
+
ExpressionAttributeValues: {
|
|
787
|
+
":one": 1,
|
|
788
|
+
":limit": firstTouch ? policy.weeklyLimit : policy.followUpLimit
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const counted = this.countsOutreach ? [counterWrite] : [];
|
|
793
|
+
if (firstTouch) {
|
|
794
|
+
return [
|
|
795
|
+
...counted,
|
|
796
|
+
{
|
|
797
|
+
Put: {
|
|
798
|
+
TableName: this.tableName,
|
|
799
|
+
Item: {
|
|
800
|
+
...key,
|
|
801
|
+
firstTouchAt: occurredAt,
|
|
802
|
+
lastTouchAt: occurredAt,
|
|
803
|
+
followUps: 0,
|
|
804
|
+
...event.threadId === undefined ? {} : { lastThreadId: event.threadId },
|
|
805
|
+
...event.draftId === undefined ? {} : { lastDraftId: event.draftId }
|
|
806
|
+
},
|
|
807
|
+
ConditionExpression: "attribute_not_exists(#pk)",
|
|
808
|
+
ExpressionAttributeNames: { "#pk": "pk" }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
];
|
|
812
|
+
}
|
|
813
|
+
const names = {
|
|
814
|
+
"#pk": "pk",
|
|
815
|
+
"#lastTouchAt": "lastTouchAt",
|
|
816
|
+
"#followUps": "followUps"
|
|
817
|
+
};
|
|
818
|
+
const values = {
|
|
819
|
+
":at": occurredAt,
|
|
820
|
+
":one": 1,
|
|
821
|
+
":max": policy.maxFollowUps
|
|
822
|
+
};
|
|
823
|
+
const sets = ["#lastTouchAt = :at", "#followUps = #followUps + :one"];
|
|
824
|
+
if (event.threadId !== undefined) {
|
|
825
|
+
names["#lastThreadId"] = "lastThreadId";
|
|
826
|
+
values[":thread"] = event.threadId;
|
|
827
|
+
sets.push("#lastThreadId = :thread");
|
|
828
|
+
}
|
|
829
|
+
if (event.draftId !== undefined) {
|
|
830
|
+
names["#lastDraftId"] = "lastDraftId";
|
|
831
|
+
values[":draft"] = event.draftId;
|
|
832
|
+
sets.push("#lastDraftId = :draft");
|
|
833
|
+
}
|
|
834
|
+
return [
|
|
835
|
+
...counted,
|
|
836
|
+
{
|
|
837
|
+
Update: {
|
|
838
|
+
TableName: this.tableName,
|
|
839
|
+
Key: key,
|
|
840
|
+
UpdateExpression: `SET ${sets.join(", ")}`,
|
|
841
|
+
ConditionExpression: "attribute_exists(#pk) AND #followUps < :max",
|
|
842
|
+
ExpressionAttributeNames: names,
|
|
843
|
+
ExpressionAttributeValues: values
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
async readOutreachState(partition, generation) {
|
|
849
|
+
const output = await this.documentClient.send(new GetCommand2({
|
|
850
|
+
TableName: this.tableName,
|
|
851
|
+
Key: { pk: partition, sk: outreachSk(generation) },
|
|
852
|
+
ConsistentRead: true
|
|
853
|
+
}));
|
|
854
|
+
return output.Item === undefined ? null : outreachStateFromItem(output.Item);
|
|
855
|
+
}
|
|
856
|
+
async readWeekCounter(weekStart) {
|
|
857
|
+
const output = await this.documentClient.send(new GetCommand2({
|
|
858
|
+
TableName: this.tableName,
|
|
859
|
+
Key: { pk: this.outreachPartition(), sk: outreachWeekSk(weekStart) },
|
|
860
|
+
ConsistentRead: true
|
|
861
|
+
}));
|
|
862
|
+
return {
|
|
863
|
+
firstTouch: storedCount(output.Item?.firstTouch),
|
|
864
|
+
followUp: storedCount(output.Item?.followUp)
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async mergeProfile(recordType, input, timestamp, targetGeneration) {
|
|
868
|
+
const recordId = deterministicRecordId(recordType, input.externalId);
|
|
869
|
+
const partition = this.recordPartition(recordType, recordId);
|
|
870
|
+
await this.ensureLifecycleGeneration(partition, targetGeneration);
|
|
871
|
+
for (let attempt = 0;attempt < MAX_WRITE_RETRIES; attempt += 1) {
|
|
872
|
+
const stored = await this.readProfile(recordType, recordId);
|
|
873
|
+
const current = stored?.generation === targetGeneration ? stored : null;
|
|
874
|
+
const next = {
|
|
875
|
+
...mergeProfile(current, recordType, recordId, input, timestamp, this.defaultStatuses[recordType], this.actor),
|
|
876
|
+
generation: targetGeneration,
|
|
877
|
+
version: (current?.version ?? 0) + 1
|
|
878
|
+
};
|
|
879
|
+
try {
|
|
880
|
+
await this.writeProfile(next, current);
|
|
881
|
+
return;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
if (!isConditionalFailure(error) || attempt === MAX_WRITE_RETRIES - 1)
|
|
884
|
+
throw error;
|
|
885
|
+
await this.assertActiveGeneration(partition, targetGeneration);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
throw new CrmStorageError;
|
|
889
|
+
}
|
|
890
|
+
async changeProfile(recordType, recordId, timestamp, targetGeneration, change) {
|
|
891
|
+
for (let attempt = 0;attempt < MAX_WRITE_RETRIES; attempt += 1) {
|
|
892
|
+
const current = await this.readActiveProfile(recordType, recordId);
|
|
893
|
+
if (current === null)
|
|
894
|
+
throw new CrmRecordNotFoundError;
|
|
895
|
+
if (current.generation !== targetGeneration)
|
|
896
|
+
throw new CrmStorageError;
|
|
897
|
+
const next = {
|
|
898
|
+
...change(current),
|
|
899
|
+
recordType,
|
|
900
|
+
recordId,
|
|
901
|
+
createdAt: current.createdAt,
|
|
902
|
+
updatedAt: timestamp,
|
|
903
|
+
generation: targetGeneration,
|
|
904
|
+
version: current.version + 1
|
|
905
|
+
};
|
|
906
|
+
try {
|
|
907
|
+
await this.writeProfile(next, current);
|
|
908
|
+
return;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (!isConditionalFailure(error) || attempt === MAX_WRITE_RETRIES - 1)
|
|
911
|
+
throw error;
|
|
912
|
+
await this.assertActiveGeneration(this.recordPartition(recordType, recordId), targetGeneration);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
throw new CrmStorageError;
|
|
916
|
+
}
|
|
917
|
+
async readProfile(recordType, recordId) {
|
|
918
|
+
const output = await this.documentClient.send(new GetCommand2({
|
|
919
|
+
TableName: this.tableName,
|
|
920
|
+
Key: { pk: this.recordPartition(recordType, recordId), sk: PROFILE_SK },
|
|
921
|
+
ConsistentRead: true
|
|
922
|
+
}));
|
|
923
|
+
if (output.Item === undefined)
|
|
924
|
+
return null;
|
|
925
|
+
const profile = profileFromItem(output.Item);
|
|
926
|
+
this.assertProfileStorageKey(output.Item, profile);
|
|
927
|
+
if (profile.recordType !== recordType || profile.recordId !== recordId)
|
|
928
|
+
throw new CrmStorageError;
|
|
929
|
+
return profile;
|
|
930
|
+
}
|
|
931
|
+
async readActiveProfile(recordType, recordId) {
|
|
932
|
+
const partition = this.recordPartition(recordType, recordId);
|
|
933
|
+
const [profile, lifecycle] = await Promise.all([
|
|
934
|
+
this.readProfile(recordType, recordId),
|
|
935
|
+
this.readLifecycle(partition)
|
|
936
|
+
]);
|
|
937
|
+
if (profile === null || lifecycle?.state !== "active" || lifecycle.generation !== profile.generation) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return profile;
|
|
941
|
+
}
|
|
942
|
+
assertProfileStorageKey(item, profile) {
|
|
943
|
+
if (item.pk !== this.recordPartition(profile.recordType, profile.recordId) || item.sk !== PROFILE_SK || item.gsi1pk !== this.recordsPartition() || item.gsi1sk !== `PROFILE#${profile.recordType}#${profile.recordId}`) {
|
|
944
|
+
throw new CrmStorageError;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
profileItem(profile) {
|
|
948
|
+
return {
|
|
949
|
+
pk: this.recordPartition(profile.recordType, profile.recordId),
|
|
950
|
+
sk: PROFILE_SK,
|
|
951
|
+
gsi1pk: this.recordsPartition(),
|
|
952
|
+
gsi1sk: `PROFILE#${profile.recordType}#${profile.recordId}`,
|
|
953
|
+
...profile,
|
|
954
|
+
searchText: normalizedText(profileSearchFields(profile)),
|
|
955
|
+
createdBy: profile.createdBy,
|
|
956
|
+
updatedBy: this.actor
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
async writeProfile(next, previous) {
|
|
960
|
+
const partition = this.recordPartition(next.recordType, next.recordId);
|
|
961
|
+
await this.documentClient.send(new TransactWriteCommand2({
|
|
962
|
+
TransactItems: [
|
|
963
|
+
{
|
|
964
|
+
ConditionCheck: {
|
|
965
|
+
TableName: this.tableName,
|
|
966
|
+
Key: { pk: partition, sk: LIFECYCLE_SK },
|
|
967
|
+
ConditionExpression: "#state = :active AND #generation = :generation",
|
|
968
|
+
ExpressionAttributeNames: { "#state": "state", "#generation": "generation" },
|
|
969
|
+
ExpressionAttributeValues: {
|
|
970
|
+
":active": "active",
|
|
971
|
+
":generation": next.generation
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
Put: {
|
|
977
|
+
TableName: this.tableName,
|
|
978
|
+
Item: this.profileItem(next),
|
|
979
|
+
...previous === null ? {
|
|
980
|
+
ConditionExpression: "attribute_not_exists(#pk) AND attribute_not_exists(#sk)",
|
|
981
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" }
|
|
982
|
+
} : {
|
|
983
|
+
ConditionExpression: "#version = :version",
|
|
984
|
+
ExpressionAttributeNames: { "#version": "version" },
|
|
985
|
+
ExpressionAttributeValues: { ":version": previous.version }
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
]
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
async resolveClaim(operation, idempotencyKey, payload) {
|
|
993
|
+
const key = {
|
|
994
|
+
pk: this.idempotencyPartition(),
|
|
995
|
+
sk: `${operation}#${hashText(idempotencyKey)}`
|
|
996
|
+
};
|
|
997
|
+
const payloadHash = hashPayload(operation, payload);
|
|
998
|
+
const output = await this.documentClient.send(new GetCommand2({ TableName: this.tableName, Key: key, ConsistentRead: true }));
|
|
999
|
+
if (output.Item === undefined)
|
|
1000
|
+
return null;
|
|
1001
|
+
if (output.Item.payloadHash !== payloadHash)
|
|
1002
|
+
throw new CrmIdempotencyConflictError;
|
|
1003
|
+
const claimedAt = output.Item.claimedAt;
|
|
1004
|
+
if (typeof claimedAt !== "string")
|
|
1005
|
+
throw new CrmStorageError;
|
|
1006
|
+
const actor = storedActor(output.Item.actor);
|
|
1007
|
+
if (!sameActor(actor, this.actor))
|
|
1008
|
+
throw new CrmIdempotencyConflictError;
|
|
1009
|
+
const targets = storedTargets(output.Item.targets);
|
|
1010
|
+
if (output.Item.state === "completed") {
|
|
1011
|
+
return {
|
|
1012
|
+
key,
|
|
1013
|
+
payloadHash,
|
|
1014
|
+
claimedAt,
|
|
1015
|
+
actor,
|
|
1016
|
+
targets,
|
|
1017
|
+
replay: completedResult(output.Item, operation)
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
if (output.Item.state === "claimed") {
|
|
1021
|
+
return { key, payloadHash, claimedAt, actor, targets };
|
|
1022
|
+
}
|
|
1023
|
+
throw new CrmStorageError;
|
|
1024
|
+
}
|
|
1025
|
+
async claimWrite(operation, idempotencyKey, payload, targets) {
|
|
1026
|
+
const key = {
|
|
1027
|
+
pk: this.idempotencyPartition(),
|
|
1028
|
+
sk: `${operation}#${hashText(idempotencyKey)}`
|
|
1029
|
+
};
|
|
1030
|
+
const payloadHash = hashPayload(operation, payload);
|
|
1031
|
+
for (let attempt = 0;attempt < MAX_WRITE_RETRIES; attempt += 1) {
|
|
1032
|
+
const existing = await this.resolveClaim(operation, idempotencyKey, payload);
|
|
1033
|
+
if (existing !== null)
|
|
1034
|
+
return existing;
|
|
1035
|
+
const claimedAt = this.nowIso();
|
|
1036
|
+
try {
|
|
1037
|
+
await this.documentClient.send(new PutCommand2({
|
|
1038
|
+
TableName: this.tableName,
|
|
1039
|
+
Item: {
|
|
1040
|
+
...key,
|
|
1041
|
+
payloadHash,
|
|
1042
|
+
state: "claimed",
|
|
1043
|
+
claimedAt,
|
|
1044
|
+
actor: this.actor,
|
|
1045
|
+
targets
|
|
1046
|
+
},
|
|
1047
|
+
ConditionExpression: "attribute_not_exists(#pk) AND attribute_not_exists(#sk)",
|
|
1048
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" }
|
|
1049
|
+
}));
|
|
1050
|
+
return {
|
|
1051
|
+
key,
|
|
1052
|
+
payloadHash,
|
|
1053
|
+
claimedAt,
|
|
1054
|
+
actor: copyActor(this.actor),
|
|
1055
|
+
targets: { ...targets }
|
|
1056
|
+
};
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
if (!isConditionalFailure(error) || attempt === MAX_WRITE_RETRIES - 1)
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
throw new CrmStorageError;
|
|
1063
|
+
}
|
|
1064
|
+
completedClaimItem(claim, result) {
|
|
1065
|
+
return {
|
|
1066
|
+
...claim.key,
|
|
1067
|
+
payloadHash: claim.payloadHash,
|
|
1068
|
+
state: "completed",
|
|
1069
|
+
claimedAt: claim.claimedAt,
|
|
1070
|
+
completedAt: this.nowIso(),
|
|
1071
|
+
actor: claim.actor,
|
|
1072
|
+
targets: { ...claim.targets },
|
|
1073
|
+
result
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
async completeClaim(claim, result) {
|
|
1077
|
+
await this.documentClient.send(new PutCommand2({
|
|
1078
|
+
TableName: this.tableName,
|
|
1079
|
+
Item: this.completedClaimItem(claim, result),
|
|
1080
|
+
ConditionExpression: "#payloadHash = :payloadHash AND #state = :claimed",
|
|
1081
|
+
ExpressionAttributeNames: { "#payloadHash": "payloadHash", "#state": "state" },
|
|
1082
|
+
ExpressionAttributeValues: { ":payloadHash": claim.payloadHash, ":claimed": "claimed" }
|
|
1083
|
+
}));
|
|
1084
|
+
}
|
|
1085
|
+
async readLifecycle(partition) {
|
|
1086
|
+
const output = await this.documentClient.send(new GetCommand2({
|
|
1087
|
+
TableName: this.tableName,
|
|
1088
|
+
Key: { pk: partition, sk: LIFECYCLE_SK },
|
|
1089
|
+
ConsistentRead: true
|
|
1090
|
+
}));
|
|
1091
|
+
if (output.Item === undefined)
|
|
1092
|
+
return null;
|
|
1093
|
+
const { generation, state, deletedByClaim } = output.Item;
|
|
1094
|
+
if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 1 || state !== "active" && state !== "deleted" || deletedByClaim !== undefined && typeof deletedByClaim !== "string") {
|
|
1095
|
+
throw new CrmStorageError;
|
|
1096
|
+
}
|
|
1097
|
+
return {
|
|
1098
|
+
generation,
|
|
1099
|
+
state,
|
|
1100
|
+
...deletedByClaim === undefined ? {} : { deletedByClaim }
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
async upsertTargets(companies, contacts) {
|
|
1104
|
+
const partitions = new Set;
|
|
1105
|
+
for (const company of companies) {
|
|
1106
|
+
partitions.add(this.recordPartition("company", deterministicRecordId("company", company.externalId)));
|
|
1107
|
+
}
|
|
1108
|
+
for (const contact of contacts) {
|
|
1109
|
+
partitions.add(this.recordPartition("contact", deterministicRecordId("contact", contact.externalId)));
|
|
1110
|
+
}
|
|
1111
|
+
const targets = {};
|
|
1112
|
+
await Promise.all([...partitions].map(async (partition) => {
|
|
1113
|
+
const lifecycle = await this.readLifecycle(partition);
|
|
1114
|
+
targets[partition] = lifecycle?.state === "active" ? lifecycle.generation : (lifecycle?.generation ?? 0) + 1;
|
|
1115
|
+
}));
|
|
1116
|
+
return targets;
|
|
1117
|
+
}
|
|
1118
|
+
async assertActiveGeneration(partition, generation) {
|
|
1119
|
+
const lifecycle = await this.readLifecycle(partition);
|
|
1120
|
+
if (lifecycle?.state !== "active" || lifecycle.generation !== generation) {
|
|
1121
|
+
throw new CrmStorageError;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async ensureLifecycleGeneration(partition, targetGeneration) {
|
|
1125
|
+
for (let attempt = 0;attempt < MAX_WRITE_RETRIES; attempt += 1) {
|
|
1126
|
+
const current = await this.readLifecycle(partition);
|
|
1127
|
+
if (current?.state === "active" && current.generation === targetGeneration)
|
|
1128
|
+
return;
|
|
1129
|
+
const mayActivate = current === null && targetGeneration === 1 || current?.state === "deleted" && current.generation + 1 === targetGeneration;
|
|
1130
|
+
if (!mayActivate)
|
|
1131
|
+
throw new CrmStorageError;
|
|
1132
|
+
try {
|
|
1133
|
+
await this.documentClient.send(new PutCommand2({
|
|
1134
|
+
TableName: this.tableName,
|
|
1135
|
+
Item: {
|
|
1136
|
+
pk: partition,
|
|
1137
|
+
sk: LIFECYCLE_SK,
|
|
1138
|
+
state: "active",
|
|
1139
|
+
generation: targetGeneration
|
|
1140
|
+
},
|
|
1141
|
+
...current === null ? {
|
|
1142
|
+
ConditionExpression: "attribute_not_exists(#pk) AND attribute_not_exists(#sk)",
|
|
1143
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" }
|
|
1144
|
+
} : {
|
|
1145
|
+
ConditionExpression: "#state = :deleted AND #generation = :previous",
|
|
1146
|
+
ExpressionAttributeNames: { "#state": "state", "#generation": "generation" },
|
|
1147
|
+
ExpressionAttributeValues: {
|
|
1148
|
+
":deleted": "deleted",
|
|
1149
|
+
":previous": current.generation
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}));
|
|
1153
|
+
return;
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
if (!isConditionalFailure(error) || attempt === MAX_WRITE_RETRIES - 1)
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
throw new CrmStorageError;
|
|
1160
|
+
}
|
|
1161
|
+
async fenceDelete(partition, claim, result, targetGeneration) {
|
|
1162
|
+
for (let attempt = 0;attempt < MAX_WRITE_RETRIES; attempt += 1) {
|
|
1163
|
+
const lifecycle = await this.readLifecycle(partition);
|
|
1164
|
+
if (targetGeneration === undefined) {
|
|
1165
|
+
await this.completeClaim(claim, result);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (lifecycle?.state === "deleted" && lifecycle.generation === targetGeneration) {
|
|
1169
|
+
await this.completeClaim(claim, result);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (lifecycle?.state !== "active" || lifecycle.generation !== targetGeneration) {
|
|
1173
|
+
throw new CrmStorageError;
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
await this.documentClient.send(new TransactWriteCommand2({
|
|
1177
|
+
TransactItems: [
|
|
1178
|
+
{
|
|
1179
|
+
Put: {
|
|
1180
|
+
TableName: this.tableName,
|
|
1181
|
+
Item: {
|
|
1182
|
+
pk: partition,
|
|
1183
|
+
sk: LIFECYCLE_SK,
|
|
1184
|
+
state: "deleted",
|
|
1185
|
+
generation: targetGeneration,
|
|
1186
|
+
deletedByClaim: claim.key.sk
|
|
1187
|
+
},
|
|
1188
|
+
ConditionExpression: "#state = :active AND #generation = :generation",
|
|
1189
|
+
ExpressionAttributeNames: { "#state": "state", "#generation": "generation" },
|
|
1190
|
+
ExpressionAttributeValues: {
|
|
1191
|
+
":active": "active",
|
|
1192
|
+
":generation": targetGeneration
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
Delete: {
|
|
1198
|
+
TableName: this.tableName,
|
|
1199
|
+
Key: { pk: partition, sk: PROFILE_SK }
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
Delete: {
|
|
1204
|
+
TableName: this.tableName,
|
|
1205
|
+
Key: { pk: partition, sk: outreachSk(targetGeneration) }
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
Put: {
|
|
1210
|
+
TableName: this.tableName,
|
|
1211
|
+
Item: this.completedClaimItem(claim, result),
|
|
1212
|
+
ConditionExpression: "#payloadHash = :payloadHash AND #state = :claimed",
|
|
1213
|
+
ExpressionAttributeNames: { "#payloadHash": "payloadHash", "#state": "state" },
|
|
1214
|
+
ExpressionAttributeValues: {
|
|
1215
|
+
":payloadHash": claim.payloadHash,
|
|
1216
|
+
":claimed": "claimed"
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
]
|
|
1221
|
+
}));
|
|
1222
|
+
return;
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
if (!isConditionalFailure(error) || attempt === MAX_WRITE_RETRIES - 1)
|
|
1225
|
+
throw error;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
throw new CrmStorageError;
|
|
1229
|
+
}
|
|
1230
|
+
async deleteActivityGeneration(partition, generation) {
|
|
1231
|
+
let exclusiveStartKey;
|
|
1232
|
+
for (let page = 0;page < MAX_ACTIVITY_DELETE_PAGES; page += 1) {
|
|
1233
|
+
const output = await this.documentClient.send(new QueryCommand({
|
|
1234
|
+
TableName: this.tableName,
|
|
1235
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :activityPrefix)",
|
|
1236
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" },
|
|
1237
|
+
ExpressionAttributeValues: {
|
|
1238
|
+
":pk": partition,
|
|
1239
|
+
":activityPrefix": activityPrefix(generation)
|
|
1240
|
+
},
|
|
1241
|
+
ConsistentRead: true,
|
|
1242
|
+
Limit: ACTIVITY_DELETE_PAGE_SIZE,
|
|
1243
|
+
...exclusiveStartKey === undefined ? {} : { ExclusiveStartKey: exclusiveStartKey }
|
|
1244
|
+
}));
|
|
1245
|
+
await this.batchDelete(output.Items ?? []);
|
|
1246
|
+
exclusiveStartKey = output.LastEvaluatedKey;
|
|
1247
|
+
if (exclusiveStartKey === undefined)
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
throw new CrmStorageError;
|
|
1251
|
+
}
|
|
1252
|
+
async batchDelete(items) {
|
|
1253
|
+
for (let offset = 0;offset < items.length; offset += BATCH_WRITE_LIMIT) {
|
|
1254
|
+
let requests = items.slice(offset, offset + BATCH_WRITE_LIMIT).map((item) => ({
|
|
1255
|
+
DeleteRequest: { Key: { pk: item.pk, sk: item.sk } }
|
|
1256
|
+
}));
|
|
1257
|
+
for (let attempt = 0;requests.length > 0 && attempt < MAX_BATCH_WRITE_RETRIES; attempt += 1) {
|
|
1258
|
+
const output = await this.documentClient.send(new BatchWriteCommand({ RequestItems: { [this.tableName]: requests } }));
|
|
1259
|
+
requests = output.UnprocessedItems?.[this.tableName] ?? [];
|
|
1260
|
+
}
|
|
1261
|
+
if (requests.length > 0)
|
|
1262
|
+
throw new CrmStorageError;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
recordsPartition() {
|
|
1266
|
+
return `TENANT#${this.tenantId}#RECORDS`;
|
|
1267
|
+
}
|
|
1268
|
+
recordPartition(recordType, recordId) {
|
|
1269
|
+
return `TENANT#${this.tenantId}#RECORD#${recordType}#${recordId}`;
|
|
1270
|
+
}
|
|
1271
|
+
idempotencyPartition() {
|
|
1272
|
+
return `TENANT#${this.tenantId}#IDEMPOTENCY`;
|
|
1273
|
+
}
|
|
1274
|
+
outreachPartition() {
|
|
1275
|
+
return outreachLedgerPartition(this.tenantId);
|
|
1276
|
+
}
|
|
1277
|
+
nowIso() {
|
|
1278
|
+
const value = this.now();
|
|
1279
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime()))
|
|
1280
|
+
throw new CrmStorageError;
|
|
1281
|
+
return value.toISOString();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
function createDynamoCrmClient(options) {
|
|
1285
|
+
return new DynamoCrmClient(options);
|
|
1286
|
+
}
|
|
1287
|
+
function outreachSk(generation) {
|
|
1288
|
+
return `${OUTREACH_SK_PREFIX}${String(generation).padStart(12, "0")}`;
|
|
1289
|
+
}
|
|
1290
|
+
function itemKey(pk, sk) {
|
|
1291
|
+
return `${pk}\x00${sk}`;
|
|
1292
|
+
}
|
|
1293
|
+
function outreachStateFromItem(item) {
|
|
1294
|
+
const state = { followUps: storedCount(item.followUps) };
|
|
1295
|
+
for (const field of [
|
|
1296
|
+
"firstTouchAt",
|
|
1297
|
+
"lastTouchAt",
|
|
1298
|
+
"lastReplyAt",
|
|
1299
|
+
"lastThreadId",
|
|
1300
|
+
"lastDraftId"
|
|
1301
|
+
]) {
|
|
1302
|
+
const value = item[field];
|
|
1303
|
+
if (value === undefined)
|
|
1304
|
+
continue;
|
|
1305
|
+
if (typeof value !== "string")
|
|
1306
|
+
throw new CrmStorageError;
|
|
1307
|
+
state[field] = value;
|
|
1308
|
+
}
|
|
1309
|
+
return state;
|
|
1310
|
+
}
|
|
1311
|
+
function storedCount(value) {
|
|
1312
|
+
if (value === undefined)
|
|
1313
|
+
return 0;
|
|
1314
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
1315
|
+
throw new CrmStorageError;
|
|
1316
|
+
}
|
|
1317
|
+
return value;
|
|
1318
|
+
}
|
|
1319
|
+
function rankOf(profile, companies, attribute) {
|
|
1320
|
+
const own = profile.attributes?.[attribute];
|
|
1321
|
+
if (typeof own === "number" && Number.isFinite(own))
|
|
1322
|
+
return own;
|
|
1323
|
+
const company = profile.companyExternalId === undefined ? undefined : companies.get(profile.companyExternalId)?.attributes?.[attribute];
|
|
1324
|
+
return typeof company === "number" && Number.isFinite(company) ? company : undefined;
|
|
1325
|
+
}
|
|
1326
|
+
function outreachCandidate(profile, companies, touch, rankAttribute) {
|
|
1327
|
+
const companyProfile = profile.companyExternalId === undefined ? undefined : companies.get(profile.companyExternalId);
|
|
1328
|
+
const rank = rankOf(profile, companies, rankAttribute);
|
|
1329
|
+
return {
|
|
1330
|
+
contactId: profile.recordId,
|
|
1331
|
+
email: profile.email,
|
|
1332
|
+
status: profile.status,
|
|
1333
|
+
createdAt: profile.createdAt,
|
|
1334
|
+
...profile.firstName === undefined ? {} : { firstName: profile.firstName },
|
|
1335
|
+
...profile.lastName === undefined ? {} : { lastName: profile.lastName },
|
|
1336
|
+
...profile.title === undefined ? {} : { title: profile.title },
|
|
1337
|
+
...profile.companyExternalId === undefined ? {} : { companyExternalId: profile.companyExternalId, organization: profile.companyExternalId },
|
|
1338
|
+
...companyProfile === undefined ? {} : {
|
|
1339
|
+
companyId: companyProfile.recordId,
|
|
1340
|
+
companyStatus: companyProfile.status,
|
|
1341
|
+
...companyProfile.name === undefined ? {} : { company: companyProfile.name }
|
|
1342
|
+
},
|
|
1343
|
+
...rank === undefined ? {} : { rank },
|
|
1344
|
+
...touch === undefined ? {} : { touch }
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function storedTargets(value) {
|
|
1348
|
+
if (value === undefined)
|
|
1349
|
+
return {};
|
|
1350
|
+
if (!isObject(value) || Object.keys(value).length > 100)
|
|
1351
|
+
throw new CrmStorageError;
|
|
1352
|
+
const targets = {};
|
|
1353
|
+
for (const [partition, generation] of Object.entries(value)) {
|
|
1354
|
+
targets[partition] = storedPositiveInteger(generation);
|
|
1355
|
+
}
|
|
1356
|
+
return targets;
|
|
1357
|
+
}
|
|
1358
|
+
function requiredTarget(claim, partition) {
|
|
1359
|
+
const generation = claim.targets[partition];
|
|
1360
|
+
if (generation === undefined)
|
|
1361
|
+
throw new CrmStorageError;
|
|
1362
|
+
return generation;
|
|
1363
|
+
}
|
|
1364
|
+
function deterministicRecordId(recordType, externalId) {
|
|
1365
|
+
return `${recordType}_${hashText(`${recordType}\x00${externalId}`)}`;
|
|
1366
|
+
}
|
|
1367
|
+
function deterministicActivityId(partition, idempotencySk) {
|
|
1368
|
+
return `activity_${hashText(`${partition}\x00${idempotencySk}`)}`;
|
|
1369
|
+
}
|
|
1370
|
+
function activityPrefix(generation) {
|
|
1371
|
+
return `${ACTIVITY_SK_PREFIX}${String(generation).padStart(12, "0")}#`;
|
|
1372
|
+
}
|
|
1373
|
+
function mergeProfile(current, recordType, recordId, incoming, timestamp, defaultStatus, actor) {
|
|
1374
|
+
const fields = recordType === "company" ? ["name", "domain", "website", "source"] : ["email", "firstName", "lastName", "title", "companyExternalId", "source"];
|
|
1375
|
+
const result = {
|
|
1376
|
+
recordType,
|
|
1377
|
+
recordId,
|
|
1378
|
+
externalId: incoming.externalId,
|
|
1379
|
+
status: current?.status ?? defaultStatus,
|
|
1380
|
+
createdAt: current?.createdAt ?? timestamp,
|
|
1381
|
+
updatedAt: timestamp,
|
|
1382
|
+
createdBy: current?.createdBy ?? copyActor(actor),
|
|
1383
|
+
generation: current?.generation ?? 1,
|
|
1384
|
+
version: (current?.version ?? 0) + 1
|
|
1385
|
+
};
|
|
1386
|
+
for (const field of fields) {
|
|
1387
|
+
const previous = current?.[field];
|
|
1388
|
+
const next = incoming[field];
|
|
1389
|
+
if (typeof next === "string") {
|
|
1390
|
+
result[field] = next;
|
|
1391
|
+
} else if (typeof previous === "string") {
|
|
1392
|
+
result[field] = previous;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const incomingAttributes = incoming.attributes;
|
|
1396
|
+
if (incomingAttributes !== undefined) {
|
|
1397
|
+
result.attributes = copyAttributes(incomingAttributes);
|
|
1398
|
+
} else if (current?.attributes !== undefined) {
|
|
1399
|
+
result.attributes = copyAttributes(current.attributes);
|
|
1400
|
+
}
|
|
1401
|
+
return result;
|
|
1402
|
+
}
|
|
1403
|
+
function storedPositiveInteger(value) {
|
|
1404
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
1405
|
+
throw new CrmStorageError;
|
|
1406
|
+
}
|
|
1407
|
+
return value;
|
|
1408
|
+
}
|
|
1409
|
+
function profileFromItem(item) {
|
|
1410
|
+
const recordType = item.recordType;
|
|
1411
|
+
if (recordType !== "company" && recordType !== "contact")
|
|
1412
|
+
throw new CrmStorageError;
|
|
1413
|
+
const required = ["recordId", "externalId", "status", "createdAt", "updatedAt"];
|
|
1414
|
+
for (const key of required) {
|
|
1415
|
+
if (typeof item[key] !== "string")
|
|
1416
|
+
throw new CrmStorageError;
|
|
1417
|
+
}
|
|
1418
|
+
const version = storedPositiveInteger(item.version);
|
|
1419
|
+
const generation = storedPositiveInteger(item.generation);
|
|
1420
|
+
const profile = {
|
|
1421
|
+
recordType,
|
|
1422
|
+
recordId: item.recordId,
|
|
1423
|
+
externalId: item.externalId,
|
|
1424
|
+
status: item.status,
|
|
1425
|
+
createdAt: item.createdAt,
|
|
1426
|
+
updatedAt: item.updatedAt,
|
|
1427
|
+
createdBy: storedActor(item.createdBy),
|
|
1428
|
+
generation,
|
|
1429
|
+
version
|
|
1430
|
+
};
|
|
1431
|
+
for (const field of [
|
|
1432
|
+
"name",
|
|
1433
|
+
"domain",
|
|
1434
|
+
"website",
|
|
1435
|
+
"source",
|
|
1436
|
+
"email",
|
|
1437
|
+
"firstName",
|
|
1438
|
+
"lastName",
|
|
1439
|
+
"title",
|
|
1440
|
+
"companyExternalId"
|
|
1441
|
+
]) {
|
|
1442
|
+
if (typeof item[field] === "string")
|
|
1443
|
+
profile[field] = item[field];
|
|
1444
|
+
}
|
|
1445
|
+
const attributes = storedAttributes(item.attributes);
|
|
1446
|
+
if (attributes !== undefined)
|
|
1447
|
+
profile.attributes = attributes;
|
|
1448
|
+
return profile;
|
|
1449
|
+
}
|
|
1450
|
+
function searchRecord(profile) {
|
|
1451
|
+
const {
|
|
1452
|
+
attributes: _attributes,
|
|
1453
|
+
createdAt: _createdAt,
|
|
1454
|
+
updatedAt: _updatedAt,
|
|
1455
|
+
...result
|
|
1456
|
+
} = profileRecord(profile);
|
|
1457
|
+
return result;
|
|
1458
|
+
}
|
|
1459
|
+
function profileRecord(profile) {
|
|
1460
|
+
const result = {
|
|
1461
|
+
recordType: profile.recordType,
|
|
1462
|
+
id: profile.recordId,
|
|
1463
|
+
externalId: profile.externalId,
|
|
1464
|
+
status: profile.status,
|
|
1465
|
+
createdAt: profile.createdAt,
|
|
1466
|
+
updatedAt: profile.updatedAt
|
|
1467
|
+
};
|
|
1468
|
+
for (const field of [
|
|
1469
|
+
"name",
|
|
1470
|
+
"domain",
|
|
1471
|
+
"website",
|
|
1472
|
+
"source",
|
|
1473
|
+
"email",
|
|
1474
|
+
"firstName",
|
|
1475
|
+
"lastName",
|
|
1476
|
+
"title",
|
|
1477
|
+
"companyExternalId"
|
|
1478
|
+
]) {
|
|
1479
|
+
const value = profile[field];
|
|
1480
|
+
if (value !== undefined)
|
|
1481
|
+
result[field] = value;
|
|
1482
|
+
}
|
|
1483
|
+
if (profile.attributes !== undefined)
|
|
1484
|
+
result.attributes = copyAttributes(profile.attributes);
|
|
1485
|
+
return result;
|
|
1486
|
+
}
|
|
1487
|
+
function activityFromItem(item) {
|
|
1488
|
+
const required = ["activityId", "activityType", "summary", "occurredAt", "createdAt"];
|
|
1489
|
+
for (const key of required) {
|
|
1490
|
+
if (typeof item[key] !== "string")
|
|
1491
|
+
throw new CrmStorageError;
|
|
1492
|
+
}
|
|
1493
|
+
const activity = {
|
|
1494
|
+
id: item.activityId,
|
|
1495
|
+
activityType: item.activityType,
|
|
1496
|
+
summary: item.summary,
|
|
1497
|
+
occurredAt: item.occurredAt,
|
|
1498
|
+
createdAt: item.createdAt
|
|
1499
|
+
};
|
|
1500
|
+
const attributes = storedAttributes(item.attributes);
|
|
1501
|
+
if (attributes !== undefined)
|
|
1502
|
+
activity.attributes = attributes;
|
|
1503
|
+
if (item.version !== undefined)
|
|
1504
|
+
activity.version = storedPositiveInteger(item.version);
|
|
1505
|
+
return activity;
|
|
1506
|
+
}
|
|
1507
|
+
function activityVersion(item) {
|
|
1508
|
+
return item.version === undefined ? 1 : storedPositiveInteger(item.version);
|
|
1509
|
+
}
|
|
1510
|
+
function assertNoteEditable(item, version) {
|
|
1511
|
+
if (item === null)
|
|
1512
|
+
throw new CrmRecordNotFoundError;
|
|
1513
|
+
const refusal = checkNoteEdit(activityFromItem(item), "agent");
|
|
1514
|
+
if (refusal !== undefined)
|
|
1515
|
+
throw new CrmPolicyRefusedError(refusal);
|
|
1516
|
+
if (activityVersion(item) !== version)
|
|
1517
|
+
throw new CrmStaleWriteError("stale_version");
|
|
1518
|
+
}
|
|
1519
|
+
function storedNoteHistory(value) {
|
|
1520
|
+
if (value === undefined)
|
|
1521
|
+
return [];
|
|
1522
|
+
if (!Array.isArray(value) || !value.every(isObject))
|
|
1523
|
+
throw new CrmStorageError;
|
|
1524
|
+
return value;
|
|
1525
|
+
}
|
|
1526
|
+
function profileSearchFields(profile) {
|
|
1527
|
+
const values = [
|
|
1528
|
+
profile.recordType,
|
|
1529
|
+
profile.externalId,
|
|
1530
|
+
profile.status,
|
|
1531
|
+
profile.name,
|
|
1532
|
+
profile.domain,
|
|
1533
|
+
profile.website,
|
|
1534
|
+
profile.source,
|
|
1535
|
+
profile.email,
|
|
1536
|
+
profile.firstName,
|
|
1537
|
+
profile.lastName,
|
|
1538
|
+
profile.title,
|
|
1539
|
+
profile.companyExternalId
|
|
1540
|
+
];
|
|
1541
|
+
return values.filter((value) => typeof value === "string");
|
|
1542
|
+
}
|
|
1543
|
+
function indexCandidate(item, partition) {
|
|
1544
|
+
const key = recordCursorKey(recordsCursorProjection(item), partition);
|
|
1545
|
+
if (typeof key.gsi1sk !== "string")
|
|
1546
|
+
throw new CrmStorageError;
|
|
1547
|
+
const match = /^PROFILE#(company|contact)#(.+)$/.exec(key.gsi1sk);
|
|
1548
|
+
const recordType = item.recordType;
|
|
1549
|
+
const recordId = item.recordId;
|
|
1550
|
+
if (match === null || recordType !== "company" && recordType !== "contact" || typeof recordId !== "string" || match[1] !== recordType || match[2] !== recordId) {
|
|
1551
|
+
throw new CrmStorageError;
|
|
1552
|
+
}
|
|
1553
|
+
return { recordType, recordId };
|
|
1554
|
+
}
|
|
1555
|
+
function profileMatchesSearch(profile, args, searchText) {
|
|
1556
|
+
if (searchText !== undefined && searchText !== "" && !normalizedText(profileSearchFields(profile)).includes(searchText)) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
if (args.statuses !== undefined && !args.statuses.includes(profile.status))
|
|
1560
|
+
return false;
|
|
1561
|
+
if (args.hasEmail !== undefined && args.hasEmail !== (profile.email !== undefined))
|
|
1562
|
+
return false;
|
|
1563
|
+
return true;
|
|
1564
|
+
}
|
|
1565
|
+
function normalizedText(values) {
|
|
1566
|
+
return values.join(" ").normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
1567
|
+
}
|
|
1568
|
+
function boundedSearchResponse(items, records, finalCursor, partition) {
|
|
1569
|
+
const minimum = records.length === 0 ? 0 : 1;
|
|
1570
|
+
for (let count = records.length;count >= minimum; count -= 1) {
|
|
1571
|
+
const cursor = count < records.length ? encodeRecordsCursor(recordsCursorProjection(items[count - 1]), partition) : finalCursor;
|
|
1572
|
+
const candidate = {
|
|
1573
|
+
records: records.slice(0, count),
|
|
1574
|
+
...cursor === undefined ? {} : { cursor }
|
|
1575
|
+
};
|
|
1576
|
+
if (crmResponseFitsBudget(candidate)) {
|
|
1577
|
+
return parseCrmSearchResponse(candidate);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
throw new CrmStorageError;
|
|
1581
|
+
}
|
|
1582
|
+
function boundedGetResponse(profile, items, activities, finalCursor, partition, prefix) {
|
|
1583
|
+
const minimum = activities.length === 0 ? 0 : 1;
|
|
1584
|
+
for (let count = activities.length;count >= minimum; count -= 1) {
|
|
1585
|
+
const cursor = count < activities.length ? encodeActivityCursor(activityCursorProjection(items[count - 1]), partition, prefix) : finalCursor;
|
|
1586
|
+
const candidate = {
|
|
1587
|
+
profile,
|
|
1588
|
+
activities: activities.slice(0, count),
|
|
1589
|
+
...cursor === undefined ? {} : { cursor }
|
|
1590
|
+
};
|
|
1591
|
+
if (crmResponseFitsBudget(candidate)) {
|
|
1592
|
+
return parseCrmGetRecordResponse(candidate);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
throw new CrmStorageError;
|
|
1596
|
+
}
|
|
1597
|
+
function boundedLimit(value, maximum, field, defaultValue = maximum) {
|
|
1598
|
+
const result = value ?? defaultValue;
|
|
1599
|
+
if (!Number.isInteger(result) || result < 1 || result > maximum) {
|
|
1600
|
+
throw new CrmInputError(`${field} must be an integer between 1 and ${maximum}`);
|
|
1601
|
+
}
|
|
1602
|
+
return result;
|
|
1603
|
+
}
|
|
1604
|
+
function sameActor(left, right) {
|
|
1605
|
+
return left.teamId === right.teamId && left.userId === right.userId && left.channelId === right.channelId && left.sessionKey === right.sessionKey;
|
|
1606
|
+
}
|
|
1607
|
+
function copyActor(actor) {
|
|
1608
|
+
return {
|
|
1609
|
+
teamId: actor.teamId,
|
|
1610
|
+
userId: actor.userId,
|
|
1611
|
+
channelId: actor.channelId,
|
|
1612
|
+
sessionKey: actor.sessionKey
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
function storedActor(value) {
|
|
1616
|
+
if (!isObject(value))
|
|
1617
|
+
throw new CrmStorageError;
|
|
1618
|
+
for (const field of ["teamId", "userId", "channelId", "sessionKey"]) {
|
|
1619
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
1620
|
+
throw new CrmStorageError;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return {
|
|
1624
|
+
teamId: value.teamId,
|
|
1625
|
+
userId: value.userId,
|
|
1626
|
+
channelId: value.channelId,
|
|
1627
|
+
sessionKey: value.sessionKey
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
function copyAttributes(attributes) {
|
|
1631
|
+
const output = {};
|
|
1632
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
1633
|
+
Object.defineProperty(output, key, {
|
|
1634
|
+
value,
|
|
1635
|
+
enumerable: true,
|
|
1636
|
+
configurable: false,
|
|
1637
|
+
writable: false
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
return output;
|
|
1641
|
+
}
|
|
1642
|
+
function storedAttributes(value) {
|
|
1643
|
+
if (!isObject(value))
|
|
1644
|
+
return;
|
|
1645
|
+
const output = {};
|
|
1646
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
1647
|
+
if (candidate !== null && typeof candidate !== "string" && typeof candidate !== "number" && typeof candidate !== "boolean") {
|
|
1648
|
+
throw new CrmStorageError;
|
|
1649
|
+
}
|
|
1650
|
+
Object.defineProperty(output, key, {
|
|
1651
|
+
value: candidate,
|
|
1652
|
+
enumerable: true,
|
|
1653
|
+
configurable: false,
|
|
1654
|
+
writable: false
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
return output;
|
|
1658
|
+
}
|
|
1659
|
+
function completedResult(item, operation) {
|
|
1660
|
+
const result = parseCrmOperationAck(item.result);
|
|
1661
|
+
if (result.operation !== operation)
|
|
1662
|
+
throw new CrmStorageError;
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
function recordsCursorProjection(item) {
|
|
1666
|
+
return { pk: item.pk, sk: item.sk, gsi1pk: item.gsi1pk, gsi1sk: item.gsi1sk };
|
|
1667
|
+
}
|
|
1668
|
+
function activityCursorProjection(item) {
|
|
1669
|
+
return { pk: item.pk, sk: item.sk };
|
|
1670
|
+
}
|
|
1671
|
+
function encodeRecordsCursor(key, partition) {
|
|
1672
|
+
const valid = recordCursorKey(key, partition);
|
|
1673
|
+
return encodeCursor("records", valid);
|
|
1674
|
+
}
|
|
1675
|
+
function decodeRecordsCursor(cursor, partition) {
|
|
1676
|
+
return recordCursorKey(decodeCursor(cursor, "records"), partition);
|
|
1677
|
+
}
|
|
1678
|
+
function recordCursorKey(key, partition) {
|
|
1679
|
+
if (!hasExactKeys(key, ["pk", "sk", "gsi1pk", "gsi1sk"]))
|
|
1680
|
+
throw new CrmInputError("cursor is invalid");
|
|
1681
|
+
if (typeof key.pk !== "string" || typeof key.sk !== "string" || typeof key.gsi1pk !== "string" || typeof key.gsi1sk !== "string" || key.gsi1pk !== partition || key.sk !== PROFILE_SK) {
|
|
1682
|
+
throw new CrmInputError("cursor is invalid");
|
|
1683
|
+
}
|
|
1684
|
+
const match = /^PROFILE#(company|contact)#(.+)$/.exec(key.gsi1sk);
|
|
1685
|
+
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
1686
|
+
throw new CrmInputError("cursor is invalid");
|
|
1687
|
+
}
|
|
1688
|
+
if (key.pk !== `${partition.replace(/#RECORDS$/, "")}#RECORD#${match[1]}#${match[2]}`) {
|
|
1689
|
+
throw new CrmInputError("cursor is invalid");
|
|
1690
|
+
}
|
|
1691
|
+
return { pk: key.pk, sk: key.sk, gsi1pk: key.gsi1pk, gsi1sk: key.gsi1sk };
|
|
1692
|
+
}
|
|
1693
|
+
function encodeActivityCursor(key, partition, prefix) {
|
|
1694
|
+
const valid = activityCursorKey(key, partition, prefix);
|
|
1695
|
+
return encodeCursor("activities", valid);
|
|
1696
|
+
}
|
|
1697
|
+
function decodeActivityCursor(cursor, partition, prefix) {
|
|
1698
|
+
return activityCursorKey(decodeCursor(cursor, "activities"), partition, prefix);
|
|
1699
|
+
}
|
|
1700
|
+
function activityCursorKey(key, partition, prefix) {
|
|
1701
|
+
if (!hasExactKeys(key, ["pk", "sk"]))
|
|
1702
|
+
throw new CrmInputError("cursor is invalid");
|
|
1703
|
+
if (typeof key.pk !== "string" || typeof key.sk !== "string" || key.pk !== partition || !key.sk.startsWith(prefix)) {
|
|
1704
|
+
throw new CrmInputError("cursor is invalid");
|
|
1705
|
+
}
|
|
1706
|
+
return { pk: key.pk, sk: key.sk };
|
|
1707
|
+
}
|
|
1708
|
+
function encodeCursor(scope, key) {
|
|
1709
|
+
const encoded = Buffer.from(JSON.stringify({ v: 1, scope, key })).toString("base64url");
|
|
1710
|
+
if (encoded.length > CRM_MAX_CURSOR_CHARS)
|
|
1711
|
+
throw new CrmStorageError;
|
|
1712
|
+
return encoded;
|
|
1713
|
+
}
|
|
1714
|
+
function decodeCursor(cursor, scope) {
|
|
1715
|
+
if (typeof cursor !== "string" || cursor.length === 0 || cursor.length > CRM_MAX_CURSOR_CHARS || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
|
|
1716
|
+
throw new CrmInputError("cursor is invalid");
|
|
1717
|
+
}
|
|
1718
|
+
let parsed;
|
|
1719
|
+
try {
|
|
1720
|
+
const decoded = Buffer.from(cursor, "base64url");
|
|
1721
|
+
if (decoded.length === 0 || decoded.length > CRM_MAX_CURSOR_CHARS) {
|
|
1722
|
+
throw new CrmInputError("cursor is invalid");
|
|
1723
|
+
}
|
|
1724
|
+
if (decoded.toString("base64url") !== cursor)
|
|
1725
|
+
throw new CrmInputError("cursor is invalid");
|
|
1726
|
+
parsed = JSON.parse(decoded.toString("utf8"));
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
if (error instanceof CrmInputError)
|
|
1729
|
+
throw error;
|
|
1730
|
+
throw new CrmInputError("cursor is invalid");
|
|
1731
|
+
}
|
|
1732
|
+
if (!isObject(parsed) || !hasExactKeys(parsed, ["v", "scope", "key"])) {
|
|
1733
|
+
throw new CrmInputError("cursor is invalid");
|
|
1734
|
+
}
|
|
1735
|
+
if (parsed.v !== 1 || parsed.scope !== scope || !isObject(parsed.key)) {
|
|
1736
|
+
throw new CrmInputError("cursor is invalid");
|
|
1737
|
+
}
|
|
1738
|
+
return Object.fromEntries(Object.entries(parsed.key));
|
|
1739
|
+
}
|
|
1740
|
+
function hasExactKeys(value, expected) {
|
|
1741
|
+
const keys = Object.keys(value).sort();
|
|
1742
|
+
if (keys.length !== expected.length)
|
|
1743
|
+
return false;
|
|
1744
|
+
const sortedExpected = [...expected].sort();
|
|
1745
|
+
return keys.every((key, index) => key === sortedExpected[index]);
|
|
1746
|
+
}
|
|
1747
|
+
function isObject(value) {
|
|
1748
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1749
|
+
}
|
|
1750
|
+
function isConditionalFailure(error) {
|
|
1751
|
+
if (typeof error !== "object" || error === null || !("name" in error))
|
|
1752
|
+
return false;
|
|
1753
|
+
const name = error.name;
|
|
1754
|
+
return name === "ConditionalCheckFailedException" || name === "TransactionCanceledException";
|
|
1755
|
+
}
|
|
1756
|
+
export {
|
|
1757
|
+
CrmIdempotencyConflictError,
|
|
1758
|
+
CrmOutreachUnconfiguredError,
|
|
1759
|
+
CrmRecordNotFoundError,
|
|
1760
|
+
CrmStorageError,
|
|
1761
|
+
DynamoCrmClient,
|
|
1762
|
+
createDynamoCrmClient,
|
|
1763
|
+
deterministicRecordId
|
|
1764
|
+
};
|