@odla-ai/chapter 0.25.8 → 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/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,6 +919,81 @@ 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_]*$/;
@@ -987,6 +1082,7 @@ function defineChapter(config) {
987
1082
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
988
1083
  const copy = resolveChapterCopy(config.copy);
989
1084
  const network = resolveNetwork(config, crm);
1085
+ const formation = resolveLeaderFormation(config.formation, crm);
990
1086
  const { schema, rules } = chapterDb(mode, auth);
991
1087
  const services = config.services ?? ["db", "calendar", "o11y"];
992
1088
  const account = config.account ?? "none";
@@ -1042,6 +1138,7 @@ function defineChapter(config) {
1042
1138
  brand,
1043
1139
  copy,
1044
1140
  network,
1141
+ formation,
1045
1142
  crm,
1046
1143
  auth,
1047
1144
  pipeline,
@@ -1058,13 +1155,154 @@ function defineChapter(config) {
1058
1155
  return chapter;
1059
1156
  }
1060
1157
 
1061
- // src/descriptor.ts
1158
+ // src/leader.ts
1062
1159
  var import_crm2 = require("@odla-ai/crm");
1160
+ function leaderCrmConfig(options = {}) {
1161
+ const base = options.base ?? defaultCrm("hub");
1162
+ const portfolio = {
1163
+ types: {
1164
+ chapter: {
1165
+ label: "Chapter",
1166
+ labelPlural: "Chapters",
1167
+ workspace: "portfolio",
1168
+ nameField: "name",
1169
+ fields: {
1170
+ name: { type: "string", label: "Name", required: true },
1171
+ slug: { type: "string", label: "Slug", slot: "s1" },
1172
+ region: { type: "string", label: "Region", slot: "s2" },
1173
+ url: { type: "url", label: "Website" },
1174
+ thesis: { type: "string", label: "Investment thesis" },
1175
+ launchedAt: { type: "date", label: "Launch date", slot: "d1" },
1176
+ notes: { type: "string", label: "Notes" }
1177
+ },
1178
+ pipeline: {
1179
+ stages: [
1180
+ { id: "proposed", label: "Proposed" },
1181
+ { id: "forming", label: "Forming" },
1182
+ { id: "active", label: "Active" },
1183
+ { id: "paused", label: "Paused" },
1184
+ { id: "closed", label: "Closed", terminal: true }
1185
+ ],
1186
+ transitions: {
1187
+ proposed: ["forming", "closed"],
1188
+ forming: ["active", "paused", "closed"],
1189
+ active: ["paused", "closed"],
1190
+ paused: ["forming", "active", "closed"]
1191
+ }
1192
+ },
1193
+ facets: { rank: "manual" }
1194
+ },
1195
+ chapter_application: {
1196
+ label: "Chapter application",
1197
+ labelPlural: "Formation applications",
1198
+ workspace: "portfolio",
1199
+ nameField: "name",
1200
+ emailField: "email",
1201
+ fields: {
1202
+ name: { type: "string", label: "Applicant name", required: true },
1203
+ email: { type: "email", label: "Email", required: true },
1204
+ location: { type: "string", label: "Location", required: true, slot: "s1" },
1205
+ proposedChapter: { type: "string", label: "Proposed chapter", slot: "s2" },
1206
+ thesis: { type: "string", label: "Proposed thesis", required: true },
1207
+ experience: { type: "string", label: "Relevant experience" },
1208
+ collaborators: { type: "json", label: "Collaborators" },
1209
+ notes: { type: "string", label: "Internal notes" }
1210
+ },
1211
+ pipeline: {
1212
+ stages: [
1213
+ { id: "submitted", label: "Submitted" },
1214
+ { id: "reviewing", label: "Reviewing" },
1215
+ { id: "interviewing", label: "Interviewing" },
1216
+ { id: "approved", label: "Approved" },
1217
+ { id: "forming", label: "Forming" },
1218
+ { id: "declined", label: "Declined", terminal: true }
1219
+ ],
1220
+ transitions: {
1221
+ submitted: ["reviewing", "declined"],
1222
+ reviewing: ["interviewing", "approved", "declined"],
1223
+ interviewing: ["approved", "declined"],
1224
+ approved: ["forming", "declined"]
1225
+ }
1226
+ },
1227
+ facets: { email: true, rank: "manual" }
1228
+ },
1229
+ deal: {
1230
+ label: "Deal",
1231
+ labelPlural: "Deal flow",
1232
+ workspace: "portfolio",
1233
+ nameField: "name",
1234
+ fields: {
1235
+ name: { type: "string", label: "Company or deal", required: true },
1236
+ chapter: { type: "string", label: "Source chapter", slot: "s1" },
1237
+ sector: { type: "string", label: "Sector", slot: "s2" },
1238
+ source: { type: "string", label: "Source", slot: "s3" },
1239
+ website: { type: "url", label: "Website" },
1240
+ amount: { type: "number", label: "Target amount", slot: "n1" },
1241
+ notes: { type: "string", label: "Notes" }
1242
+ },
1243
+ pipeline: {
1244
+ stages: [
1245
+ { id: "sourced", label: "Sourced" },
1246
+ { id: "screening", label: "Screening" },
1247
+ { id: "diligence", label: "Diligence" },
1248
+ { id: "committee", label: "Committee" },
1249
+ { id: "offered", label: "Offered" },
1250
+ { id: "invested", label: "Invested", terminal: true },
1251
+ { id: "passed", label: "Passed", terminal: true }
1252
+ ],
1253
+ transitions: {
1254
+ sourced: ["screening", "passed"],
1255
+ screening: ["diligence", "committee", "passed"],
1256
+ diligence: ["committee", "offered", "passed"],
1257
+ committee: ["offered", "passed"],
1258
+ offered: ["invested", "passed"],
1259
+ passed: ["sourced"]
1260
+ }
1261
+ },
1262
+ facets: { rank: "manual" }
1263
+ }
1264
+ },
1265
+ relations: {
1266
+ formation_result: {
1267
+ from: "chapter_application",
1268
+ to: "chapter",
1269
+ label: "formed as",
1270
+ reverseLabel: "formation application"
1271
+ },
1272
+ chapter_deal: {
1273
+ from: "deal",
1274
+ to: "chapter",
1275
+ label: "sourced by",
1276
+ reverseLabel: "deal flow"
1277
+ }
1278
+ }
1279
+ };
1280
+ if (base.types.company) {
1281
+ portfolio.relations.deal_company = {
1282
+ from: "deal",
1283
+ to: "company",
1284
+ label: "concerns",
1285
+ reverseLabel: "deals"
1286
+ };
1287
+ }
1288
+ if (base.types.person) {
1289
+ portfolio.relations.application_contact = {
1290
+ from: "person",
1291
+ to: "chapter_application",
1292
+ label: "submitted",
1293
+ reverseLabel: "applicant"
1294
+ };
1295
+ }
1296
+ return (0, import_crm2.composeCrmConfigs)(base, portfolio);
1297
+ }
1298
+
1299
+ // src/descriptor.ts
1300
+ var import_crm3 = require("@odla-ai/crm");
1063
1301
  function createChapterIntegration(chapter, options = {}) {
1064
1302
  const basePath = options.basePath ?? "/api/crm";
1065
1303
  const now = options.now ?? Date.now();
1066
1304
  const emails = chapter.config.emails;
1067
- const crmDesc = (0, import_crm2.createCrmIntegration)(chapter.crm, {
1305
+ const crmDesc = (0, import_crm3.createCrmIntegration)(chapter.crm, {
1068
1306
  basePath,
1069
1307
  now,
1070
1308
  ...emails?.notificationEmail ? { notificationEmail: emails.notificationEmail } : {},
@@ -1096,7 +1334,15 @@ function createChapterIntegration(chapter, options = {}) {
1096
1334
  },
1097
1335
  rules: { ...crmDesc.rules, ...chapter.rules },
1098
1336
  seeds,
1099
- probes: [...crmDesc.probes ?? []],
1337
+ probes: [
1338
+ ...crmDesc.probes ?? [],
1339
+ // The snapshot route is deliberately private. A credential-free smoke
1340
+ // request must prove that the route is mounted and closed.
1341
+ { path: "/api/network/snapshot", expectedStatus: 401 },
1342
+ // Formation metadata is public only when the host explicitly enables
1343
+ // the bounded intake contract.
1344
+ ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : []
1345
+ ],
1100
1346
  runbooks
1101
1347
  };
1102
1348
  }
@@ -1300,11 +1546,15 @@ function canceledPatch() {
1300
1546
  }
1301
1547
 
1302
1548
  // src/network.ts
1303
- var import_crm3 = require("@odla-ai/crm");
1549
+ var import_crm4 = require("@odla-ai/crm");
1550
+
1551
+ // src/network-contract.ts
1304
1552
  var DEFAULT_SHARE_FIELDS = {
1305
1553
  person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
1306
1554
  company: ["name", "domain", "industry", "location", "linkedin", "notes"]
1307
1555
  };
1556
+
1557
+ // src/network.ts
1308
1558
  function sharedPersonInput(person) {
1309
1559
  const email = person.email.toLowerCase();
1310
1560
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -1326,25 +1576,55 @@ function shortHash(value) {
1326
1576
  }
1327
1577
  return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
1328
1578
  }
1329
- function networkSourceTag(type, hubRecordId) {
1579
+ function networkSourceTag(type, hubRecordId, sourceId) {
1330
1580
  const typeKey = type.toLowerCase();
1331
1581
  const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
1332
- const raw = `network:${typeKey}:${hubRecordId}`;
1582
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
1583
+ const raw = `${prefix}:${hubRecordId}`;
1333
1584
  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 };
1585
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
1586
+ }
1587
+ function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
1588
+ if ("input" in record && record.version === 2) {
1589
+ return {
1590
+ version: 2,
1591
+ sourceId: record.source.siteId,
1592
+ sourceRecordId: record.source.recordId,
1593
+ type: record.type,
1594
+ input: record.input
1595
+ };
1596
+ }
1597
+ if ("input" in record) {
1598
+ return {
1599
+ version: 1,
1600
+ sourceId: fallbackSourceId,
1601
+ sourceRecordId: record.hubRecordId,
1602
+ type: record.type,
1603
+ input: record.input
1604
+ };
1605
+ }
1338
1606
  if ("type" in record && record.type === "company") {
1339
1607
  const input = { name: record.name };
1340
1608
  for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
1341
1609
  if (record[key]) input[key] = record[key];
1342
1610
  }
1343
- return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
1611
+ return {
1612
+ version: 1,
1613
+ sourceId: fallbackSourceId,
1614
+ sourceRecordId: record.hubRecordId,
1615
+ type: "company",
1616
+ input
1617
+ };
1344
1618
  }
1345
- return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
1619
+ return {
1620
+ version: 1,
1621
+ sourceId: fallbackSourceId,
1622
+ sourceRecordId: record.hubRecordId,
1623
+ type: "person",
1624
+ input: sharedPersonInput(record)
1625
+ };
1346
1626
  }
1347
- function sharedRecordFromCrm(crm, record, target) {
1627
+ function sharedRecordFromCrm(crm, record, target, sourceId) {
1348
1628
  if (target.fields && !target.fields[record.type]) {
1349
1629
  throw new Error(`${target.name} does not accept "${record.type}" records`);
1350
1630
  }
@@ -1360,7 +1640,7 @@ function sharedRecordFromCrm(crm, record, target) {
1360
1640
  if (value !== void 0) input[field] = value;
1361
1641
  }
1362
1642
  if (input[nameField] === void 0) input[nameField] = record.name;
1363
- return { version: 1, type: record.type, hubRecordId: record.id, input };
1643
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
1364
1644
  }
1365
1645
  async function upsertPerson(deps, opts) {
1366
1646
  const email = opts.email.toLowerCase();
@@ -1368,13 +1648,19 @@ async function upsertPerson(deps, opts) {
1368
1648
  const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1369
1649
  const existing = crm_record?.[0];
1370
1650
  if (existing && typeof existing.id === "string") {
1371
- await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1651
+ await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1372
1652
  return { recordId: existing.id };
1373
1653
  }
1374
- const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1654
+ const created = await (0, import_crm4.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1375
1655
  return { recordId: created.id };
1376
1656
  }
1377
1657
  async function findSharedRecord(deps, record, tag) {
1658
+ const structured = await (0, import_crm4.getRecordByOrigin)(
1659
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1660
+ record.sourceId,
1661
+ record.sourceRecordId
1662
+ );
1663
+ if (structured) return structured;
1378
1664
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1379
1665
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1380
1666
  if (typeof mappedId === "string") {
@@ -1408,21 +1694,24 @@ async function findSharedRecord(deps, record, tag) {
1408
1694
  }
1409
1695
  return void 0;
1410
1696
  }
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");
1697
+ async function projectSharedRecord(deps, shared, options = {}) {
1698
+ const record = normalizeSharedRecord(shared, options.sourceId);
1699
+ if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1700
+ throw new Error("type, source site id, and source record id must be non-empty");
1701
+ }
1702
+ if (options.sourceId && options.sourceId !== record.sourceId) {
1703
+ throw new Error("signed sender does not match payload source");
1415
1704
  }
1416
- const tag = networkSourceTag(record.type, record.hubRecordId);
1705
+ const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1417
1706
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1418
1707
  const existing = await findSharedRecord(deps, record, tag);
1419
1708
  let recordId;
1420
1709
  if (existing && typeof existing.id === "string") {
1421
- await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1710
+ await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1422
1711
  recordId = existing.id;
1423
1712
  } else {
1424
- recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
1425
- await (0, import_crm3.createRecord)({ ...crmDeps, newId: () => recordId }, {
1713
+ recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1714
+ await (0, import_crm4.createRecord)({ ...crmDeps, newId: () => recordId }, {
1426
1715
  type: record.type,
1427
1716
  input: record.input,
1428
1717
  mutationId: `share-create:${tag}`
@@ -1432,6 +1721,13 @@ async function projectSharedRecord(deps, shared) {
1432
1721
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1433
1722
  { mutationId: `share-map:${tag}:${recordId}` }
1434
1723
  );
1724
+ await (0, import_crm4.upsertRecordOrigin)(crmDeps, {
1725
+ recordId,
1726
+ sourceId: record.sourceId,
1727
+ sourceRecordId: record.sourceRecordId,
1728
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
1729
+ payloadVersion: record.version
1730
+ });
1435
1731
  return { recordId };
1436
1732
  }
1437
1733
  async function projectApplicant(deps, applicant) {
@@ -1454,7 +1750,7 @@ async function projectApplicant(deps, applicant) {
1454
1750
  }
1455
1751
 
1456
1752
  // src/crm-sync.ts
1457
- var import_crm4 = require("@odla-ai/crm");
1753
+ var import_crm5 = require("@odla-ai/crm");
1458
1754
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1459
1755
  function personInputFromApp(chapter, app) {
1460
1756
  const input = sharedPersonInput({
@@ -1494,16 +1790,16 @@ async function syncApplicationToCrm(deps, opts) {
1494
1790
  let recordId;
1495
1791
  if (existing && typeof existing.id === "string") {
1496
1792
  recordId = existing.id;
1497
- await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
1793
+ await (0, import_crm5.updateRecord)(recordDeps, { id: recordId, input });
1498
1794
  } else {
1499
- const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1795
+ const created = await (0, import_crm5.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1500
1796
  recordId = created.id;
1501
1797
  }
1502
1798
  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);
1799
+ await (0, import_crm5.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1504
1800
  }
1505
1801
  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);
1802
+ await (0, import_crm5.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1507
1803
  return recordId;
1508
1804
  }
1509
1805
  async function backfillCrm(deps) {