@odla-ai/chapter 0.25.8 → 0.26.1
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 +148 -15
- package/dist/{chunk-4FW35E3P.js → chunk-4QZEAWQL.js} +170 -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-Vof2eTMt.d.ts} +78 -42
- package/dist/index.cjs +335 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +175 -58
- package/dist/index.d.ts +175 -58
- package/dist/index.js +329 -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 -216
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +78 -42
- package/dist/worker/index.d.ts +78 -42
- package/dist/worker/index.js +557 -216
- package/dist/worker/index.js.map +1 -1
- package/package.json +3 -3
- package/runbooks/adopt-existing.md +12 -1
- package/runbooks/greenfield.md +27 -6
- package/dist/chunk-4FW35E3P.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
|
@@ -236,160 +236,6 @@ function joinConfig(group, paymentsReady) {
|
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
// src/network.ts
|
|
240
|
-
import { createRecord, updateRecord } from "@odla-ai/crm";
|
|
241
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
242
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
243
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
244
|
-
};
|
|
245
|
-
function sharedPersonInput(person) {
|
|
246
|
-
const email = person.email.toLowerCase();
|
|
247
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
248
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
249
|
-
if (input.name === "") input.name = email;
|
|
250
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
251
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
252
|
-
if (person.phone) input.phone = person.phone;
|
|
253
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
254
|
-
return input;
|
|
255
|
-
}
|
|
256
|
-
function shortHash(value) {
|
|
257
|
-
let a = 2166136261;
|
|
258
|
-
let b = 2654435769;
|
|
259
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
260
|
-
const n = value.charCodeAt(i);
|
|
261
|
-
a = Math.imul(a ^ n, 16777619);
|
|
262
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
263
|
-
}
|
|
264
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
265
|
-
}
|
|
266
|
-
function networkSourceTag(type, hubRecordId) {
|
|
267
|
-
const typeKey = type.toLowerCase();
|
|
268
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
269
|
-
const raw = `network:${typeKey}:${hubRecordId}`;
|
|
270
|
-
if (readable && raw.length <= 64) return raw;
|
|
271
|
-
return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
|
|
272
|
-
}
|
|
273
|
-
function normalizeSharedRecord(record) {
|
|
274
|
-
if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
|
|
275
|
-
if ("type" in record && record.type === "company") {
|
|
276
|
-
const input = { name: record.name };
|
|
277
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
278
|
-
if (record[key]) input[key] = record[key];
|
|
279
|
-
}
|
|
280
|
-
return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
|
|
281
|
-
}
|
|
282
|
-
return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
|
|
283
|
-
}
|
|
284
|
-
function sharedRecordFromCrm(crm, record, target) {
|
|
285
|
-
if (target.fields && !target.fields[record.type]) {
|
|
286
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
287
|
-
}
|
|
288
|
-
const def = crm.type(record.type);
|
|
289
|
-
const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
|
|
290
|
-
if (!fields) {
|
|
291
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
|
|
292
|
-
}
|
|
293
|
-
const nameField = def.nameField ?? "name";
|
|
294
|
-
const input = {};
|
|
295
|
-
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
296
|
-
const value = record.fields?.[field];
|
|
297
|
-
if (value !== void 0) input[field] = value;
|
|
298
|
-
}
|
|
299
|
-
if (input[nameField] === void 0) input[nameField] = record.name;
|
|
300
|
-
return { version: 1, type: record.type, hubRecordId: record.id, input };
|
|
301
|
-
}
|
|
302
|
-
async function upsertPerson(deps, opts) {
|
|
303
|
-
const email = opts.email.toLowerCase();
|
|
304
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
305
|
-
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
306
|
-
const existing = crm_record?.[0];
|
|
307
|
-
if (existing && typeof existing.id === "string") {
|
|
308
|
-
await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
|
|
309
|
-
return { recordId: existing.id };
|
|
310
|
-
}
|
|
311
|
-
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
312
|
-
return { recordId: created.id };
|
|
313
|
-
}
|
|
314
|
-
async function findSharedRecord(deps, record, tag) {
|
|
315
|
-
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
316
|
-
const mappedId = mapped.crm_tag?.[0]?.recordId;
|
|
317
|
-
if (typeof mappedId === "string") {
|
|
318
|
-
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
319
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
320
|
-
}
|
|
321
|
-
const def = deps.crm.type(record.type);
|
|
322
|
-
const emailField = def.emailField;
|
|
323
|
-
if (emailField && typeof record.input[emailField] === "string") {
|
|
324
|
-
const primaryEmail = record.input[emailField].toLowerCase();
|
|
325
|
-
const found = await deps.db.query({
|
|
326
|
-
crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
|
|
327
|
-
});
|
|
328
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
329
|
-
}
|
|
330
|
-
const domain = record.input.domain;
|
|
331
|
-
const domainSlot = def.fields.domain?.slot;
|
|
332
|
-
if (typeof domain === "string" && domainSlot) {
|
|
333
|
-
const found = await deps.db.query({
|
|
334
|
-
crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
|
|
335
|
-
});
|
|
336
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
337
|
-
}
|
|
338
|
-
const nameField = def.nameField ?? "name";
|
|
339
|
-
const name = record.input[nameField];
|
|
340
|
-
if (record.type === "company" && typeof name === "string" && name.trim()) {
|
|
341
|
-
const found = await deps.db.query({
|
|
342
|
-
crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
|
|
343
|
-
});
|
|
344
|
-
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
345
|
-
}
|
|
346
|
-
return void 0;
|
|
347
|
-
}
|
|
348
|
-
async function projectSharedRecord(deps, shared) {
|
|
349
|
-
const record = normalizeSharedRecord(shared);
|
|
350
|
-
if (!record.type.trim() || !record.hubRecordId.trim()) {
|
|
351
|
-
throw new Error("type and hubRecordId must be non-empty");
|
|
352
|
-
}
|
|
353
|
-
const tag = networkSourceTag(record.type, record.hubRecordId);
|
|
354
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
355
|
-
const existing = await findSharedRecord(deps, record, tag);
|
|
356
|
-
let recordId;
|
|
357
|
-
if (existing && typeof existing.id === "string") {
|
|
358
|
-
await updateRecord(crmDeps3, { id: existing.id, input: record.input });
|
|
359
|
-
recordId = existing.id;
|
|
360
|
-
} else {
|
|
361
|
-
recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
|
|
362
|
-
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
363
|
-
type: record.type,
|
|
364
|
-
input: record.input,
|
|
365
|
-
mutationId: `share-create:${tag}`
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
await deps.db.transact(
|
|
369
|
-
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
370
|
-
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
371
|
-
);
|
|
372
|
-
return { recordId };
|
|
373
|
-
}
|
|
374
|
-
async function projectApplicant(deps, applicant) {
|
|
375
|
-
const base = sharedPersonInput({
|
|
376
|
-
email: applicant.email,
|
|
377
|
-
firstName: applicant.firstName,
|
|
378
|
-
lastName: applicant.lastName,
|
|
379
|
-
phone: applicant.phone,
|
|
380
|
-
linkedin: applicant.linkedin,
|
|
381
|
-
hubRecordId: applicant.applicationId
|
|
382
|
-
});
|
|
383
|
-
const mutationId = `apply:${applicant.applicationId}`;
|
|
384
|
-
const extra = applicant.extra ?? {};
|
|
385
|
-
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
386
|
-
try {
|
|
387
|
-
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
388
|
-
} catch {
|
|
389
|
-
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
239
|
// src/scheduling.ts
|
|
394
240
|
var SCHEDULING_DEFAULTS = {
|
|
395
241
|
slotMinutes: 45,
|
|
@@ -618,6 +464,215 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
|
618
464
|
return { ...healed, refreshed };
|
|
619
465
|
}
|
|
620
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
|
+
|
|
621
676
|
// src/email.ts
|
|
622
677
|
function render(template, vars) {
|
|
623
678
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
@@ -924,44 +979,6 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
924
979
|
if (res) return res;
|
|
925
980
|
return json({ error: "not found" }, 404);
|
|
926
981
|
};
|
|
927
|
-
var handleNetworkShared = async (req, url, env, ctx) => {
|
|
928
|
-
if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
|
|
929
|
-
const db = ctx.makeDb(env);
|
|
930
|
-
const secret = await getVaultSecret(db, "network_share_secret");
|
|
931
|
-
const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
|
|
932
|
-
if (!secret || provided.length !== secret.length || provided !== secret) {
|
|
933
|
-
return json({ error: "unauthorized" }, 401);
|
|
934
|
-
}
|
|
935
|
-
let payload;
|
|
936
|
-
try {
|
|
937
|
-
payload = JSON.parse(await req.text());
|
|
938
|
-
} catch {
|
|
939
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
940
|
-
}
|
|
941
|
-
if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
|
|
942
|
-
return json({ error: "hubRecordId is required" }, 400);
|
|
943
|
-
}
|
|
944
|
-
if ("input" in payload) {
|
|
945
|
-
if (payload.version !== 1 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
|
|
946
|
-
return json({ error: "version 1, type, and input are required" }, 400);
|
|
947
|
-
}
|
|
948
|
-
} else if (payload.type !== "company" && typeof payload.email !== "string") {
|
|
949
|
-
return json({ error: "legacy person shares require email" }, 400);
|
|
950
|
-
} else if (payload.type === "company" && typeof payload.name !== "string") {
|
|
951
|
-
return json({ error: "business shares require name" }, 400);
|
|
952
|
-
}
|
|
953
|
-
try {
|
|
954
|
-
const record = normalizeSharedRecord(payload);
|
|
955
|
-
const { recordId } = await projectSharedRecord(
|
|
956
|
-
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
957
|
-
record
|
|
958
|
-
);
|
|
959
|
-
return json({ recordId, type: record.type });
|
|
960
|
-
} catch (err) {
|
|
961
|
-
const message = err instanceof Error ? err.message : "invalid shared record";
|
|
962
|
-
return json({ error: message }, 400);
|
|
963
|
-
}
|
|
964
|
-
};
|
|
965
982
|
var handleMember = async (req, url, env, ctx) => {
|
|
966
983
|
const chapter = ctx.chapter;
|
|
967
984
|
if (chapter.mode !== "chapter") return null;
|
|
@@ -1058,6 +1075,62 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1058
1075
|
return null;
|
|
1059
1076
|
};
|
|
1060
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
|
+
|
|
1061
1134
|
// src/crm-sync.ts
|
|
1062
1135
|
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
1063
1136
|
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
@@ -2542,7 +2615,118 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
2542
2615
|
};
|
|
2543
2616
|
|
|
2544
2617
|
// src/worker-routes-network.ts
|
|
2545
|
-
import {
|
|
2618
|
+
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
2619
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
|
|
2620
|
+
|
|
2621
|
+
// src/worker-network-fetch.ts
|
|
2622
|
+
function targetFetcher(env, target) {
|
|
2623
|
+
if (!target.binding) return null;
|
|
2624
|
+
const candidate = env[target.binding];
|
|
2625
|
+
if (typeof candidate !== "object" || candidate === null || !("fetch" in candidate) || typeof candidate.fetch !== "function") {
|
|
2626
|
+
throw new Error(`service binding "${target.binding}" is unavailable`);
|
|
2627
|
+
}
|
|
2628
|
+
return candidate;
|
|
2629
|
+
}
|
|
2630
|
+
function fetchNetworkTarget(env, target, destination, init) {
|
|
2631
|
+
const binding = targetFetcher(env, target);
|
|
2632
|
+
return binding ? binding.fetch(new Request(destination, init)) : globalThis.fetch(destination, init);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/worker-network-push.ts
|
|
2636
|
+
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
2637
|
+
import { signFederatedRequest } from "@odla-ai/db";
|
|
2638
|
+
async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
2639
|
+
const secret = await getVaultSecret(db, target.secretName);
|
|
2640
|
+
const crmDeps3 = {
|
|
2641
|
+
crm: ctx.chapter.crm,
|
|
2642
|
+
db,
|
|
2643
|
+
now: () => Date.now(),
|
|
2644
|
+
newId: () => crypto.randomUUID()
|
|
2645
|
+
};
|
|
2646
|
+
if (!secret) {
|
|
2647
|
+
const error = `vault secret "${target.secretName}" is missing`;
|
|
2648
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2649
|
+
recordId: record.id,
|
|
2650
|
+
targetId: target.id,
|
|
2651
|
+
status: "failed",
|
|
2652
|
+
payloadVersion: 2,
|
|
2653
|
+
error
|
|
2654
|
+
});
|
|
2655
|
+
return { id: target.id, name: target.name, ok: false, error };
|
|
2656
|
+
}
|
|
2657
|
+
let payload;
|
|
2658
|
+
try {
|
|
2659
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
|
|
2660
|
+
} catch (err) {
|
|
2661
|
+
return {
|
|
2662
|
+
id: target.id,
|
|
2663
|
+
name: target.name,
|
|
2664
|
+
ok: false,
|
|
2665
|
+
error: err instanceof Error ? err.message : "record is not shareable"
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
const payloadBody = JSON.stringify(payload);
|
|
2669
|
+
const destination = new URL("/api/network/shared", target.url);
|
|
2670
|
+
try {
|
|
2671
|
+
const signed = await signFederatedRequest({
|
|
2672
|
+
secret,
|
|
2673
|
+
sender: ctx.chapter.id,
|
|
2674
|
+
method: "POST",
|
|
2675
|
+
url: destination,
|
|
2676
|
+
body: payloadBody
|
|
2677
|
+
});
|
|
2678
|
+
const res = await fetchNetworkTarget(env, target, destination, {
|
|
2679
|
+
method: "POST",
|
|
2680
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
2681
|
+
body: payloadBody,
|
|
2682
|
+
signal: AbortSignal.timeout(1e4)
|
|
2683
|
+
});
|
|
2684
|
+
const responseBody = await res.json().catch(() => ({}));
|
|
2685
|
+
if (!res.ok) {
|
|
2686
|
+
const error = responseBody.error ?? "follower rejected the record";
|
|
2687
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2688
|
+
recordId: record.id,
|
|
2689
|
+
targetId: target.id,
|
|
2690
|
+
status: "failed",
|
|
2691
|
+
payloadVersion: 2,
|
|
2692
|
+
error
|
|
2693
|
+
});
|
|
2694
|
+
return {
|
|
2695
|
+
id: target.id,
|
|
2696
|
+
name: target.name,
|
|
2697
|
+
ok: false,
|
|
2698
|
+
status: res.status,
|
|
2699
|
+
error
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2703
|
+
recordId: record.id,
|
|
2704
|
+
targetId: target.id,
|
|
2705
|
+
status: "delivered",
|
|
2706
|
+
payloadVersion: 2,
|
|
2707
|
+
...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
|
|
2708
|
+
});
|
|
2709
|
+
return {
|
|
2710
|
+
id: target.id,
|
|
2711
|
+
name: target.name,
|
|
2712
|
+
ok: true,
|
|
2713
|
+
status: res.status,
|
|
2714
|
+
recordId: responseBody.recordId
|
|
2715
|
+
};
|
|
2716
|
+
} catch (err) {
|
|
2717
|
+
const error = err instanceof Error ? err.message : "delivery failed";
|
|
2718
|
+
await recordDeliveryAttempt(crmDeps3, {
|
|
2719
|
+
recordId: record.id,
|
|
2720
|
+
targetId: target.id,
|
|
2721
|
+
status: "failed",
|
|
2722
|
+
payloadVersion: 2,
|
|
2723
|
+
error
|
|
2724
|
+
});
|
|
2725
|
+
return { id: target.id, name: target.name, ok: false, error };
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// src/worker-routes-network.ts
|
|
2546
2730
|
async function gate4(req, env, ctx) {
|
|
2547
2731
|
const db = ctx.makeDb(env);
|
|
2548
2732
|
const user = await ctx.verifyUser(req, env);
|
|
@@ -2563,35 +2747,95 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
|
2563
2747
|
}))
|
|
2564
2748
|
});
|
|
2565
2749
|
};
|
|
2566
|
-
async
|
|
2750
|
+
var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
2751
|
+
if (req.method !== "GET" || url.pathname !== "/api/network/snapshot") return null;
|
|
2752
|
+
const db = ctx.makeDb(env);
|
|
2753
|
+
const secret = await getVaultSecret(db, "network_share_secret");
|
|
2754
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2755
|
+
const verified = await verifyFederatedRequest2(req, { secret });
|
|
2756
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2757
|
+
const types = await Promise.all(
|
|
2758
|
+
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
2759
|
+
);
|
|
2760
|
+
const snapshot = {
|
|
2761
|
+
version: 1,
|
|
2762
|
+
site: { id: ctx.chapter.id, name: ctx.chapter.name, mode: ctx.chapter.mode },
|
|
2763
|
+
generatedAt: Date.now(),
|
|
2764
|
+
types
|
|
2765
|
+
};
|
|
2766
|
+
return json(snapshot);
|
|
2767
|
+
};
|
|
2768
|
+
async function snapshotOne(db, env, ctx, target) {
|
|
2567
2769
|
const secret = await getVaultSecret(db, target.secretName);
|
|
2568
|
-
if (!secret) return { id: target.id, name: target.name,
|
|
2569
|
-
|
|
2570
|
-
try {
|
|
2571
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm, record, target);
|
|
2572
|
-
} catch (err) {
|
|
2573
|
-
return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
|
|
2574
|
-
}
|
|
2770
|
+
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
2771
|
+
const destination = new URL("/api/network/snapshot", target.url);
|
|
2575
2772
|
try {
|
|
2576
|
-
const
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2773
|
+
const headers = await signFederatedRequest2({
|
|
2774
|
+
secret,
|
|
2775
|
+
sender: ctx.chapter.id,
|
|
2776
|
+
method: "GET",
|
|
2777
|
+
url: destination
|
|
2778
|
+
});
|
|
2779
|
+
const response = await fetchNetworkTarget(env, target, destination, {
|
|
2780
|
+
headers,
|
|
2580
2781
|
signal: AbortSignal.timeout(1e4)
|
|
2581
2782
|
});
|
|
2582
|
-
const body = await
|
|
2583
|
-
if (!
|
|
2584
|
-
|
|
2783
|
+
const body = await response.json().catch(() => null);
|
|
2784
|
+
if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.types)) {
|
|
2785
|
+
const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
|
|
2786
|
+
return {
|
|
2787
|
+
id: target.id,
|
|
2788
|
+
name: target.name,
|
|
2789
|
+
url: target.url,
|
|
2790
|
+
available: false,
|
|
2791
|
+
error: upstream ?? (response.ok ? "invalid follower snapshot" : `follower returned ${response.status}`)
|
|
2792
|
+
};
|
|
2585
2793
|
}
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2794
|
+
return {
|
|
2795
|
+
id: target.id,
|
|
2796
|
+
name: target.name,
|
|
2797
|
+
url: target.url,
|
|
2798
|
+
available: true,
|
|
2799
|
+
generatedAt: body.generatedAt,
|
|
2800
|
+
types: body.types
|
|
2801
|
+
};
|
|
2802
|
+
} catch (error) {
|
|
2803
|
+
return {
|
|
2804
|
+
id: target.id,
|
|
2805
|
+
name: target.name,
|
|
2806
|
+
url: target.url,
|
|
2807
|
+
available: false,
|
|
2808
|
+
error: error instanceof Error ? error.message : "snapshot failed"
|
|
2809
|
+
};
|
|
2593
2810
|
}
|
|
2594
2811
|
}
|
|
2812
|
+
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
2813
|
+
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
2814
|
+
const got = await gate4(req, env, ctx);
|
|
2815
|
+
if ("response" in got) return got.response;
|
|
2816
|
+
const targets = await Promise.all(
|
|
2817
|
+
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
2818
|
+
);
|
|
2819
|
+
const byType = /* @__PURE__ */ new Map();
|
|
2820
|
+
for (const target of targets) {
|
|
2821
|
+
for (const type of target.types ?? []) {
|
|
2822
|
+
const aggregate = byType.get(type.type) ?? { type: type.type, total: 0, stages: {} };
|
|
2823
|
+
aggregate.total += type.total;
|
|
2824
|
+
for (const [stage, count] of Object.entries(type.stages)) {
|
|
2825
|
+
aggregate.stages[stage] = (aggregate.stages[stage] ?? 0) + count;
|
|
2826
|
+
}
|
|
2827
|
+
byType.set(type.type, aggregate);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
const rollup = {
|
|
2831
|
+
configured: targets.length,
|
|
2832
|
+
available: targets.filter((target) => target.available).length,
|
|
2833
|
+
totalRecords: [...byType.values()].reduce((sum, type) => sum + type.total, 0),
|
|
2834
|
+
types: [...byType.values()],
|
|
2835
|
+
targets
|
|
2836
|
+
};
|
|
2837
|
+
return json(rollup);
|
|
2838
|
+
};
|
|
2595
2839
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
2596
2840
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
2597
2841
|
const got = await gate4(req, env, ctx);
|
|
@@ -2612,11 +2856,105 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
2612
2856
|
const record = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
2613
2857
|
if (!record) return json({ error: "record not found" }, 404);
|
|
2614
2858
|
const results = await Promise.all(
|
|
2615
|
-
targets.map((target) =>
|
|
2859
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
|
|
2616
2860
|
);
|
|
2617
2861
|
return json({ ok: results.every((result) => result.ok), results });
|
|
2618
2862
|
};
|
|
2619
2863
|
|
|
2864
|
+
// src/worker-routes-formation.ts
|
|
2865
|
+
import { createRecord as createRecord3 } from "@odla-ai/crm";
|
|
2866
|
+
|
|
2867
|
+
// src/formation.ts
|
|
2868
|
+
function formationFields(crm, formation) {
|
|
2869
|
+
const def = crm.type(formation.type);
|
|
2870
|
+
return [...formation.required, ...formation.optional].map((id) => {
|
|
2871
|
+
const field = def.fields[id];
|
|
2872
|
+
return {
|
|
2873
|
+
id,
|
|
2874
|
+
label: field.label ?? id,
|
|
2875
|
+
type: field.type,
|
|
2876
|
+
required: formation.required.includes(id),
|
|
2877
|
+
...field.options ? { options: field.options } : {}
|
|
2878
|
+
};
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/worker-routes-formation.ts
|
|
2883
|
+
var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
|
|
2884
|
+
var handleFormation = async (req, url, env, ctx) => {
|
|
2885
|
+
const formation = ctx.chapter.formation;
|
|
2886
|
+
if (!formation.enabled) return null;
|
|
2887
|
+
if (req.method === "GET" && url.pathname === "/api/formation/config") {
|
|
2888
|
+
return json({
|
|
2889
|
+
type: formation.type,
|
|
2890
|
+
fields: formationFields(ctx.chapter.crm, formation)
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
|
|
2894
|
+
const raw = await req.text();
|
|
2895
|
+
if (raw.length > formation.bodyCap) return json({ error: "request body too large" }, 413);
|
|
2896
|
+
let body;
|
|
2897
|
+
try {
|
|
2898
|
+
body = JSON.parse(raw);
|
|
2899
|
+
} catch {
|
|
2900
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2901
|
+
}
|
|
2902
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "JSON body must be an object" }, 400);
|
|
2903
|
+
const submissionId = typeof body.submissionId === "string" ? body.submissionId : void 0;
|
|
2904
|
+
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
2905
|
+
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
2906
|
+
}
|
|
2907
|
+
const allowed = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
2908
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed.has(field));
|
|
2909
|
+
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
2910
|
+
for (const field of formation.required) {
|
|
2911
|
+
const value = body[field];
|
|
2912
|
+
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
2913
|
+
}
|
|
2914
|
+
const input = {};
|
|
2915
|
+
for (const field of allowed) {
|
|
2916
|
+
const value = body[field];
|
|
2917
|
+
if (value === void 0) continue;
|
|
2918
|
+
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
2919
|
+
if (typeof value === "string") {
|
|
2920
|
+
if (value.length > cap) return json({ error: `${field} exceeds ${cap} characters` }, 400);
|
|
2921
|
+
input[field] = value.trim();
|
|
2922
|
+
} else if (Array.isArray(value)) {
|
|
2923
|
+
input[field] = value.slice(0, 100);
|
|
2924
|
+
} else {
|
|
2925
|
+
input[field] = value;
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
const db = ctx.makeDb(env);
|
|
2929
|
+
const id = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
|
|
2930
|
+
try {
|
|
2931
|
+
const created = await createRecord3(
|
|
2932
|
+
{
|
|
2933
|
+
crm: ctx.chapter.crm,
|
|
2934
|
+
db,
|
|
2935
|
+
now: () => Date.now(),
|
|
2936
|
+
newId: () => id
|
|
2937
|
+
},
|
|
2938
|
+
{
|
|
2939
|
+
type: formation.type,
|
|
2940
|
+
input,
|
|
2941
|
+
...submissionId ? { mutationId: `formation:${ctx.chapter.id}:${submissionId}` } : {}
|
|
2942
|
+
}
|
|
2943
|
+
);
|
|
2944
|
+
return json({
|
|
2945
|
+
id,
|
|
2946
|
+
duplicate: created.duplicate,
|
|
2947
|
+
status: created.record.stage ?? null
|
|
2948
|
+
}, created.duplicate ? 200 : 201);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
const detail = error && typeof error === "object" && "fields" in error ? error.fields : void 0;
|
|
2951
|
+
return json({
|
|
2952
|
+
error: error instanceof Error ? error.message : "invalid formation application",
|
|
2953
|
+
...detail ? { fields: detail } : {}
|
|
2954
|
+
}, 400);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
|
|
2620
2958
|
// src/worker.ts
|
|
2621
2959
|
var BUILTIN_ROUTES = [
|
|
2622
2960
|
handleHealth,
|
|
@@ -2624,6 +2962,8 @@ var BUILTIN_ROUTES = [
|
|
|
2624
2962
|
handleMe,
|
|
2625
2963
|
handleCrm,
|
|
2626
2964
|
handleNetworkShared,
|
|
2965
|
+
handleNetworkSnapshot,
|
|
2966
|
+
handleFormation,
|
|
2627
2967
|
handleMember,
|
|
2628
2968
|
handleSchedule,
|
|
2629
2969
|
handlePayments,
|
|
@@ -2651,6 +2991,7 @@ var BUILTIN_ROUTES = [
|
|
|
2651
2991
|
handleAdminComms,
|
|
2652
2992
|
// Leader → follower record delivery
|
|
2653
2993
|
handleAdminNetworkTargets,
|
|
2994
|
+
handleAdminNetworkRollup,
|
|
2654
2995
|
handleAdminNetworkPush,
|
|
2655
2996
|
// API requests must never fall through to an SPA asset response. Hosts still
|
|
2656
2997
|
// get first refusal through options.routes, then this terminates unknown API
|