@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/dist/index.cjs CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  findApplicationRef: () => findApplicationRef,
58
58
  firstPaymentPatch: () => firstPaymentPatch,
59
59
  formatChapterCopy: () => formatChapterCopy,
60
+ formationFields: () => formationFields,
60
61
  getVaultSecret: () => getVaultSecret,
61
62
  hasDisclaimerAck: () => hasDisclaimerAck,
62
63
  introIdempotencyKey: () => introIdempotencyKey,
@@ -66,6 +67,7 @@ __export(index_exports, {
66
67
  isSlotAvailable: () => isSlotAvailable,
67
68
  isValidEmail: () => isValidEmail,
68
69
  joinConfig: () => joinConfig,
70
+ leaderCrmConfig: () => leaderCrmConfig,
69
71
  meetingCreateRow: () => meetingCreateRow,
70
72
  meetingRescheduleUpdate: () => meetingRescheduleUpdate,
71
73
  memberApplication: () => memberApplication,
@@ -87,6 +89,7 @@ __export(index_exports, {
87
89
  resolveApplication: () => resolveApplication,
88
90
  resolveAuth: () => resolveAuth,
89
91
  resolveChapterCopy: () => resolveChapterCopy,
92
+ resolveLeaderFormation: () => resolveLeaderFormation,
90
93
  resolvePipeline: () => resolvePipeline,
91
94
  resolveScheduling: () => resolveScheduling,
92
95
  roleFromClaim: () => roleFromClaim,
@@ -630,12 +633,17 @@ var DEFAULT_ADMIN_COPY = {
630
633
  overview: "Overview",
631
634
  billing: "Billing",
632
635
  people: "People",
636
+ portfolio: "Portfolio",
633
637
  settings: "Settings",
634
638
  calendar: "Calendar",
635
639
  email: "Email",
640
+ chapters: "Chapters",
641
+ formations: "Formation applications",
642
+ dealFlow: "Deal flow",
636
643
  dashboardViewsLabel: "Dashboard views",
637
644
  settingsViewsLabel: "Settings views",
638
- collectionsLabel: "CRM collections"
645
+ collectionsLabel: "CRM collections",
646
+ portfolioViewsLabel: "Portfolio views"
639
647
  },
640
648
  dashboard: {
641
649
  loading: "Loading dashboard\u2026",
@@ -741,7 +749,19 @@ var DEFAULT_ADMIN_COPY = {
741
749
  shareWith: "Share with {target}",
742
750
  shared: "Shared with {target}.",
743
751
  shareFailed: "Could not share with {target}.",
744
- deliveryFailed: "Delivery failed."
752
+ deliveryFailed: "Delivery failed.",
753
+ loadingRollup: "Loading network overview\u2026",
754
+ loadRollupFailed: "Couldn't load the network overview",
755
+ overview: "Network overview",
756
+ configuredFollowers: "Configured followers",
757
+ reachableFollowers: "Reachable followers",
758
+ recordsAcrossFollowers: "Records across followers",
759
+ noFollowers: "No follower sites are configured.",
760
+ followers: "Followers",
761
+ available: "available",
762
+ unavailable: "unavailable",
763
+ recordTotals: "Portfolio records",
764
+ noRecordTotals: "No aggregate records are available yet."
745
765
  },
746
766
  records: {
747
767
  workflowMissing: "This record is not linked to an application workflow.",
@@ -899,9 +919,85 @@ function formatChapterCopy(template, values) {
899
919
  );
900
920
  }
901
921
 
922
+ // src/formation.ts
923
+ var DEFAULT_REQUIRED2 = ["name", "email", "location", "thesis"];
924
+ var DEFAULT_OPTIONAL2 = ["proposedChapter", "experience", "collaborators"];
925
+ function resolveLeaderFormation(input, crm) {
926
+ const type = input?.type ?? "chapter_application";
927
+ if (!input) {
928
+ return {
929
+ enabled: false,
930
+ type,
931
+ required: [],
932
+ optional: [],
933
+ maxLen: {},
934
+ defaultMaxLen: 4e3,
935
+ bodyCap: 32768
936
+ };
937
+ }
938
+ const def = crm.config.types[type];
939
+ if (!def) throw new Error(`defineChapter.formation.type: unknown CRM type "${type}"`);
940
+ const required = input.required ?? (type === "chapter_application" ? DEFAULT_REQUIRED2 : []);
941
+ const optional = input.optional ?? (type === "chapter_application" ? DEFAULT_OPTIONAL2 : []);
942
+ for (const [name, values] of [["required", required], ["optional", optional]]) {
943
+ if (!Array.isArray(values) || !values.every((field) => typeof field === "string" && field !== "")) {
944
+ throw new Error(`defineChapter.formation.${name}: must be an array of field-name strings`);
945
+ }
946
+ }
947
+ const duplicates = [...required, ...optional].filter((field, index, fields) => fields.indexOf(field) !== index);
948
+ if (duplicates.length) throw new Error(`defineChapter.formation: duplicate public field "${duplicates[0]}"`);
949
+ const unknown = [...required, ...optional].filter((field) => !def.fields[field]);
950
+ if (unknown.length) throw new Error(`defineChapter.formation: field "${unknown[0]}" is not declared on CRM type "${type}"`);
951
+ const missingRequired = Object.entries(def.fields).filter(([, field]) => field.required).map(([field]) => field).filter((field) => !required.includes(field));
952
+ if (missingRequired.length) {
953
+ throw new Error(`defineChapter.formation.required: must include required CRM field "${missingRequired[0]}"`);
954
+ }
955
+ const unsupportedRequired = required.find((field) => !["string", "email", "url", "enum"].includes(def.fields[field].type));
956
+ if (unsupportedRequired) {
957
+ throw new Error(`defineChapter.formation.required: field "${unsupportedRequired}" must be string-like`);
958
+ }
959
+ if (required.length === 0) throw new Error("defineChapter.formation.required: must not be empty");
960
+ const defaultMaxLen = input.defaultMaxLen ?? 4e3;
961
+ const bodyCap = input.bodyCap ?? 32768;
962
+ if (!Number.isSafeInteger(defaultMaxLen) || defaultMaxLen < 1 || defaultMaxLen > 32768) {
963
+ throw new Error("defineChapter.formation.defaultMaxLen: must be an integer from 1 to 32768");
964
+ }
965
+ if (!Number.isSafeInteger(bodyCap) || bodyCap < 1024 || bodyCap > 131072) {
966
+ throw new Error("defineChapter.formation.bodyCap: must be an integer from 1024 to 131072");
967
+ }
968
+ for (const [field, cap] of Object.entries(input.maxLen ?? {})) {
969
+ if (![...required, ...optional].includes(field) || !Number.isSafeInteger(cap) || cap < 1 || cap > 32768) {
970
+ throw new Error(`defineChapter.formation.maxLen.${field}: must name a public field and be an integer from 1 to 32768`);
971
+ }
972
+ }
973
+ return {
974
+ enabled: true,
975
+ type,
976
+ required,
977
+ optional,
978
+ maxLen: input.maxLen ?? {},
979
+ defaultMaxLen,
980
+ bodyCap
981
+ };
982
+ }
983
+ function formationFields(crm, formation) {
984
+ const def = crm.type(formation.type);
985
+ return [...formation.required, ...formation.optional].map((id2) => {
986
+ const field = def.fields[id2];
987
+ return {
988
+ id: id2,
989
+ label: field.label ?? id2,
990
+ type: field.type,
991
+ required: formation.required.includes(id2),
992
+ ...field.options ? { options: field.options } : {}
993
+ };
994
+ });
995
+ }
996
+
902
997
  // src/config.ts
903
998
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
904
999
  var FIELD = /^[a-z][a-zA-Z0-9_]*$/;
1000
+ var BINDING = /^[A-Z][A-Z0-9_]{0,127}$/;
905
1001
  function resolveNetwork(config, crm) {
906
1002
  const seen = /* @__PURE__ */ new Set();
907
1003
  const targets = [];
@@ -947,10 +1043,17 @@ function resolveNetwork(config, crm) {
947
1043
  if (!/^[a-zA-Z][a-zA-Z0-9_-]{1,127}$/.test(secretName)) {
948
1044
  throw new Error(`defineChapter.network.targets.${target.id}.secretName: must be a vault-key identifier`);
949
1045
  }
1046
+ const binding = target.binding?.trim();
1047
+ if (binding && !BINDING.test(binding)) {
1048
+ throw new Error(
1049
+ `defineChapter.network.targets.${target.id}.binding: must be an uppercase Worker binding identifier`
1050
+ );
1051
+ }
950
1052
  targets.push({
951
1053
  id: target.id,
952
1054
  name: target.name?.trim() || target.id,
953
1055
  url: url.origin,
1056
+ ...binding ? { binding } : {},
954
1057
  secretName,
955
1058
  ...fields ? { fields } : {}
956
1059
  });
@@ -987,6 +1090,7 @@ function defineChapter(config) {
987
1090
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
988
1091
  const copy = resolveChapterCopy(config.copy);
989
1092
  const network = resolveNetwork(config, crm);
1093
+ const formation = resolveLeaderFormation(config.formation, crm);
990
1094
  const { schema, rules } = chapterDb(mode, auth);
991
1095
  const services = config.services ?? ["db", "calendar", "o11y"];
992
1096
  const account = config.account ?? "none";
@@ -1042,6 +1146,7 @@ function defineChapter(config) {
1042
1146
  brand,
1043
1147
  copy,
1044
1148
  network,
1149
+ formation,
1045
1150
  crm,
1046
1151
  auth,
1047
1152
  pipeline,
@@ -1058,13 +1163,154 @@ function defineChapter(config) {
1058
1163
  return chapter;
1059
1164
  }
1060
1165
 
1061
- // src/descriptor.ts
1166
+ // src/leader.ts
1062
1167
  var import_crm2 = require("@odla-ai/crm");
1168
+ function leaderCrmConfig(options = {}) {
1169
+ const base = options.base ?? defaultCrm("hub");
1170
+ const portfolio = {
1171
+ types: {
1172
+ chapter: {
1173
+ label: "Chapter",
1174
+ labelPlural: "Chapters",
1175
+ workspace: "portfolio",
1176
+ nameField: "name",
1177
+ fields: {
1178
+ name: { type: "string", label: "Name", required: true },
1179
+ slug: { type: "string", label: "Slug", slot: "s1" },
1180
+ region: { type: "string", label: "Region", slot: "s2" },
1181
+ url: { type: "url", label: "Website" },
1182
+ thesis: { type: "string", label: "Investment thesis" },
1183
+ launchedAt: { type: "date", label: "Launch date", slot: "d1" },
1184
+ notes: { type: "string", label: "Notes" }
1185
+ },
1186
+ pipeline: {
1187
+ stages: [
1188
+ { id: "proposed", label: "Proposed" },
1189
+ { id: "forming", label: "Forming" },
1190
+ { id: "active", label: "Active" },
1191
+ { id: "paused", label: "Paused" },
1192
+ { id: "closed", label: "Closed", terminal: true }
1193
+ ],
1194
+ transitions: {
1195
+ proposed: ["forming", "closed"],
1196
+ forming: ["active", "paused", "closed"],
1197
+ active: ["paused", "closed"],
1198
+ paused: ["forming", "active", "closed"]
1199
+ }
1200
+ },
1201
+ facets: { rank: "manual" }
1202
+ },
1203
+ chapter_application: {
1204
+ label: "Chapter application",
1205
+ labelPlural: "Formation applications",
1206
+ workspace: "portfolio",
1207
+ nameField: "name",
1208
+ emailField: "email",
1209
+ fields: {
1210
+ name: { type: "string", label: "Applicant name", required: true },
1211
+ email: { type: "email", label: "Email", required: true },
1212
+ location: { type: "string", label: "Location", required: true, slot: "s1" },
1213
+ proposedChapter: { type: "string", label: "Proposed chapter", slot: "s2" },
1214
+ thesis: { type: "string", label: "Proposed thesis", required: true },
1215
+ experience: { type: "string", label: "Relevant experience" },
1216
+ collaborators: { type: "json", label: "Collaborators" },
1217
+ notes: { type: "string", label: "Internal notes" }
1218
+ },
1219
+ pipeline: {
1220
+ stages: [
1221
+ { id: "submitted", label: "Submitted" },
1222
+ { id: "reviewing", label: "Reviewing" },
1223
+ { id: "interviewing", label: "Interviewing" },
1224
+ { id: "approved", label: "Approved" },
1225
+ { id: "forming", label: "Forming" },
1226
+ { id: "declined", label: "Declined", terminal: true }
1227
+ ],
1228
+ transitions: {
1229
+ submitted: ["reviewing", "declined"],
1230
+ reviewing: ["interviewing", "approved", "declined"],
1231
+ interviewing: ["approved", "declined"],
1232
+ approved: ["forming", "declined"]
1233
+ }
1234
+ },
1235
+ facets: { email: true, rank: "manual" }
1236
+ },
1237
+ deal: {
1238
+ label: "Deal",
1239
+ labelPlural: "Deal flow",
1240
+ workspace: "portfolio",
1241
+ nameField: "name",
1242
+ fields: {
1243
+ name: { type: "string", label: "Company or deal", required: true },
1244
+ chapter: { type: "string", label: "Source chapter", slot: "s1" },
1245
+ sector: { type: "string", label: "Sector", slot: "s2" },
1246
+ source: { type: "string", label: "Source", slot: "s3" },
1247
+ website: { type: "url", label: "Website" },
1248
+ amount: { type: "number", label: "Target amount", slot: "n1" },
1249
+ notes: { type: "string", label: "Notes" }
1250
+ },
1251
+ pipeline: {
1252
+ stages: [
1253
+ { id: "sourced", label: "Sourced" },
1254
+ { id: "screening", label: "Screening" },
1255
+ { id: "diligence", label: "Diligence" },
1256
+ { id: "committee", label: "Committee" },
1257
+ { id: "offered", label: "Offered" },
1258
+ { id: "invested", label: "Invested", terminal: true },
1259
+ { id: "passed", label: "Passed", terminal: true }
1260
+ ],
1261
+ transitions: {
1262
+ sourced: ["screening", "passed"],
1263
+ screening: ["diligence", "committee", "passed"],
1264
+ diligence: ["committee", "offered", "passed"],
1265
+ committee: ["offered", "passed"],
1266
+ offered: ["invested", "passed"],
1267
+ passed: ["sourced"]
1268
+ }
1269
+ },
1270
+ facets: { rank: "manual" }
1271
+ }
1272
+ },
1273
+ relations: {
1274
+ formation_result: {
1275
+ from: "chapter_application",
1276
+ to: "chapter",
1277
+ label: "formed as",
1278
+ reverseLabel: "formation application"
1279
+ },
1280
+ chapter_deal: {
1281
+ from: "deal",
1282
+ to: "chapter",
1283
+ label: "sourced by",
1284
+ reverseLabel: "deal flow"
1285
+ }
1286
+ }
1287
+ };
1288
+ if (base.types.company) {
1289
+ portfolio.relations.deal_company = {
1290
+ from: "deal",
1291
+ to: "company",
1292
+ label: "concerns",
1293
+ reverseLabel: "deals"
1294
+ };
1295
+ }
1296
+ if (base.types.person) {
1297
+ portfolio.relations.application_contact = {
1298
+ from: "person",
1299
+ to: "chapter_application",
1300
+ label: "submitted",
1301
+ reverseLabel: "applicant"
1302
+ };
1303
+ }
1304
+ return (0, import_crm2.composeCrmConfigs)(base, portfolio);
1305
+ }
1306
+
1307
+ // src/descriptor.ts
1308
+ var import_crm3 = require("@odla-ai/crm");
1063
1309
  function createChapterIntegration(chapter, options = {}) {
1064
1310
  const basePath = options.basePath ?? "/api/crm";
1065
1311
  const now = options.now ?? Date.now();
1066
1312
  const emails = chapter.config.emails;
1067
- const crmDesc = (0, import_crm2.createCrmIntegration)(chapter.crm, {
1313
+ const crmDesc = (0, import_crm3.createCrmIntegration)(chapter.crm, {
1068
1314
  basePath,
1069
1315
  now,
1070
1316
  ...emails?.notificationEmail ? { notificationEmail: emails.notificationEmail } : {},
@@ -1096,7 +1342,15 @@ function createChapterIntegration(chapter, options = {}) {
1096
1342
  },
1097
1343
  rules: { ...crmDesc.rules, ...chapter.rules },
1098
1344
  seeds,
1099
- probes: [...crmDesc.probes ?? []],
1345
+ probes: [
1346
+ ...crmDesc.probes ?? [],
1347
+ // The snapshot route is deliberately private. A credential-free smoke
1348
+ // request must prove that the route is mounted and closed.
1349
+ { path: "/api/network/snapshot", expectedStatus: 401 },
1350
+ // Formation metadata is public only when the host explicitly enables
1351
+ // the bounded intake contract.
1352
+ ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : []
1353
+ ],
1100
1354
  runbooks
1101
1355
  };
1102
1356
  }
@@ -1300,11 +1554,15 @@ function canceledPatch() {
1300
1554
  }
1301
1555
 
1302
1556
  // src/network.ts
1303
- var import_crm3 = require("@odla-ai/crm");
1557
+ var import_crm4 = require("@odla-ai/crm");
1558
+
1559
+ // src/network-contract.ts
1304
1560
  var DEFAULT_SHARE_FIELDS = {
1305
1561
  person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
1306
1562
  company: ["name", "domain", "industry", "location", "linkedin", "notes"]
1307
1563
  };
1564
+
1565
+ // src/network.ts
1308
1566
  function sharedPersonInput(person) {
1309
1567
  const email = person.email.toLowerCase();
1310
1568
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -1326,25 +1584,55 @@ function shortHash(value) {
1326
1584
  }
1327
1585
  return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
1328
1586
  }
1329
- function networkSourceTag(type, hubRecordId) {
1587
+ function networkSourceTag(type, hubRecordId, sourceId) {
1330
1588
  const typeKey = type.toLowerCase();
1331
1589
  const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
1332
- const raw = `network:${typeKey}:${hubRecordId}`;
1590
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
1591
+ const raw = `${prefix}:${hubRecordId}`;
1333
1592
  if (readable && raw.length <= 64) return raw;
1334
- return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
1335
- }
1336
- function normalizeSharedRecord(record) {
1337
- if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
1593
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
1594
+ }
1595
+ function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
1596
+ if ("input" in record && record.version === 2) {
1597
+ return {
1598
+ version: 2,
1599
+ sourceId: record.source.siteId,
1600
+ sourceRecordId: record.source.recordId,
1601
+ type: record.type,
1602
+ input: record.input
1603
+ };
1604
+ }
1605
+ if ("input" in record) {
1606
+ return {
1607
+ version: 1,
1608
+ sourceId: fallbackSourceId,
1609
+ sourceRecordId: record.hubRecordId,
1610
+ type: record.type,
1611
+ input: record.input
1612
+ };
1613
+ }
1338
1614
  if ("type" in record && record.type === "company") {
1339
1615
  const input = { name: record.name };
1340
1616
  for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
1341
1617
  if (record[key]) input[key] = record[key];
1342
1618
  }
1343
- return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
1619
+ return {
1620
+ version: 1,
1621
+ sourceId: fallbackSourceId,
1622
+ sourceRecordId: record.hubRecordId,
1623
+ type: "company",
1624
+ input
1625
+ };
1344
1626
  }
1345
- return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
1627
+ return {
1628
+ version: 1,
1629
+ sourceId: fallbackSourceId,
1630
+ sourceRecordId: record.hubRecordId,
1631
+ type: "person",
1632
+ input: sharedPersonInput(record)
1633
+ };
1346
1634
  }
1347
- function sharedRecordFromCrm(crm, record, target) {
1635
+ function sharedRecordFromCrm(crm, record, target, sourceId) {
1348
1636
  if (target.fields && !target.fields[record.type]) {
1349
1637
  throw new Error(`${target.name} does not accept "${record.type}" records`);
1350
1638
  }
@@ -1360,7 +1648,7 @@ function sharedRecordFromCrm(crm, record, target) {
1360
1648
  if (value !== void 0) input[field] = value;
1361
1649
  }
1362
1650
  if (input[nameField] === void 0) input[nameField] = record.name;
1363
- return { version: 1, type: record.type, hubRecordId: record.id, input };
1651
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
1364
1652
  }
1365
1653
  async function upsertPerson(deps, opts) {
1366
1654
  const email = opts.email.toLowerCase();
@@ -1368,13 +1656,19 @@ async function upsertPerson(deps, opts) {
1368
1656
  const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1369
1657
  const existing = crm_record?.[0];
1370
1658
  if (existing && typeof existing.id === "string") {
1371
- await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1659
+ await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1372
1660
  return { recordId: existing.id };
1373
1661
  }
1374
- const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1662
+ const created = await (0, import_crm4.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1375
1663
  return { recordId: created.id };
1376
1664
  }
1377
1665
  async function findSharedRecord(deps, record, tag) {
1666
+ const structured = await (0, import_crm4.getRecordByOrigin)(
1667
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1668
+ record.sourceId,
1669
+ record.sourceRecordId
1670
+ );
1671
+ if (structured) return structured;
1378
1672
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1379
1673
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1380
1674
  if (typeof mappedId === "string") {
@@ -1408,21 +1702,24 @@ async function findSharedRecord(deps, record, tag) {
1408
1702
  }
1409
1703
  return void 0;
1410
1704
  }
1411
- async function projectSharedRecord(deps, shared) {
1412
- const record = normalizeSharedRecord(shared);
1413
- if (!record.type.trim() || !record.hubRecordId.trim()) {
1414
- throw new Error("type and hubRecordId must be non-empty");
1705
+ async function projectSharedRecord(deps, shared, options = {}) {
1706
+ const record = normalizeSharedRecord(shared, options.sourceId);
1707
+ if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1708
+ throw new Error("type, source site id, and source record id must be non-empty");
1709
+ }
1710
+ if (options.sourceId && options.sourceId !== record.sourceId) {
1711
+ throw new Error("signed sender does not match payload source");
1415
1712
  }
1416
- const tag = networkSourceTag(record.type, record.hubRecordId);
1713
+ const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1417
1714
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1418
1715
  const existing = await findSharedRecord(deps, record, tag);
1419
1716
  let recordId;
1420
1717
  if (existing && typeof existing.id === "string") {
1421
- await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1718
+ await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1422
1719
  recordId = existing.id;
1423
1720
  } else {
1424
- recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
1425
- await (0, import_crm3.createRecord)({ ...crmDeps, newId: () => recordId }, {
1721
+ recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1722
+ await (0, import_crm4.createRecord)({ ...crmDeps, newId: () => recordId }, {
1426
1723
  type: record.type,
1427
1724
  input: record.input,
1428
1725
  mutationId: `share-create:${tag}`
@@ -1432,6 +1729,13 @@ async function projectSharedRecord(deps, shared) {
1432
1729
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1433
1730
  { mutationId: `share-map:${tag}:${recordId}` }
1434
1731
  );
1732
+ await (0, import_crm4.upsertRecordOrigin)(crmDeps, {
1733
+ recordId,
1734
+ sourceId: record.sourceId,
1735
+ sourceRecordId: record.sourceRecordId,
1736
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
1737
+ payloadVersion: record.version
1738
+ });
1435
1739
  return { recordId };
1436
1740
  }
1437
1741
  async function projectApplicant(deps, applicant) {
@@ -1454,7 +1758,7 @@ async function projectApplicant(deps, applicant) {
1454
1758
  }
1455
1759
 
1456
1760
  // src/crm-sync.ts
1457
- var import_crm4 = require("@odla-ai/crm");
1761
+ var import_crm5 = require("@odla-ai/crm");
1458
1762
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1459
1763
  function personInputFromApp(chapter, app) {
1460
1764
  const input = sharedPersonInput({
@@ -1494,16 +1798,16 @@ async function syncApplicationToCrm(deps, opts) {
1494
1798
  let recordId;
1495
1799
  if (existing && typeof existing.id === "string") {
1496
1800
  recordId = existing.id;
1497
- await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
1801
+ await (0, import_crm5.updateRecord)(recordDeps, { id: recordId, input });
1498
1802
  } else {
1499
- const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1803
+ const created = await (0, import_crm5.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1500
1804
  recordId = created.id;
1501
1805
  }
1502
1806
  if (existing && stage && existing.stage !== stage) {
1503
- await (0, import_crm4.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1807
+ await (0, import_crm5.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1504
1808
  }
1505
1809
  await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1506
- await (0, import_crm4.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1810
+ await (0, import_crm5.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1507
1811
  return recordId;
1508
1812
  }
1509
1813
  async function backfillCrm(deps) {