@odla-ai/chapter 0.18.0 → 0.20.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
@@ -195,16 +195,10 @@ function companyType() {
195
195
  facets: { rank: "manual" }
196
196
  };
197
197
  }
198
- function defaultCrm(mode) {
199
- if (mode === "hub") {
200
- return {
201
- types: { person: personType(), company: companyType() },
202
- relations: { works_at: { from: "person", to: "company", label: "works at", reverseLabel: "team" } },
203
- templates: TEMPLATES
204
- };
205
- }
198
+ function defaultCrm(_mode) {
206
199
  return {
207
- types: { person: personType() },
200
+ types: { person: personType(), company: companyType() },
201
+ relations: { works_at: { from: "person", to: "company", label: "works at", reverseLabel: "team" } },
208
202
  templates: TEMPLATES
209
203
  };
210
204
  }
@@ -487,6 +481,62 @@ function joinConfig(group, paymentsReady2) {
487
481
 
488
482
  // src/config.ts
489
483
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
484
+ var FIELD = /^[a-z][a-zA-Z0-9_]*$/;
485
+ function resolveNetwork(config, crm) {
486
+ const seen = /* @__PURE__ */ new Set();
487
+ const targets = [];
488
+ for (const target of config.network?.targets ?? []) {
489
+ if (!SLUG.test(target.id)) {
490
+ throw new Error(`defineChapter.network.targets.id: must be a lowercase slug \u2014 got ${JSON.stringify(target.id)}`);
491
+ }
492
+ if (seen.has(target.id)) throw new Error(`defineChapter.network.targets: duplicate target "${target.id}"`);
493
+ seen.add(target.id);
494
+ let url;
495
+ try {
496
+ url = new URL(target.url);
497
+ } catch {
498
+ throw new Error(`defineChapter.network.targets.${target.id}.url: must be an absolute URL`);
499
+ }
500
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
501
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
502
+ throw new Error(`defineChapter.network.targets.${target.id}.url: must use https (http is allowed only for localhost)`);
503
+ }
504
+ const fields = target.fields;
505
+ for (const [type, names] of Object.entries(fields ?? {})) {
506
+ if (!FIELD.test(type) || names.length === 0 || names.some((name) => !FIELD.test(name))) {
507
+ throw new Error(
508
+ `defineChapter.network.targets.${target.id}.fields.${type}: type and field names must be non-empty CRM identifiers`
509
+ );
510
+ }
511
+ const def = crm.config.types[type];
512
+ if (!def) throw new Error(`defineChapter.network.targets.${target.id}.fields: unknown leader CRM type "${type}"`);
513
+ const unknown = names.filter((field) => !def.fields[field]);
514
+ if (unknown.length > 0) {
515
+ throw new Error(
516
+ `defineChapter.network.targets.${target.id}.fields.${type}: ${unknown.map((field) => `"${field}"`).join(", ")} not declared on the leader CRM type`
517
+ );
518
+ }
519
+ const nameField = def.nameField ?? "name";
520
+ if (!names.includes(nameField)) {
521
+ throw new Error(
522
+ `defineChapter.network.targets.${target.id}.fields.${type}: must include required name field "${nameField}"`
523
+ );
524
+ }
525
+ }
526
+ const secretName = target.secretName?.trim() || `network_share_${target.id.replaceAll("-", "_")}`;
527
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{1,127}$/.test(secretName)) {
528
+ throw new Error(`defineChapter.network.targets.${target.id}.secretName: must be a vault-key identifier`);
529
+ }
530
+ targets.push({
531
+ id: target.id,
532
+ name: target.name?.trim() || target.id,
533
+ url: url.origin,
534
+ secretName,
535
+ ...fields ? { fields } : {}
536
+ });
537
+ }
538
+ return { targets };
539
+ }
490
540
  function isResolvedCrm(x) {
491
541
  return !!x && typeof x === "object" && "prepare" in x && typeof x.prepare === "function";
492
542
  }
@@ -511,6 +561,8 @@ function defineChapter(config) {
511
561
  const auth = resolveAuth(mode, config.auth);
512
562
  const pipeline = resolvePipeline(config.pipeline);
513
563
  const application = resolveApplication(config.application);
564
+ const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
565
+ const network = resolveNetwork(config, crm);
514
566
  const { schema, rules } = chapterDb(mode, auth);
515
567
  const services = config.services ?? ["db", "calendar", "o11y"];
516
568
  const account = config.account ?? "none";
@@ -563,6 +615,8 @@ function defineChapter(config) {
563
615
  id: id2,
564
616
  name,
565
617
  mode,
618
+ brand,
619
+ network,
566
620
  crm,
567
621
  auth,
568
622
  pipeline,
@@ -811,6 +865,10 @@ function canceledPatch() {
811
865
 
812
866
  // src/network.ts
813
867
  import { createRecord, updateRecord } from "@odla-ai/crm";
868
+ var DEFAULT_SHARE_FIELDS = {
869
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
870
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
871
+ };
814
872
  function sharedPersonInput(person) {
815
873
  const email = person.email.toLowerCase();
816
874
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -822,6 +880,52 @@ function sharedPersonInput(person) {
822
880
  if (person.linkedin) input.linkedin = person.linkedin;
823
881
  return input;
824
882
  }
883
+ function shortHash(value) {
884
+ let a = 2166136261;
885
+ let b = 2654435769;
886
+ for (let i = 0; i < value.length; i += 1) {
887
+ const n = value.charCodeAt(i);
888
+ a = Math.imul(a ^ n, 16777619);
889
+ b = Math.imul(b ^ n, 2246822507);
890
+ }
891
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
892
+ }
893
+ function networkSourceTag(type, hubRecordId) {
894
+ const typeKey = type.toLowerCase();
895
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
896
+ const raw = `network:${typeKey}:${hubRecordId}`;
897
+ if (readable && raw.length <= 64) return raw;
898
+ return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
899
+ }
900
+ function normalizeSharedRecord(record) {
901
+ if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
902
+ if ("type" in record && record.type === "company") {
903
+ const input = { name: record.name };
904
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
905
+ if (record[key]) input[key] = record[key];
906
+ }
907
+ return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
908
+ }
909
+ return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
910
+ }
911
+ function sharedRecordFromCrm(crm, record, target) {
912
+ if (target.fields && !target.fields[record.type]) {
913
+ throw new Error(`${target.name} does not accept "${record.type}" records`);
914
+ }
915
+ const def = crm.type(record.type);
916
+ const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
917
+ if (!fields) {
918
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
919
+ }
920
+ const nameField = def.nameField ?? "name";
921
+ const input = {};
922
+ for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
923
+ const value = record.fields?.[field];
924
+ if (value !== void 0) input[field] = value;
925
+ }
926
+ if (input[nameField] === void 0) input[nameField] = record.name;
927
+ return { version: 1, type: record.type, hubRecordId: record.id, input };
928
+ }
825
929
  async function upsertPerson(deps, opts) {
826
930
  const email = opts.email.toLowerCase();
827
931
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
@@ -834,8 +938,65 @@ async function upsertPerson(deps, opts) {
834
938
  const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
835
939
  return { recordId: created.id };
836
940
  }
837
- async function projectSharedRecord(deps, person) {
838
- return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
941
+ async function findSharedRecord(deps, record, tag) {
942
+ const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
943
+ const mappedId = mapped.crm_tag?.[0]?.recordId;
944
+ if (typeof mappedId === "string") {
945
+ const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
946
+ if (found.crm_record?.[0]) return found.crm_record[0];
947
+ }
948
+ const def = deps.crm.type(record.type);
949
+ const emailField = def.emailField;
950
+ if (emailField && typeof record.input[emailField] === "string") {
951
+ const primaryEmail = record.input[emailField].toLowerCase();
952
+ const found = await deps.db.query({
953
+ crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
954
+ });
955
+ if (found.crm_record?.[0]) return found.crm_record[0];
956
+ }
957
+ const domain = record.input.domain;
958
+ const domainSlot = def.fields.domain?.slot;
959
+ if (typeof domain === "string" && domainSlot) {
960
+ const found = await deps.db.query({
961
+ crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
962
+ });
963
+ if (found.crm_record?.[0]) return found.crm_record[0];
964
+ }
965
+ const nameField = def.nameField ?? "name";
966
+ const name = record.input[nameField];
967
+ if (record.type === "company" && typeof name === "string" && name.trim()) {
968
+ const found = await deps.db.query({
969
+ crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
970
+ });
971
+ if (found.crm_record?.[0]) return found.crm_record[0];
972
+ }
973
+ return void 0;
974
+ }
975
+ async function projectSharedRecord(deps, shared) {
976
+ const record = normalizeSharedRecord(shared);
977
+ if (!record.type.trim() || !record.hubRecordId.trim()) {
978
+ throw new Error("type and hubRecordId must be non-empty");
979
+ }
980
+ const tag = networkSourceTag(record.type, record.hubRecordId);
981
+ const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
982
+ const existing = await findSharedRecord(deps, record, tag);
983
+ let recordId;
984
+ if (existing && typeof existing.id === "string") {
985
+ await updateRecord(crmDeps, { id: existing.id, input: record.input });
986
+ recordId = existing.id;
987
+ } else {
988
+ recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
989
+ await createRecord({ ...crmDeps, newId: () => recordId }, {
990
+ type: record.type,
991
+ input: record.input,
992
+ mutationId: `share-create:${tag}`
993
+ });
994
+ }
995
+ await deps.db.transact(
996
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
997
+ { mutationId: `share-map:${tag}:${recordId}` }
998
+ );
999
+ return { recordId };
839
1000
  }
840
1001
  async function projectApplicant(deps, applicant) {
841
1002
  const base = sharedPersonInput({
@@ -1199,20 +1360,36 @@ function paletteVar(key) {
1199
1360
  function cleanValue(value) {
1200
1361
  return value.replace(/[<>{};]/g, "").trim();
1201
1362
  }
1202
- function brandTokens(brand) {
1203
- if (!brand) return "";
1363
+ function paletteDecls(palette) {
1204
1364
  const decls = [];
1205
- for (const [key, value] of Object.entries(brand.palette ?? {})) {
1365
+ for (const [key, value] of Object.entries(palette ?? {})) {
1206
1366
  if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
1207
1367
  }
1368
+ return decls;
1369
+ }
1370
+ function brandTokens(brand) {
1371
+ if (!brand) return "";
1372
+ const light = paletteDecls(brand.palette);
1208
1373
  const fonts = brand.fonts;
1209
- if (fonts?.display) decls.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
1210
- if (fonts?.body) decls.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
1211
- if (fonts?.numeral) decls.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
1212
- return decls.length ? `:root {
1213
- ${decls.join("\n ")}
1374
+ if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
1375
+ if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
1376
+ if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
1377
+ const dark = paletteDecls(brand.paletteDark);
1378
+ let css = light.length ? `:root {
1379
+ ${light.join("\n ")}
1214
1380
  }
1215
1381
  ` : "";
1382
+ if (dark.length) {
1383
+ const block = `{
1384
+ ${dark.join("\n ")}
1385
+ }`;
1386
+ css += `:root[data-theme="dark"] ${block}
1387
+ @media (prefers-color-scheme: dark) {
1388
+ :root:not([data-theme="light"]) ${block}
1389
+ }
1390
+ `;
1391
+ }
1392
+ return css;
1216
1393
  }
1217
1394
 
1218
1395
  // src/reconcile.ts
@@ -1354,6 +1531,7 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
1354
1531
  };
1355
1532
  }
1356
1533
  export {
1534
+ DEFAULT_SHARE_FIELDS,
1357
1535
  SCHEDULING_DEFAULTS,
1358
1536
  applicantProfile,
1359
1537
  applicationBookingUpdate,
@@ -1399,6 +1577,8 @@ export {
1399
1577
  meetingRescheduleUpdate,
1400
1578
  memberApplication,
1401
1579
  memberSession,
1580
+ networkSourceTag,
1581
+ normalizeSharedRecord,
1402
1582
  normalizeWebhookEvent,
1403
1583
  paymentsReady,
1404
1584
  personInputFromApp,
@@ -1418,6 +1598,7 @@ export {
1418
1598
  roleFromClaim,
1419
1599
  sendTemplated,
1420
1600
  sharedPersonInput,
1601
+ sharedRecordFromCrm,
1421
1602
  slotWindow,
1422
1603
  stageIndex,
1423
1604
  stripeCall,