@odla-ai/chapter 0.25.6 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -13
- package/dist/{chunk-5SWVMJZN.js → chunk-4QZEAWQL.js} +183 -45
- package/dist/chunk-4QZEAWQL.js.map +1 -0
- package/dist/{chunk-WGQO4WDJ.js → chunk-OIWCKF2G.js} +20 -3
- package/dist/chunk-OIWCKF2G.js.map +1 -0
- package/dist/{chunk-WHA3MUDQ.js → chunk-QBTFQUDL.js} +2 -2
- package/dist/{copy-context-DI21CYQ3.d.ts → copy-context-CLvhJhrF.d.ts} +73 -42
- package/dist/index.cjs +327 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +170 -58
- package/dist/index.d.ts +170 -58
- package/dist/index.js +321 -20
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +8 -3
- package/dist/ui/admin/index.js +4 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +5 -3
- package/dist/ui/member/index.d.ts +2 -2
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +552 -208
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +76 -42
- package/dist/worker/index.d.ts +76 -42
- package/dist/worker/index.js +558 -209
- package/dist/worker/index.js.map +1 -1
- package/package.json +3 -3
- package/runbooks/adopt-existing.md +12 -1
- package/runbooks/greenfield.md +29 -7
- package/dist/chunk-5SWVMJZN.js.map +0 -1
- package/dist/chunk-WGQO4WDJ.js.map +0 -1
- /package/dist/{chunk-WHA3MUDQ.js.map → chunk-QBTFQUDL.js.map} +0 -0
package/dist/worker/index.js
CHANGED
|
@@ -111,6 +111,48 @@ function createWorkerContext(options) {
|
|
|
111
111
|
// src/worker-routes.ts
|
|
112
112
|
import { createCrmRoutes } from "@odla-ai/crm";
|
|
113
113
|
|
|
114
|
+
// src/discussion-reference-security.ts
|
|
115
|
+
async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
|
|
116
|
+
const match = req.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/);
|
|
117
|
+
const appIncarnation = env.ODLA_APP_INCARNATION;
|
|
118
|
+
const expectedTenant = env.ODLA_ENV === "prod" ? env.ODLA_APP_ID : `${env.ODLA_APP_ID}--${env.ODLA_ENV}`;
|
|
119
|
+
if (!match || env.ODLA_TENANT !== expectedTenant || typeof appIncarnation !== "string" || !/^[a-f0-9]{32}$/.test(appIncarnation)) return null;
|
|
120
|
+
const requestUrl = new URL(req.url);
|
|
121
|
+
requestUrl.search = "";
|
|
122
|
+
requestUrl.hash = "";
|
|
123
|
+
const audience = requestUrl.href;
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(
|
|
126
|
+
"/registry/discuss/reference-authority/verify",
|
|
127
|
+
env.ODLA_PLATFORM
|
|
128
|
+
);
|
|
129
|
+
const response = await fetcher(url, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
token: match[1],
|
|
134
|
+
appId: env.ODLA_APP_ID,
|
|
135
|
+
appIncarnation,
|
|
136
|
+
env: env.ODLA_ENV,
|
|
137
|
+
product: "crm",
|
|
138
|
+
projectCapability: "crm.read",
|
|
139
|
+
audience
|
|
140
|
+
}),
|
|
141
|
+
redirect: "manual"
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) return null;
|
|
144
|
+
const value = await response.json();
|
|
145
|
+
if (value.authority?.appId !== env.ODLA_APP_ID || value.authority.appIncarnation !== appIncarnation || value.authority.appEnv !== env.ODLA_ENV || value.authority.capability !== "product.reference.read" || value.authority.product !== "crm" || value.authority.projectCapability !== "crm.read" || value.authority.audience !== audience || typeof value.actor?.id !== "string" || value.actor.kind !== "human" && value.actor.kind !== "agent" || value.actor.email !== void 0 && typeof value.actor.email !== "string") return null;
|
|
146
|
+
return {
|
|
147
|
+
userId: value.actor.id,
|
|
148
|
+
discussionReferenceScope: "all",
|
|
149
|
+
...value.actor.email ? { email: value.actor.email } : {}
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
114
156
|
// src/application-id.ts
|
|
115
157
|
var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
|
|
116
158
|
async function applicationIdForSubmission(submissionId) {
|
|
@@ -194,160 +236,6 @@ function joinConfig(group, paymentsReady) {
|
|
|
194
236
|
};
|
|
195
237
|
}
|
|
196
238
|
|
|
197
|
-
// src/network.ts
|
|
198
|
-
import { createRecord, updateRecord } from "@odla-ai/crm";
|
|
199
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
200
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
201
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
202
|
-
};
|
|
203
|
-
function sharedPersonInput(person) {
|
|
204
|
-
const email = person.email.toLowerCase();
|
|
205
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
206
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
207
|
-
if (input.name === "") input.name = email;
|
|
208
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
209
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
210
|
-
if (person.phone) input.phone = person.phone;
|
|
211
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
212
|
-
return input;
|
|
213
|
-
}
|
|
214
|
-
function shortHash(value) {
|
|
215
|
-
let a = 2166136261;
|
|
216
|
-
let b = 2654435769;
|
|
217
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
218
|
-
const n = value.charCodeAt(i);
|
|
219
|
-
a = Math.imul(a ^ n, 16777619);
|
|
220
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
221
|
-
}
|
|
222
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
223
|
-
}
|
|
224
|
-
function networkSourceTag(type, hubRecordId) {
|
|
225
|
-
const typeKey = type.toLowerCase();
|
|
226
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
227
|
-
const raw = `network:${typeKey}:${hubRecordId}`;
|
|
228
|
-
if (readable && raw.length <= 64) return raw;
|
|
229
|
-
return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
|
|
230
|
-
}
|
|
231
|
-
function normalizeSharedRecord(record) {
|
|
232
|
-
if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
|
|
233
|
-
if ("type" in record && record.type === "company") {
|
|
234
|
-
const input = { name: record.name };
|
|
235
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
236
|
-
if (record[key]) input[key] = record[key];
|
|
237
|
-
}
|
|
238
|
-
return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
|
|
239
|
-
}
|
|
240
|
-
return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
|
|
241
|
-
}
|
|
242
|
-
function sharedRecordFromCrm(crm, record, target) {
|
|
243
|
-
if (target.fields && !target.fields[record.type]) {
|
|
244
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
245
|
-
}
|
|
246
|
-
const def = crm.type(record.type);
|
|
247
|
-
const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
|
|
248
|
-
if (!fields) {
|
|
249
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
|
|
250
|
-
}
|
|
251
|
-
const nameField = def.nameField ?? "name";
|
|
252
|
-
const input = {};
|
|
253
|
-
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
254
|
-
const value = record.fields?.[field];
|
|
255
|
-
if (value !== void 0) input[field] = value;
|
|
256
|
-
}
|
|
257
|
-
if (input[nameField] === void 0) input[nameField] = record.name;
|
|
258
|
-
return { version: 1, type: record.type, hubRecordId: record.id, input };
|
|
259
|
-
}
|
|
260
|
-
async function upsertPerson(deps, opts) {
|
|
261
|
-
const email = opts.email.toLowerCase();
|
|
262
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
263
|
-
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
264
|
-
const existing = crm_record?.[0];
|
|
265
|
-
if (existing && typeof existing.id === "string") {
|
|
266
|
-
await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
|
|
267
|
-
return { recordId: existing.id };
|
|
268
|
-
}
|
|
269
|
-
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
270
|
-
return { recordId: created.id };
|
|
271
|
-
}
|
|
272
|
-
async function findSharedRecord(deps, record, tag) {
|
|
273
|
-
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
274
|
-
const mappedId = mapped.crm_tag?.[0]?.recordId;
|
|
275
|
-
if (typeof mappedId === "string") {
|
|
276
|
-
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
277
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
278
|
-
}
|
|
279
|
-
const def = deps.crm.type(record.type);
|
|
280
|
-
const emailField = def.emailField;
|
|
281
|
-
if (emailField && typeof record.input[emailField] === "string") {
|
|
282
|
-
const primaryEmail = record.input[emailField].toLowerCase();
|
|
283
|
-
const found = await deps.db.query({
|
|
284
|
-
crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
|
|
285
|
-
});
|
|
286
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
287
|
-
}
|
|
288
|
-
const domain = record.input.domain;
|
|
289
|
-
const domainSlot = def.fields.domain?.slot;
|
|
290
|
-
if (typeof domain === "string" && domainSlot) {
|
|
291
|
-
const found = await deps.db.query({
|
|
292
|
-
crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
|
|
293
|
-
});
|
|
294
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
295
|
-
}
|
|
296
|
-
const nameField = def.nameField ?? "name";
|
|
297
|
-
const name = record.input[nameField];
|
|
298
|
-
if (record.type === "company" && typeof name === "string" && name.trim()) {
|
|
299
|
-
const found = await deps.db.query({
|
|
300
|
-
crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
|
|
301
|
-
});
|
|
302
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
303
|
-
}
|
|
304
|
-
return void 0;
|
|
305
|
-
}
|
|
306
|
-
async function projectSharedRecord(deps, shared) {
|
|
307
|
-
const record = normalizeSharedRecord(shared);
|
|
308
|
-
if (!record.type.trim() || !record.hubRecordId.trim()) {
|
|
309
|
-
throw new Error("type and hubRecordId must be non-empty");
|
|
310
|
-
}
|
|
311
|
-
const tag = networkSourceTag(record.type, record.hubRecordId);
|
|
312
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
313
|
-
const existing = await findSharedRecord(deps, record, tag);
|
|
314
|
-
let recordId;
|
|
315
|
-
if (existing && typeof existing.id === "string") {
|
|
316
|
-
await updateRecord(crmDeps3, { id: existing.id, input: record.input });
|
|
317
|
-
recordId = existing.id;
|
|
318
|
-
} else {
|
|
319
|
-
recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
|
|
320
|
-
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
321
|
-
type: record.type,
|
|
322
|
-
input: record.input,
|
|
323
|
-
mutationId: `share-create:${tag}`
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
await deps.db.transact(
|
|
327
|
-
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
328
|
-
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
329
|
-
);
|
|
330
|
-
return { recordId };
|
|
331
|
-
}
|
|
332
|
-
async function projectApplicant(deps, applicant) {
|
|
333
|
-
const base = sharedPersonInput({
|
|
334
|
-
email: applicant.email,
|
|
335
|
-
firstName: applicant.firstName,
|
|
336
|
-
lastName: applicant.lastName,
|
|
337
|
-
phone: applicant.phone,
|
|
338
|
-
linkedin: applicant.linkedin,
|
|
339
|
-
hubRecordId: applicant.applicationId
|
|
340
|
-
});
|
|
341
|
-
const mutationId = `apply:${applicant.applicationId}`;
|
|
342
|
-
const extra = applicant.extra ?? {};
|
|
343
|
-
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
344
|
-
try {
|
|
345
|
-
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
346
|
-
} catch {
|
|
347
|
-
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
239
|
// src/scheduling.ts
|
|
352
240
|
var SCHEDULING_DEFAULTS = {
|
|
353
241
|
slotMinutes: 45,
|
|
@@ -576,6 +464,215 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
|
576
464
|
return { ...healed, refreshed };
|
|
577
465
|
}
|
|
578
466
|
|
|
467
|
+
// src/network.ts
|
|
468
|
+
import {
|
|
469
|
+
createRecord,
|
|
470
|
+
getRecordByOrigin,
|
|
471
|
+
updateRecord,
|
|
472
|
+
upsertRecordOrigin
|
|
473
|
+
} from "@odla-ai/crm";
|
|
474
|
+
|
|
475
|
+
// src/network-contract.ts
|
|
476
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
477
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
478
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// src/network.ts
|
|
482
|
+
function sharedPersonInput(person) {
|
|
483
|
+
const email = person.email.toLowerCase();
|
|
484
|
+
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
485
|
+
const input = { name: person.name ?? fullName ?? email, email };
|
|
486
|
+
if (input.name === "") input.name = email;
|
|
487
|
+
if (person.firstName) input.firstName = person.firstName;
|
|
488
|
+
if (person.lastName) input.lastName = person.lastName;
|
|
489
|
+
if (person.phone) input.phone = person.phone;
|
|
490
|
+
if (person.linkedin) input.linkedin = person.linkedin;
|
|
491
|
+
return input;
|
|
492
|
+
}
|
|
493
|
+
function shortHash(value) {
|
|
494
|
+
let a = 2166136261;
|
|
495
|
+
let b = 2654435769;
|
|
496
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
497
|
+
const n = value.charCodeAt(i);
|
|
498
|
+
a = Math.imul(a ^ n, 16777619);
|
|
499
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
500
|
+
}
|
|
501
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
502
|
+
}
|
|
503
|
+
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
504
|
+
const typeKey = type.toLowerCase();
|
|
505
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
506
|
+
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
507
|
+
const raw = `${prefix}:${hubRecordId}`;
|
|
508
|
+
if (readable && raw.length <= 64) return raw;
|
|
509
|
+
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
510
|
+
}
|
|
511
|
+
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
512
|
+
if ("input" in record && record.version === 2) {
|
|
513
|
+
return {
|
|
514
|
+
version: 2,
|
|
515
|
+
sourceId: record.source.siteId,
|
|
516
|
+
sourceRecordId: record.source.recordId,
|
|
517
|
+
type: record.type,
|
|
518
|
+
input: record.input
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
if ("input" in record) {
|
|
522
|
+
return {
|
|
523
|
+
version: 1,
|
|
524
|
+
sourceId: fallbackSourceId,
|
|
525
|
+
sourceRecordId: record.hubRecordId,
|
|
526
|
+
type: record.type,
|
|
527
|
+
input: record.input
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if ("type" in record && record.type === "company") {
|
|
531
|
+
const input = { name: record.name };
|
|
532
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
533
|
+
if (record[key]) input[key] = record[key];
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
version: 1,
|
|
537
|
+
sourceId: fallbackSourceId,
|
|
538
|
+
sourceRecordId: record.hubRecordId,
|
|
539
|
+
type: "company",
|
|
540
|
+
input
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
version: 1,
|
|
545
|
+
sourceId: fallbackSourceId,
|
|
546
|
+
sourceRecordId: record.hubRecordId,
|
|
547
|
+
type: "person",
|
|
548
|
+
input: sharedPersonInput(record)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
552
|
+
if (target.fields && !target.fields[record.type]) {
|
|
553
|
+
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
554
|
+
}
|
|
555
|
+
const def = crm.type(record.type);
|
|
556
|
+
const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
|
|
557
|
+
if (!fields) {
|
|
558
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
|
|
559
|
+
}
|
|
560
|
+
const nameField = def.nameField ?? "name";
|
|
561
|
+
const input = {};
|
|
562
|
+
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
563
|
+
const value = record.fields?.[field];
|
|
564
|
+
if (value !== void 0) input[field] = value;
|
|
565
|
+
}
|
|
566
|
+
if (input[nameField] === void 0) input[nameField] = record.name;
|
|
567
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
|
|
568
|
+
}
|
|
569
|
+
async function upsertPerson(deps, opts) {
|
|
570
|
+
const email = opts.email.toLowerCase();
|
|
571
|
+
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
572
|
+
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
573
|
+
const existing = crm_record?.[0];
|
|
574
|
+
if (existing && typeof existing.id === "string") {
|
|
575
|
+
await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
|
|
576
|
+
return { recordId: existing.id };
|
|
577
|
+
}
|
|
578
|
+
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
579
|
+
return { recordId: created.id };
|
|
580
|
+
}
|
|
581
|
+
async function findSharedRecord(deps, record, tag) {
|
|
582
|
+
const structured = await getRecordByOrigin(
|
|
583
|
+
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
584
|
+
record.sourceId,
|
|
585
|
+
record.sourceRecordId
|
|
586
|
+
);
|
|
587
|
+
if (structured) return structured;
|
|
588
|
+
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
589
|
+
const mappedId = mapped.crm_tag?.[0]?.recordId;
|
|
590
|
+
if (typeof mappedId === "string") {
|
|
591
|
+
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
592
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
593
|
+
}
|
|
594
|
+
const def = deps.crm.type(record.type);
|
|
595
|
+
const emailField = def.emailField;
|
|
596
|
+
if (emailField && typeof record.input[emailField] === "string") {
|
|
597
|
+
const primaryEmail = record.input[emailField].toLowerCase();
|
|
598
|
+
const found = await deps.db.query({
|
|
599
|
+
crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
|
|
600
|
+
});
|
|
601
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
602
|
+
}
|
|
603
|
+
const domain = record.input.domain;
|
|
604
|
+
const domainSlot = def.fields.domain?.slot;
|
|
605
|
+
if (typeof domain === "string" && domainSlot) {
|
|
606
|
+
const found = await deps.db.query({
|
|
607
|
+
crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
|
|
608
|
+
});
|
|
609
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
610
|
+
}
|
|
611
|
+
const nameField = def.nameField ?? "name";
|
|
612
|
+
const name = record.input[nameField];
|
|
613
|
+
if (record.type === "company" && typeof name === "string" && name.trim()) {
|
|
614
|
+
const found = await deps.db.query({
|
|
615
|
+
crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
|
|
616
|
+
});
|
|
617
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
618
|
+
}
|
|
619
|
+
return void 0;
|
|
620
|
+
}
|
|
621
|
+
async function projectSharedRecord(deps, shared, options = {}) {
|
|
622
|
+
const record = normalizeSharedRecord(shared, options.sourceId);
|
|
623
|
+
if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
|
|
624
|
+
throw new Error("type, source site id, and source record id must be non-empty");
|
|
625
|
+
}
|
|
626
|
+
if (options.sourceId && options.sourceId !== record.sourceId) {
|
|
627
|
+
throw new Error("signed sender does not match payload source");
|
|
628
|
+
}
|
|
629
|
+
const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
|
|
630
|
+
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
631
|
+
const existing = await findSharedRecord(deps, record, tag);
|
|
632
|
+
let recordId;
|
|
633
|
+
if (existing && typeof existing.id === "string") {
|
|
634
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record.input });
|
|
635
|
+
recordId = existing.id;
|
|
636
|
+
} else {
|
|
637
|
+
recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
|
|
638
|
+
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
639
|
+
type: record.type,
|
|
640
|
+
input: record.input,
|
|
641
|
+
mutationId: `share-create:${tag}`
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
await deps.db.transact(
|
|
645
|
+
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
646
|
+
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
647
|
+
);
|
|
648
|
+
await upsertRecordOrigin(crmDeps3, {
|
|
649
|
+
recordId,
|
|
650
|
+
sourceId: record.sourceId,
|
|
651
|
+
sourceRecordId: record.sourceRecordId,
|
|
652
|
+
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
653
|
+
payloadVersion: record.version
|
|
654
|
+
});
|
|
655
|
+
return { recordId };
|
|
656
|
+
}
|
|
657
|
+
async function projectApplicant(deps, applicant) {
|
|
658
|
+
const base = sharedPersonInput({
|
|
659
|
+
email: applicant.email,
|
|
660
|
+
firstName: applicant.firstName,
|
|
661
|
+
lastName: applicant.lastName,
|
|
662
|
+
phone: applicant.phone,
|
|
663
|
+
linkedin: applicant.linkedin,
|
|
664
|
+
hubRecordId: applicant.applicationId
|
|
665
|
+
});
|
|
666
|
+
const mutationId = `apply:${applicant.applicationId}`;
|
|
667
|
+
const extra = applicant.extra ?? {};
|
|
668
|
+
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
669
|
+
try {
|
|
670
|
+
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
671
|
+
} catch {
|
|
672
|
+
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
579
676
|
// src/email.ts
|
|
580
677
|
function render(template, vars) {
|
|
581
678
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
@@ -871,6 +968,7 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
871
968
|
if (!u || !await ctx.isAdmin(db, u)) return null;
|
|
872
969
|
return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
|
|
873
970
|
},
|
|
971
|
+
authorizeDiscussionReferences: (r) => authorizeCrmDiscussionReference(r, env),
|
|
874
972
|
sender: ctx.crmSender(env),
|
|
875
973
|
from: env.EMAIL_FROM,
|
|
876
974
|
envName: env.ODLA_ENV,
|
|
@@ -881,44 +979,6 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
881
979
|
if (res) return res;
|
|
882
980
|
return json({ error: "not found" }, 404);
|
|
883
981
|
};
|
|
884
|
-
var handleNetworkShared = async (req, url, env, ctx) => {
|
|
885
|
-
if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
|
|
886
|
-
const db = ctx.makeDb(env);
|
|
887
|
-
const secret = await getVaultSecret(db, "network_share_secret");
|
|
888
|
-
const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
|
|
889
|
-
if (!secret || provided.length !== secret.length || provided !== secret) {
|
|
890
|
-
return json({ error: "unauthorized" }, 401);
|
|
891
|
-
}
|
|
892
|
-
let payload;
|
|
893
|
-
try {
|
|
894
|
-
payload = JSON.parse(await req.text());
|
|
895
|
-
} catch {
|
|
896
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
897
|
-
}
|
|
898
|
-
if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
|
|
899
|
-
return json({ error: "hubRecordId is required" }, 400);
|
|
900
|
-
}
|
|
901
|
-
if ("input" in payload) {
|
|
902
|
-
if (payload.version !== 1 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
|
|
903
|
-
return json({ error: "version 1, type, and input are required" }, 400);
|
|
904
|
-
}
|
|
905
|
-
} else if (payload.type !== "company" && typeof payload.email !== "string") {
|
|
906
|
-
return json({ error: "legacy person shares require email" }, 400);
|
|
907
|
-
} else if (payload.type === "company" && typeof payload.name !== "string") {
|
|
908
|
-
return json({ error: "business shares require name" }, 400);
|
|
909
|
-
}
|
|
910
|
-
try {
|
|
911
|
-
const record = normalizeSharedRecord(payload);
|
|
912
|
-
const { recordId } = await projectSharedRecord(
|
|
913
|
-
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
914
|
-
record
|
|
915
|
-
);
|
|
916
|
-
return json({ recordId, type: record.type });
|
|
917
|
-
} catch (err) {
|
|
918
|
-
const message = err instanceof Error ? err.message : "invalid shared record";
|
|
919
|
-
return json({ error: message }, 400);
|
|
920
|
-
}
|
|
921
|
-
};
|
|
922
982
|
var handleMember = async (req, url, env, ctx) => {
|
|
923
983
|
const chapter = ctx.chapter;
|
|
924
984
|
if (chapter.mode !== "chapter") return null;
|
|
@@ -1015,6 +1075,62 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1015
1075
|
return null;
|
|
1016
1076
|
};
|
|
1017
1077
|
|
|
1078
|
+
// src/worker-routes-network-receive.ts
|
|
1079
|
+
import { FEDERATION_HEADERS, verifyFederatedRequest } from "@odla-ai/db";
|
|
1080
|
+
var handleNetworkShared = async (req, url, env, ctx) => {
|
|
1081
|
+
if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
|
|
1082
|
+
const db = ctx.makeDb(env);
|
|
1083
|
+
const secret = await getVaultSecret(db, "network_share_secret");
|
|
1084
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
1085
|
+
let raw;
|
|
1086
|
+
let sourceId;
|
|
1087
|
+
if (req.headers.has(FEDERATION_HEADERS.version)) {
|
|
1088
|
+
const verified = await verifyFederatedRequest(req, { secret });
|
|
1089
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
1090
|
+
raw = verified.body;
|
|
1091
|
+
sourceId = verified.sender;
|
|
1092
|
+
} else {
|
|
1093
|
+
const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
|
|
1094
|
+
if (provided.length !== secret.length || provided !== secret) {
|
|
1095
|
+
return json({ error: "unauthorized" }, 401);
|
|
1096
|
+
}
|
|
1097
|
+
raw = await req.text();
|
|
1098
|
+
}
|
|
1099
|
+
let payload;
|
|
1100
|
+
try {
|
|
1101
|
+
payload = JSON.parse(raw);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1104
|
+
}
|
|
1105
|
+
if ("input" in payload) {
|
|
1106
|
+
const v1 = payload.version === 1 && typeof payload.hubRecordId === "string" && payload.hubRecordId.trim();
|
|
1107
|
+
const source = payload.source;
|
|
1108
|
+
const v2 = payload.version === 2 && source && typeof source.siteId === "string" && source.siteId.trim() && typeof source.recordId === "string" && source.recordId.trim();
|
|
1109
|
+
if (!v1 && !v2 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
|
|
1110
|
+
return json({ error: "a supported version, source record, type, and input are required" }, 400);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
|
|
1113
|
+
return json({ error: "hubRecordId is required" }, 400);
|
|
1114
|
+
} else if (payload.type !== "company" && typeof payload.email !== "string") {
|
|
1115
|
+
return json({ error: "legacy person shares require email" }, 400);
|
|
1116
|
+
} else if (payload.type === "company" && typeof payload.name !== "string") {
|
|
1117
|
+
return json({ error: "business shares require name" }, 400);
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
const { recordId } = await projectSharedRecord(
|
|
1121
|
+
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1122
|
+
payload,
|
|
1123
|
+
sourceId ? { sourceId } : {}
|
|
1124
|
+
);
|
|
1125
|
+
const record = normalizeSharedRecord(payload, sourceId);
|
|
1126
|
+
return json({ recordId, type: record.type });
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
return json({
|
|
1129
|
+
error: error instanceof Error ? error.message : "invalid shared record"
|
|
1130
|
+
}, 400);
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1018
1134
|
// src/crm-sync.ts
|
|
1019
1135
|
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
1020
1136
|
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
@@ -1170,7 +1286,11 @@ async function bookSlot(req, env, ctx) {
|
|
|
1170
1286
|
let meetingOp;
|
|
1171
1287
|
try {
|
|
1172
1288
|
if (decision.reschedule && decision.eventId) {
|
|
1173
|
-
await cal.actions.reschedule(decision.eventId, {
|
|
1289
|
+
await cal.actions.reschedule(decision.eventId, {
|
|
1290
|
+
idempotencyKey: `meeting:${String(existing?.id)}:reschedule:${startAt}:${endAt}`,
|
|
1291
|
+
startAt,
|
|
1292
|
+
endAt
|
|
1293
|
+
});
|
|
1174
1294
|
meetUrl = existing?.meetUrl ?? null;
|
|
1175
1295
|
htmlLink = existing?.htmlLink ?? null;
|
|
1176
1296
|
meetingOp = { t: "update", ns: "meetings", id: String(existing?.id), attrs: meetingRescheduleUpdate(startAt, endAt) };
|
|
@@ -2200,7 +2320,11 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
2200
2320
|
minNoticeMs: cfg.minNoticeHours * 36e5
|
|
2201
2321
|
});
|
|
2202
2322
|
if (!isSlotAvailable(slots, startAt)) return json({ error: "slot no longer available", code: "calendar_slot_unavailable" }, 409);
|
|
2203
|
-
await cal.actions.reschedule(String(meeting.googleEventId), {
|
|
2323
|
+
await cal.actions.reschedule(String(meeting.googleEventId), {
|
|
2324
|
+
idempotencyKey: `meeting:${String(meeting.id)}:reschedule:${startAt}:${endAt}`,
|
|
2325
|
+
startAt,
|
|
2326
|
+
endAt
|
|
2327
|
+
});
|
|
2204
2328
|
} catch {
|
|
2205
2329
|
return json({ error: "reschedule failed upstream" }, 502);
|
|
2206
2330
|
}
|
|
@@ -2220,7 +2344,9 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
2220
2344
|
if (meeting.status !== "scheduled") return json({ error: "already cancelled" }, 409);
|
|
2221
2345
|
if (meeting.googleEventId) {
|
|
2222
2346
|
try {
|
|
2223
|
-
await calFor(env).actions.cancel(String(meeting.googleEventId)
|
|
2347
|
+
await calFor(env).actions.cancel(String(meeting.googleEventId), {
|
|
2348
|
+
idempotencyKey: `meeting:${String(meeting.id)}:cancel`
|
|
2349
|
+
});
|
|
2224
2350
|
} catch {
|
|
2225
2351
|
return json({ error: "cancel failed upstream" }, 502);
|
|
2226
2352
|
}
|
|
@@ -2489,7 +2615,8 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
2489
2615
|
};
|
|
2490
2616
|
|
|
2491
2617
|
// src/worker-routes-network.ts
|
|
2492
|
-
import {
|
|
2618
|
+
import { getRecord, recordDeliveryAttempt, typeSummary } from "@odla-ai/crm";
|
|
2619
|
+
import { signFederatedRequest, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
|
|
2493
2620
|
async function gate4(req, env, ctx) {
|
|
2494
2621
|
const db = ctx.makeDb(env);
|
|
2495
2622
|
const user = await ctx.verifyUser(req, env);
|
|
@@ -2510,33 +2637,158 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
|
2510
2637
|
}))
|
|
2511
2638
|
});
|
|
2512
2639
|
};
|
|
2640
|
+
var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
2641
|
+
if (req.method !== "GET" || url.pathname !== "/api/network/snapshot") return null;
|
|
2642
|
+
const db = ctx.makeDb(env);
|
|
2643
|
+
const secret = await getVaultSecret(db, "network_share_secret");
|
|
2644
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2645
|
+
const verified = await verifyFederatedRequest2(req, { secret });
|
|
2646
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2647
|
+
const types = await Promise.all(
|
|
2648
|
+
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
2649
|
+
);
|
|
2650
|
+
const snapshot = {
|
|
2651
|
+
version: 1,
|
|
2652
|
+
site: { id: ctx.chapter.id, name: ctx.chapter.name, mode: ctx.chapter.mode },
|
|
2653
|
+
generatedAt: Date.now(),
|
|
2654
|
+
types
|
|
2655
|
+
};
|
|
2656
|
+
return json(snapshot);
|
|
2657
|
+
};
|
|
2658
|
+
async function snapshotOne(db, ctx, target) {
|
|
2659
|
+
const secret = await getVaultSecret(db, target.secretName);
|
|
2660
|
+
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
2661
|
+
const destination = new URL("/api/network/snapshot", target.url);
|
|
2662
|
+
try {
|
|
2663
|
+
const headers = await signFederatedRequest({
|
|
2664
|
+
secret,
|
|
2665
|
+
sender: ctx.chapter.id,
|
|
2666
|
+
method: "GET",
|
|
2667
|
+
url: destination
|
|
2668
|
+
});
|
|
2669
|
+
const response = await fetch(destination, { headers, signal: AbortSignal.timeout(1e4) });
|
|
2670
|
+
const body = await response.json().catch(() => null);
|
|
2671
|
+
if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.types)) {
|
|
2672
|
+
const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
|
|
2673
|
+
return {
|
|
2674
|
+
id: target.id,
|
|
2675
|
+
name: target.name,
|
|
2676
|
+
url: target.url,
|
|
2677
|
+
available: false,
|
|
2678
|
+
error: upstream ?? (response.ok ? "invalid follower snapshot" : `follower returned ${response.status}`)
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
return {
|
|
2682
|
+
id: target.id,
|
|
2683
|
+
name: target.name,
|
|
2684
|
+
url: target.url,
|
|
2685
|
+
available: true,
|
|
2686
|
+
generatedAt: body.generatedAt,
|
|
2687
|
+
types: body.types
|
|
2688
|
+
};
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
return {
|
|
2691
|
+
id: target.id,
|
|
2692
|
+
name: target.name,
|
|
2693
|
+
url: target.url,
|
|
2694
|
+
available: false,
|
|
2695
|
+
error: error instanceof Error ? error.message : "snapshot failed"
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
2700
|
+
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
2701
|
+
const got = await gate4(req, env, ctx);
|
|
2702
|
+
if ("response" in got) return got.response;
|
|
2703
|
+
const targets = await Promise.all(
|
|
2704
|
+
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, ctx, target))
|
|
2705
|
+
);
|
|
2706
|
+
const byType = /* @__PURE__ */ new Map();
|
|
2707
|
+
for (const target of targets) {
|
|
2708
|
+
for (const type of target.types ?? []) {
|
|
2709
|
+
const aggregate = byType.get(type.type) ?? { type: type.type, total: 0, stages: {} };
|
|
2710
|
+
aggregate.total += type.total;
|
|
2711
|
+
for (const [stage, count] of Object.entries(type.stages)) {
|
|
2712
|
+
aggregate.stages[stage] = (aggregate.stages[stage] ?? 0) + count;
|
|
2713
|
+
}
|
|
2714
|
+
byType.set(type.type, aggregate);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
const rollup = {
|
|
2718
|
+
configured: targets.length,
|
|
2719
|
+
available: targets.filter((target) => target.available).length,
|
|
2720
|
+
totalRecords: [...byType.values()].reduce((sum, type) => sum + type.total, 0),
|
|
2721
|
+
types: [...byType.values()],
|
|
2722
|
+
targets
|
|
2723
|
+
};
|
|
2724
|
+
return json(rollup);
|
|
2725
|
+
};
|
|
2513
2726
|
async function pushOne(db, ctx, target, record) {
|
|
2514
2727
|
const secret = await getVaultSecret(db, target.secretName);
|
|
2515
|
-
|
|
2728
|
+
const crmDeps3 = { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() };
|
|
2729
|
+
if (!secret) {
|
|
2730
|
+
const error = `vault secret "${target.secretName}" is missing`;
|
|
2731
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2732
|
+
recordId: record.id,
|
|
2733
|
+
targetId: target.id,
|
|
2734
|
+
status: "failed",
|
|
2735
|
+
payloadVersion: 2,
|
|
2736
|
+
error
|
|
2737
|
+
});
|
|
2738
|
+
return { id: target.id, name: target.name, ok: false, error };
|
|
2739
|
+
}
|
|
2516
2740
|
let payload;
|
|
2517
2741
|
try {
|
|
2518
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm, record, target);
|
|
2742
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
|
|
2519
2743
|
} catch (err) {
|
|
2520
2744
|
return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
|
|
2521
2745
|
}
|
|
2746
|
+
const payloadBody = JSON.stringify(payload);
|
|
2747
|
+
const destination = new URL("/api/network/shared", target.url);
|
|
2522
2748
|
try {
|
|
2523
|
-
const
|
|
2749
|
+
const signed = await signFederatedRequest({
|
|
2750
|
+
secret,
|
|
2751
|
+
sender: ctx.chapter.id,
|
|
2752
|
+
method: "POST",
|
|
2753
|
+
url: destination,
|
|
2754
|
+
body: payloadBody
|
|
2755
|
+
});
|
|
2756
|
+
const res = await fetch(destination, {
|
|
2524
2757
|
method: "POST",
|
|
2525
|
-
headers: {
|
|
2526
|
-
body:
|
|
2758
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
2759
|
+
body: payloadBody,
|
|
2527
2760
|
signal: AbortSignal.timeout(1e4)
|
|
2528
2761
|
});
|
|
2529
|
-
const
|
|
2762
|
+
const responseBody = await res.json().catch(() => ({}));
|
|
2530
2763
|
if (!res.ok) {
|
|
2531
|
-
|
|
2764
|
+
const error = responseBody.error ?? "follower rejected the record";
|
|
2765
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2766
|
+
recordId: record.id,
|
|
2767
|
+
targetId: target.id,
|
|
2768
|
+
status: "failed",
|
|
2769
|
+
payloadVersion: 2,
|
|
2770
|
+
error
|
|
2771
|
+
});
|
|
2772
|
+
return { id: target.id, name: target.name, ok: false, status: res.status, error };
|
|
2532
2773
|
}
|
|
2533
|
-
await
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2774
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2775
|
+
recordId: record.id,
|
|
2776
|
+
targetId: target.id,
|
|
2777
|
+
status: "delivered",
|
|
2778
|
+
payloadVersion: 2,
|
|
2779
|
+
...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
|
|
2780
|
+
});
|
|
2781
|
+
return { id: target.id, name: target.name, ok: true, status: res.status, recordId: responseBody.recordId };
|
|
2538
2782
|
} catch (err) {
|
|
2539
|
-
|
|
2783
|
+
const error = err instanceof Error ? err.message : "delivery failed";
|
|
2784
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2785
|
+
recordId: record.id,
|
|
2786
|
+
targetId: target.id,
|
|
2787
|
+
status: "failed",
|
|
2788
|
+
payloadVersion: 2,
|
|
2789
|
+
error
|
|
2790
|
+
});
|
|
2791
|
+
return { id: target.id, name: target.name, ok: false, error };
|
|
2540
2792
|
}
|
|
2541
2793
|
}
|
|
2542
2794
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
@@ -2564,6 +2816,100 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
2564
2816
|
return json({ ok: results.every((result) => result.ok), results });
|
|
2565
2817
|
};
|
|
2566
2818
|
|
|
2819
|
+
// src/worker-routes-formation.ts
|
|
2820
|
+
import { createRecord as createRecord3 } from "@odla-ai/crm";
|
|
2821
|
+
|
|
2822
|
+
// src/formation.ts
|
|
2823
|
+
function formationFields(crm, formation) {
|
|
2824
|
+
const def = crm.type(formation.type);
|
|
2825
|
+
return [...formation.required, ...formation.optional].map((id) => {
|
|
2826
|
+
const field = def.fields[id];
|
|
2827
|
+
return {
|
|
2828
|
+
id,
|
|
2829
|
+
label: field.label ?? id,
|
|
2830
|
+
type: field.type,
|
|
2831
|
+
required: formation.required.includes(id),
|
|
2832
|
+
...field.options ? { options: field.options } : {}
|
|
2833
|
+
};
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// src/worker-routes-formation.ts
|
|
2838
|
+
var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
|
|
2839
|
+
var handleFormation = async (req, url, env, ctx) => {
|
|
2840
|
+
const formation = ctx.chapter.formation;
|
|
2841
|
+
if (!formation.enabled) return null;
|
|
2842
|
+
if (req.method === "GET" && url.pathname === "/api/formation/config") {
|
|
2843
|
+
return json({
|
|
2844
|
+
type: formation.type,
|
|
2845
|
+
fields: formationFields(ctx.chapter.crm, formation)
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
|
|
2849
|
+
const raw = await req.text();
|
|
2850
|
+
if (raw.length > formation.bodyCap) return json({ error: "request body too large" }, 413);
|
|
2851
|
+
let body;
|
|
2852
|
+
try {
|
|
2853
|
+
body = JSON.parse(raw);
|
|
2854
|
+
} catch {
|
|
2855
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2856
|
+
}
|
|
2857
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "JSON body must be an object" }, 400);
|
|
2858
|
+
const submissionId = typeof body.submissionId === "string" ? body.submissionId : void 0;
|
|
2859
|
+
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
2860
|
+
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
2861
|
+
}
|
|
2862
|
+
const allowed = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
2863
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed.has(field));
|
|
2864
|
+
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
2865
|
+
for (const field of formation.required) {
|
|
2866
|
+
const value = body[field];
|
|
2867
|
+
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
2868
|
+
}
|
|
2869
|
+
const input = {};
|
|
2870
|
+
for (const field of allowed) {
|
|
2871
|
+
const value = body[field];
|
|
2872
|
+
if (value === void 0) continue;
|
|
2873
|
+
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
2874
|
+
if (typeof value === "string") {
|
|
2875
|
+
if (value.length > cap) return json({ error: `${field} exceeds ${cap} characters` }, 400);
|
|
2876
|
+
input[field] = value.trim();
|
|
2877
|
+
} else if (Array.isArray(value)) {
|
|
2878
|
+
input[field] = value.slice(0, 100);
|
|
2879
|
+
} else {
|
|
2880
|
+
input[field] = value;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
const db = ctx.makeDb(env);
|
|
2884
|
+
const id = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
|
|
2885
|
+
try {
|
|
2886
|
+
const created = await createRecord3(
|
|
2887
|
+
{
|
|
2888
|
+
crm: ctx.chapter.crm,
|
|
2889
|
+
db,
|
|
2890
|
+
now: () => Date.now(),
|
|
2891
|
+
newId: () => id
|
|
2892
|
+
},
|
|
2893
|
+
{
|
|
2894
|
+
type: formation.type,
|
|
2895
|
+
input,
|
|
2896
|
+
...submissionId ? { mutationId: `formation:${ctx.chapter.id}:${submissionId}` } : {}
|
|
2897
|
+
}
|
|
2898
|
+
);
|
|
2899
|
+
return json({
|
|
2900
|
+
id,
|
|
2901
|
+
duplicate: created.duplicate,
|
|
2902
|
+
status: created.record.stage ?? null
|
|
2903
|
+
}, created.duplicate ? 200 : 201);
|
|
2904
|
+
} catch (error) {
|
|
2905
|
+
const detail = error && typeof error === "object" && "fields" in error ? error.fields : void 0;
|
|
2906
|
+
return json({
|
|
2907
|
+
error: error instanceof Error ? error.message : "invalid formation application",
|
|
2908
|
+
...detail ? { fields: detail } : {}
|
|
2909
|
+
}, 400);
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
|
|
2567
2913
|
// src/worker.ts
|
|
2568
2914
|
var BUILTIN_ROUTES = [
|
|
2569
2915
|
handleHealth,
|
|
@@ -2571,6 +2917,8 @@ var BUILTIN_ROUTES = [
|
|
|
2571
2917
|
handleMe,
|
|
2572
2918
|
handleCrm,
|
|
2573
2919
|
handleNetworkShared,
|
|
2920
|
+
handleNetworkSnapshot,
|
|
2921
|
+
handleFormation,
|
|
2574
2922
|
handleMember,
|
|
2575
2923
|
handleSchedule,
|
|
2576
2924
|
handlePayments,
|
|
@@ -2598,6 +2946,7 @@ var BUILTIN_ROUTES = [
|
|
|
2598
2946
|
handleAdminComms,
|
|
2599
2947
|
// Leader → follower record delivery
|
|
2600
2948
|
handleAdminNetworkTargets,
|
|
2949
|
+
handleAdminNetworkRollup,
|
|
2601
2950
|
handleAdminNetworkPush,
|
|
2602
2951
|
// API requests must never fall through to an SPA asset response. Hosts still
|
|
2603
2952
|
// get first refusal through options.routes, then this terminates unknown API
|