@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.js CHANGED
@@ -519,12 +519,17 @@ var DEFAULT_ADMIN_COPY = {
519
519
  overview: "Overview",
520
520
  billing: "Billing",
521
521
  people: "People",
522
+ portfolio: "Portfolio",
522
523
  settings: "Settings",
523
524
  calendar: "Calendar",
524
525
  email: "Email",
526
+ chapters: "Chapters",
527
+ formations: "Formation applications",
528
+ dealFlow: "Deal flow",
525
529
  dashboardViewsLabel: "Dashboard views",
526
530
  settingsViewsLabel: "Settings views",
527
- collectionsLabel: "CRM collections"
531
+ collectionsLabel: "CRM collections",
532
+ portfolioViewsLabel: "Portfolio views"
528
533
  },
529
534
  dashboard: {
530
535
  loading: "Loading dashboard\u2026",
@@ -630,7 +635,19 @@ var DEFAULT_ADMIN_COPY = {
630
635
  shareWith: "Share with {target}",
631
636
  shared: "Shared with {target}.",
632
637
  shareFailed: "Could not share with {target}.",
633
- deliveryFailed: "Delivery failed."
638
+ deliveryFailed: "Delivery failed.",
639
+ loadingRollup: "Loading network overview\u2026",
640
+ loadRollupFailed: "Couldn't load the network overview",
641
+ overview: "Network overview",
642
+ configuredFollowers: "Configured followers",
643
+ reachableFollowers: "Reachable followers",
644
+ recordsAcrossFollowers: "Records across followers",
645
+ noFollowers: "No follower sites are configured.",
646
+ followers: "Followers",
647
+ available: "available",
648
+ unavailable: "unavailable",
649
+ recordTotals: "Portfolio records",
650
+ noRecordTotals: "No aggregate records are available yet."
634
651
  },
635
652
  records: {
636
653
  workflowMissing: "This record is not linked to an application workflow.",
@@ -788,6 +805,81 @@ function formatChapterCopy(template, values) {
788
805
  );
789
806
  }
790
807
 
808
+ // src/formation.ts
809
+ var DEFAULT_REQUIRED2 = ["name", "email", "location", "thesis"];
810
+ var DEFAULT_OPTIONAL2 = ["proposedChapter", "experience", "collaborators"];
811
+ function resolveLeaderFormation(input, crm) {
812
+ const type = input?.type ?? "chapter_application";
813
+ if (!input) {
814
+ return {
815
+ enabled: false,
816
+ type,
817
+ required: [],
818
+ optional: [],
819
+ maxLen: {},
820
+ defaultMaxLen: 4e3,
821
+ bodyCap: 32768
822
+ };
823
+ }
824
+ const def = crm.config.types[type];
825
+ if (!def) throw new Error(`defineChapter.formation.type: unknown CRM type "${type}"`);
826
+ const required = input.required ?? (type === "chapter_application" ? DEFAULT_REQUIRED2 : []);
827
+ const optional = input.optional ?? (type === "chapter_application" ? DEFAULT_OPTIONAL2 : []);
828
+ for (const [name, values] of [["required", required], ["optional", optional]]) {
829
+ if (!Array.isArray(values) || !values.every((field) => typeof field === "string" && field !== "")) {
830
+ throw new Error(`defineChapter.formation.${name}: must be an array of field-name strings`);
831
+ }
832
+ }
833
+ const duplicates = [...required, ...optional].filter((field, index, fields) => fields.indexOf(field) !== index);
834
+ if (duplicates.length) throw new Error(`defineChapter.formation: duplicate public field "${duplicates[0]}"`);
835
+ const unknown = [...required, ...optional].filter((field) => !def.fields[field]);
836
+ if (unknown.length) throw new Error(`defineChapter.formation: field "${unknown[0]}" is not declared on CRM type "${type}"`);
837
+ const missingRequired = Object.entries(def.fields).filter(([, field]) => field.required).map(([field]) => field).filter((field) => !required.includes(field));
838
+ if (missingRequired.length) {
839
+ throw new Error(`defineChapter.formation.required: must include required CRM field "${missingRequired[0]}"`);
840
+ }
841
+ const unsupportedRequired = required.find((field) => !["string", "email", "url", "enum"].includes(def.fields[field].type));
842
+ if (unsupportedRequired) {
843
+ throw new Error(`defineChapter.formation.required: field "${unsupportedRequired}" must be string-like`);
844
+ }
845
+ if (required.length === 0) throw new Error("defineChapter.formation.required: must not be empty");
846
+ const defaultMaxLen = input.defaultMaxLen ?? 4e3;
847
+ const bodyCap = input.bodyCap ?? 32768;
848
+ if (!Number.isSafeInteger(defaultMaxLen) || defaultMaxLen < 1 || defaultMaxLen > 32768) {
849
+ throw new Error("defineChapter.formation.defaultMaxLen: must be an integer from 1 to 32768");
850
+ }
851
+ if (!Number.isSafeInteger(bodyCap) || bodyCap < 1024 || bodyCap > 131072) {
852
+ throw new Error("defineChapter.formation.bodyCap: must be an integer from 1024 to 131072");
853
+ }
854
+ for (const [field, cap] of Object.entries(input.maxLen ?? {})) {
855
+ if (![...required, ...optional].includes(field) || !Number.isSafeInteger(cap) || cap < 1 || cap > 32768) {
856
+ throw new Error(`defineChapter.formation.maxLen.${field}: must name a public field and be an integer from 1 to 32768`);
857
+ }
858
+ }
859
+ return {
860
+ enabled: true,
861
+ type,
862
+ required,
863
+ optional,
864
+ maxLen: input.maxLen ?? {},
865
+ defaultMaxLen,
866
+ bodyCap
867
+ };
868
+ }
869
+ function formationFields(crm, formation) {
870
+ const def = crm.type(formation.type);
871
+ return [...formation.required, ...formation.optional].map((id2) => {
872
+ const field = def.fields[id2];
873
+ return {
874
+ id: id2,
875
+ label: field.label ?? id2,
876
+ type: field.type,
877
+ required: formation.required.includes(id2),
878
+ ...field.options ? { options: field.options } : {}
879
+ };
880
+ });
881
+ }
882
+
791
883
  // src/config.ts
792
884
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
793
885
  var FIELD = /^[a-z][a-zA-Z0-9_]*$/;
@@ -876,6 +968,7 @@ function defineChapter(config) {
876
968
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
877
969
  const copy = resolveChapterCopy(config.copy);
878
970
  const network = resolveNetwork(config, crm);
971
+ const formation = resolveLeaderFormation(config.formation, crm);
879
972
  const { schema, rules } = chapterDb(mode, auth);
880
973
  const services = config.services ?? ["db", "calendar", "o11y"];
881
974
  const account = config.account ?? "none";
@@ -931,6 +1024,7 @@ function defineChapter(config) {
931
1024
  brand,
932
1025
  copy,
933
1026
  network,
1027
+ formation,
934
1028
  crm,
935
1029
  auth,
936
1030
  pipeline,
@@ -947,6 +1041,147 @@ function defineChapter(config) {
947
1041
  return chapter;
948
1042
  }
949
1043
 
1044
+ // src/leader.ts
1045
+ import { composeCrmConfigs } from "@odla-ai/crm";
1046
+ function leaderCrmConfig(options = {}) {
1047
+ const base = options.base ?? defaultCrm("hub");
1048
+ const portfolio = {
1049
+ types: {
1050
+ chapter: {
1051
+ label: "Chapter",
1052
+ labelPlural: "Chapters",
1053
+ workspace: "portfolio",
1054
+ nameField: "name",
1055
+ fields: {
1056
+ name: { type: "string", label: "Name", required: true },
1057
+ slug: { type: "string", label: "Slug", slot: "s1" },
1058
+ region: { type: "string", label: "Region", slot: "s2" },
1059
+ url: { type: "url", label: "Website" },
1060
+ thesis: { type: "string", label: "Investment thesis" },
1061
+ launchedAt: { type: "date", label: "Launch date", slot: "d1" },
1062
+ notes: { type: "string", label: "Notes" }
1063
+ },
1064
+ pipeline: {
1065
+ stages: [
1066
+ { id: "proposed", label: "Proposed" },
1067
+ { id: "forming", label: "Forming" },
1068
+ { id: "active", label: "Active" },
1069
+ { id: "paused", label: "Paused" },
1070
+ { id: "closed", label: "Closed", terminal: true }
1071
+ ],
1072
+ transitions: {
1073
+ proposed: ["forming", "closed"],
1074
+ forming: ["active", "paused", "closed"],
1075
+ active: ["paused", "closed"],
1076
+ paused: ["forming", "active", "closed"]
1077
+ }
1078
+ },
1079
+ facets: { rank: "manual" }
1080
+ },
1081
+ chapter_application: {
1082
+ label: "Chapter application",
1083
+ labelPlural: "Formation applications",
1084
+ workspace: "portfolio",
1085
+ nameField: "name",
1086
+ emailField: "email",
1087
+ fields: {
1088
+ name: { type: "string", label: "Applicant name", required: true },
1089
+ email: { type: "email", label: "Email", required: true },
1090
+ location: { type: "string", label: "Location", required: true, slot: "s1" },
1091
+ proposedChapter: { type: "string", label: "Proposed chapter", slot: "s2" },
1092
+ thesis: { type: "string", label: "Proposed thesis", required: true },
1093
+ experience: { type: "string", label: "Relevant experience" },
1094
+ collaborators: { type: "json", label: "Collaborators" },
1095
+ notes: { type: "string", label: "Internal notes" }
1096
+ },
1097
+ pipeline: {
1098
+ stages: [
1099
+ { id: "submitted", label: "Submitted" },
1100
+ { id: "reviewing", label: "Reviewing" },
1101
+ { id: "interviewing", label: "Interviewing" },
1102
+ { id: "approved", label: "Approved" },
1103
+ { id: "forming", label: "Forming" },
1104
+ { id: "declined", label: "Declined", terminal: true }
1105
+ ],
1106
+ transitions: {
1107
+ submitted: ["reviewing", "declined"],
1108
+ reviewing: ["interviewing", "approved", "declined"],
1109
+ interviewing: ["approved", "declined"],
1110
+ approved: ["forming", "declined"]
1111
+ }
1112
+ },
1113
+ facets: { email: true, rank: "manual" }
1114
+ },
1115
+ deal: {
1116
+ label: "Deal",
1117
+ labelPlural: "Deal flow",
1118
+ workspace: "portfolio",
1119
+ nameField: "name",
1120
+ fields: {
1121
+ name: { type: "string", label: "Company or deal", required: true },
1122
+ chapter: { type: "string", label: "Source chapter", slot: "s1" },
1123
+ sector: { type: "string", label: "Sector", slot: "s2" },
1124
+ source: { type: "string", label: "Source", slot: "s3" },
1125
+ website: { type: "url", label: "Website" },
1126
+ amount: { type: "number", label: "Target amount", slot: "n1" },
1127
+ notes: { type: "string", label: "Notes" }
1128
+ },
1129
+ pipeline: {
1130
+ stages: [
1131
+ { id: "sourced", label: "Sourced" },
1132
+ { id: "screening", label: "Screening" },
1133
+ { id: "diligence", label: "Diligence" },
1134
+ { id: "committee", label: "Committee" },
1135
+ { id: "offered", label: "Offered" },
1136
+ { id: "invested", label: "Invested", terminal: true },
1137
+ { id: "passed", label: "Passed", terminal: true }
1138
+ ],
1139
+ transitions: {
1140
+ sourced: ["screening", "passed"],
1141
+ screening: ["diligence", "committee", "passed"],
1142
+ diligence: ["committee", "offered", "passed"],
1143
+ committee: ["offered", "passed"],
1144
+ offered: ["invested", "passed"],
1145
+ passed: ["sourced"]
1146
+ }
1147
+ },
1148
+ facets: { rank: "manual" }
1149
+ }
1150
+ },
1151
+ relations: {
1152
+ formation_result: {
1153
+ from: "chapter_application",
1154
+ to: "chapter",
1155
+ label: "formed as",
1156
+ reverseLabel: "formation application"
1157
+ },
1158
+ chapter_deal: {
1159
+ from: "deal",
1160
+ to: "chapter",
1161
+ label: "sourced by",
1162
+ reverseLabel: "deal flow"
1163
+ }
1164
+ }
1165
+ };
1166
+ if (base.types.company) {
1167
+ portfolio.relations.deal_company = {
1168
+ from: "deal",
1169
+ to: "company",
1170
+ label: "concerns",
1171
+ reverseLabel: "deals"
1172
+ };
1173
+ }
1174
+ if (base.types.person) {
1175
+ portfolio.relations.application_contact = {
1176
+ from: "person",
1177
+ to: "chapter_application",
1178
+ label: "submitted",
1179
+ reverseLabel: "applicant"
1180
+ };
1181
+ }
1182
+ return composeCrmConfigs(base, portfolio);
1183
+ }
1184
+
950
1185
  // src/descriptor.ts
951
1186
  import { createCrmIntegration } from "@odla-ai/crm";
952
1187
  function createChapterIntegration(chapter, options = {}) {
@@ -985,7 +1220,15 @@ function createChapterIntegration(chapter, options = {}) {
985
1220
  },
986
1221
  rules: { ...crmDesc.rules, ...chapter.rules },
987
1222
  seeds,
988
- probes: [...crmDesc.probes ?? []],
1223
+ probes: [
1224
+ ...crmDesc.probes ?? [],
1225
+ // The snapshot route is deliberately private. A credential-free smoke
1226
+ // request must prove that the route is mounted and closed.
1227
+ { path: "/api/network/snapshot", expectedStatus: 401 },
1228
+ // Formation metadata is public only when the host explicitly enables
1229
+ // the bounded intake contract.
1230
+ ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : []
1231
+ ],
989
1232
  runbooks
990
1233
  };
991
1234
  }
@@ -1189,11 +1432,20 @@ function canceledPatch() {
1189
1432
  }
1190
1433
 
1191
1434
  // src/network.ts
1192
- import { createRecord, updateRecord } from "@odla-ai/crm";
1435
+ import {
1436
+ createRecord,
1437
+ getRecordByOrigin,
1438
+ updateRecord,
1439
+ upsertRecordOrigin
1440
+ } from "@odla-ai/crm";
1441
+
1442
+ // src/network-contract.ts
1193
1443
  var DEFAULT_SHARE_FIELDS = {
1194
1444
  person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
1195
1445
  company: ["name", "domain", "industry", "location", "linkedin", "notes"]
1196
1446
  };
1447
+
1448
+ // src/network.ts
1197
1449
  function sharedPersonInput(person) {
1198
1450
  const email = person.email.toLowerCase();
1199
1451
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -1215,25 +1467,55 @@ function shortHash(value) {
1215
1467
  }
1216
1468
  return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
1217
1469
  }
1218
- function networkSourceTag(type, hubRecordId) {
1470
+ function networkSourceTag(type, hubRecordId, sourceId) {
1219
1471
  const typeKey = type.toLowerCase();
1220
1472
  const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
1221
- const raw = `network:${typeKey}:${hubRecordId}`;
1473
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
1474
+ const raw = `${prefix}:${hubRecordId}`;
1222
1475
  if (readable && raw.length <= 64) return raw;
1223
- return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
1224
- }
1225
- function normalizeSharedRecord(record) {
1226
- if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
1476
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
1477
+ }
1478
+ function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
1479
+ if ("input" in record && record.version === 2) {
1480
+ return {
1481
+ version: 2,
1482
+ sourceId: record.source.siteId,
1483
+ sourceRecordId: record.source.recordId,
1484
+ type: record.type,
1485
+ input: record.input
1486
+ };
1487
+ }
1488
+ if ("input" in record) {
1489
+ return {
1490
+ version: 1,
1491
+ sourceId: fallbackSourceId,
1492
+ sourceRecordId: record.hubRecordId,
1493
+ type: record.type,
1494
+ input: record.input
1495
+ };
1496
+ }
1227
1497
  if ("type" in record && record.type === "company") {
1228
1498
  const input = { name: record.name };
1229
1499
  for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
1230
1500
  if (record[key]) input[key] = record[key];
1231
1501
  }
1232
- return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
1502
+ return {
1503
+ version: 1,
1504
+ sourceId: fallbackSourceId,
1505
+ sourceRecordId: record.hubRecordId,
1506
+ type: "company",
1507
+ input
1508
+ };
1233
1509
  }
1234
- return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
1510
+ return {
1511
+ version: 1,
1512
+ sourceId: fallbackSourceId,
1513
+ sourceRecordId: record.hubRecordId,
1514
+ type: "person",
1515
+ input: sharedPersonInput(record)
1516
+ };
1235
1517
  }
1236
- function sharedRecordFromCrm(crm, record, target) {
1518
+ function sharedRecordFromCrm(crm, record, target, sourceId) {
1237
1519
  if (target.fields && !target.fields[record.type]) {
1238
1520
  throw new Error(`${target.name} does not accept "${record.type}" records`);
1239
1521
  }
@@ -1249,7 +1531,7 @@ function sharedRecordFromCrm(crm, record, target) {
1249
1531
  if (value !== void 0) input[field] = value;
1250
1532
  }
1251
1533
  if (input[nameField] === void 0) input[nameField] = record.name;
1252
- return { version: 1, type: record.type, hubRecordId: record.id, input };
1534
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
1253
1535
  }
1254
1536
  async function upsertPerson(deps, opts) {
1255
1537
  const email = opts.email.toLowerCase();
@@ -1264,6 +1546,12 @@ async function upsertPerson(deps, opts) {
1264
1546
  return { recordId: created.id };
1265
1547
  }
1266
1548
  async function findSharedRecord(deps, record, tag) {
1549
+ const structured = await getRecordByOrigin(
1550
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1551
+ record.sourceId,
1552
+ record.sourceRecordId
1553
+ );
1554
+ if (structured) return structured;
1267
1555
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1268
1556
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1269
1557
  if (typeof mappedId === "string") {
@@ -1297,12 +1585,15 @@ async function findSharedRecord(deps, record, tag) {
1297
1585
  }
1298
1586
  return void 0;
1299
1587
  }
1300
- async function projectSharedRecord(deps, shared) {
1301
- const record = normalizeSharedRecord(shared);
1302
- if (!record.type.trim() || !record.hubRecordId.trim()) {
1303
- throw new Error("type and hubRecordId must be non-empty");
1588
+ async function projectSharedRecord(deps, shared, options = {}) {
1589
+ const record = normalizeSharedRecord(shared, options.sourceId);
1590
+ if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1591
+ throw new Error("type, source site id, and source record id must be non-empty");
1304
1592
  }
1305
- const tag = networkSourceTag(record.type, record.hubRecordId);
1593
+ if (options.sourceId && options.sourceId !== record.sourceId) {
1594
+ throw new Error("signed sender does not match payload source");
1595
+ }
1596
+ const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1306
1597
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1307
1598
  const existing = await findSharedRecord(deps, record, tag);
1308
1599
  let recordId;
@@ -1310,7 +1601,7 @@ async function projectSharedRecord(deps, shared) {
1310
1601
  await updateRecord(crmDeps, { id: existing.id, input: record.input });
1311
1602
  recordId = existing.id;
1312
1603
  } else {
1313
- recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
1604
+ recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1314
1605
  await createRecord({ ...crmDeps, newId: () => recordId }, {
1315
1606
  type: record.type,
1316
1607
  input: record.input,
@@ -1321,6 +1612,13 @@ async function projectSharedRecord(deps, shared) {
1321
1612
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1322
1613
  { mutationId: `share-map:${tag}:${recordId}` }
1323
1614
  );
1615
+ await upsertRecordOrigin(crmDeps, {
1616
+ recordId,
1617
+ sourceId: record.sourceId,
1618
+ sourceRecordId: record.sourceRecordId,
1619
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
1620
+ payloadVersion: record.version
1621
+ });
1324
1622
  return { recordId };
1325
1623
  }
1326
1624
  async function projectApplicant(deps, applicant) {
@@ -2006,6 +2304,7 @@ export {
2006
2304
  findApplicationRef,
2007
2305
  firstPaymentPatch,
2008
2306
  formatChapterCopy,
2307
+ formationFields,
2009
2308
  getVaultSecret,
2010
2309
  hasDisclaimerAck,
2011
2310
  introIdempotencyKey,
@@ -2015,6 +2314,7 @@ export {
2015
2314
  isSlotAvailable,
2016
2315
  isValidEmail,
2017
2316
  joinConfig,
2317
+ leaderCrmConfig,
2018
2318
  meetingCreateRow,
2019
2319
  meetingRescheduleUpdate,
2020
2320
  memberApplication,
@@ -2036,6 +2336,7 @@ export {
2036
2336
  resolveApplication,
2037
2337
  resolveAuth,
2038
2338
  resolveChapterCopy,
2339
+ resolveLeaderFormation,
2039
2340
  resolvePipeline,
2040
2341
  resolveScheduling,
2041
2342
  roleFromClaim,