@odla-ai/chapter 0.23.0 → 0.24.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.
@@ -15,7 +15,14 @@ interface ChapterCopy {
15
15
  join: {
16
16
  form: TextFields<"submit" | "submitting" | "submitFailed" | "unexpectedFailure">;
17
17
  booking: TextFields<"unavailable" | "loadFailed" | "slotTaken" | "failed" | "loading" | "book" | "booking">;
18
- payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete">;
18
+ /**
19
+ * `consent` is the affirmative statement a member agrees to before the card
20
+ * form appears, rendered beside the checkbox and beneath the policy text.
21
+ * It is what the member is shown at the moment of consent, so it says they
22
+ * agree rather than merely restating the policy. Set it to an empty string
23
+ * to render the checkbox with the policy as its only label.
24
+ */
25
+ payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
19
26
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
20
27
  };
21
28
  members: {
@@ -15,7 +15,14 @@ interface ChapterCopy {
15
15
  join: {
16
16
  form: TextFields<"submit" | "submitting" | "submitFailed" | "unexpectedFailure">;
17
17
  booking: TextFields<"unavailable" | "loadFailed" | "slotTaken" | "failed" | "loading" | "book" | "booking">;
18
- payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete">;
18
+ /**
19
+ * `consent` is the affirmative statement a member agrees to before the card
20
+ * form appears, rendered beside the checkbox and beneath the policy text.
21
+ * It is what the member is shown at the moment of consent, so it says they
22
+ * agree rather than merely restating the policy. Set it to an empty string
23
+ * to render the checkbox with the policy as its only label.
24
+ */
25
+ payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
19
26
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
20
27
  };
21
28
  members: {
@@ -921,6 +921,93 @@ var handleMember = async (req, url, env, ctx) => {
921
921
  return null;
922
922
  };
923
923
 
924
+ // src/crm-sync.ts
925
+ import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
926
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
927
+ var crmDeps = (db, ctx) => ({
928
+ crm: ctx.chapter.crm,
929
+ db,
930
+ now: () => Date.now(),
931
+ newId: () => crypto.randomUUID(),
932
+ chapter: ctx.chapter
933
+ });
934
+ function personInputFromApp(chapter, app) {
935
+ const input = sharedPersonInput({
936
+ email: str(app.email),
937
+ firstName: str(app.firstName) || void 0,
938
+ lastName: str(app.lastName) || void 0,
939
+ phone: str(app.phone) || void 0,
940
+ linkedin: str(app.linkedin) || void 0,
941
+ hubRecordId: str(app.id)
942
+ });
943
+ for (const f of chapter.application.crmFields) {
944
+ if (app[f] !== void 0) input[f] = app[f];
945
+ }
946
+ if (app.id !== void 0) input.applicationId = str(app.id);
947
+ return input;
948
+ }
949
+ function billingColumns(app) {
950
+ const status = str(app.status);
951
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
952
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
953
+ const cols = { billingStatus };
954
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
955
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
956
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
957
+ return cols;
958
+ }
959
+ async function syncApplicationToCrm(deps, opts) {
960
+ const emailKey = str(opts.app.email).toLowerCase();
961
+ if (!emailKey) return null;
962
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
963
+ const input = personInputFromApp(deps.chapter, opts.app);
964
+ const { crm_record } = await deps.db.query({
965
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
966
+ });
967
+ const existing = crm_record?.[0] ?? null;
968
+ const stage = opts.stage || void 0;
969
+ let recordId;
970
+ if (existing && typeof existing.id === "string") {
971
+ recordId = existing.id;
972
+ await updateRecord2(recordDeps, { id: recordId, input });
973
+ } else {
974
+ const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
975
+ recordId = created.id;
976
+ }
977
+ if (existing && stage && existing.stage !== stage) {
978
+ await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
979
+ }
980
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
981
+ await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
982
+ return recordId;
983
+ }
984
+ async function backfillCrm(deps) {
985
+ const [appsRes, usersRes] = await Promise.all([
986
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
987
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
988
+ ]);
989
+ const seen = /* @__PURE__ */ new Set();
990
+ let synced = 0;
991
+ const errors = [];
992
+ const run = async (app, stage) => {
993
+ const key = str(app.email).toLowerCase();
994
+ if (!key || seen.has(key)) return;
995
+ seen.add(key);
996
+ try {
997
+ await syncApplicationToCrm(deps, { app, stage });
998
+ synced += 1;
999
+ } catch (err) {
1000
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1001
+ }
1002
+ };
1003
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1004
+ for (const u of usersRes.$users ?? []) {
1005
+ if (u.deleted === true) continue;
1006
+ await run({ email: u.email, firstName: str(u.name) });
1007
+ }
1008
+ return { synced, errors };
1009
+ }
1010
+
924
1011
  // src/worker-routes-schedule.ts
925
1012
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
926
1013
  function errCode(err) {
@@ -1024,8 +1111,11 @@ async function bookSlot(req, env, ctx) {
1024
1111
  if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
1025
1112
  return json({ error: "booking failed", code }, 502);
1026
1113
  }
1027
- const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
1114
+ const bookingAttrs = applicationBookingUpdate(status, startAt, htmlLink);
1115
+ const appOp = { t: "update", ns: "applications", id: applicationId, attrs: bookingAttrs };
1028
1116
  await db.transact([meetingOp, appOp]);
1117
+ const booked = { ...app, ...bookingAttrs };
1118
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
1029
1119
  if (typeof app.email === "string" && app.email) {
1030
1120
  await sendTemplated(
1031
1121
  { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
@@ -1375,6 +1465,8 @@ async function ingestWebhook(req, env, ctx) {
1375
1465
  const patch = webhookPatch(event, String(app.status ?? ""));
1376
1466
  if (Object.keys(patch).length) {
1377
1467
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1468
+ const next = { ...app, ...patch };
1469
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
1378
1470
  }
1379
1471
  if (event.kind === "first_payment") {
1380
1472
  await notifyPaymentConfirmed(db, env, eventId, app);
@@ -1566,86 +1658,6 @@ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
1566
1658
  return res.ok;
1567
1659
  }
1568
1660
 
1569
- // src/crm-sync.ts
1570
- import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1571
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1572
- function personInputFromApp(chapter, app) {
1573
- const input = sharedPersonInput({
1574
- email: str(app.email),
1575
- firstName: str(app.firstName) || void 0,
1576
- lastName: str(app.lastName) || void 0,
1577
- phone: str(app.phone) || void 0,
1578
- linkedin: str(app.linkedin) || void 0,
1579
- hubRecordId: str(app.id)
1580
- });
1581
- for (const f of chapter.application.crmFields) {
1582
- if (app[f] !== void 0) input[f] = app[f];
1583
- }
1584
- if (app.id !== void 0) input.applicationId = str(app.id);
1585
- return input;
1586
- }
1587
- function billingColumns(app) {
1588
- const status = str(app.status);
1589
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1590
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1591
- const cols = { billingStatus };
1592
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1593
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1594
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1595
- return cols;
1596
- }
1597
- async function syncApplicationToCrm(deps, opts) {
1598
- const emailKey = str(opts.app.email).toLowerCase();
1599
- if (!emailKey) return null;
1600
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1601
- const input = personInputFromApp(deps.chapter, opts.app);
1602
- const { crm_record } = await deps.db.query({
1603
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1604
- });
1605
- const existing = crm_record?.[0] ?? null;
1606
- const stage = opts.stage || void 0;
1607
- let recordId;
1608
- if (existing && typeof existing.id === "string") {
1609
- recordId = existing.id;
1610
- await updateRecord2(crmDeps3, { id: recordId, input });
1611
- } else {
1612
- const created = await createRecord2(crmDeps3, { type: "person", input, ...stage ? { stage } : {} });
1613
- recordId = created.id;
1614
- }
1615
- if (existing && stage && existing.stage !== stage) {
1616
- await setStage(crmDeps3, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1617
- }
1618
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1619
- await linkIdentity(crmDeps3, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1620
- return recordId;
1621
- }
1622
- async function backfillCrm(deps) {
1623
- const [appsRes, usersRes] = await Promise.all([
1624
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1625
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1626
- ]);
1627
- const seen = /* @__PURE__ */ new Set();
1628
- let synced = 0;
1629
- const errors = [];
1630
- const run = async (app, stage) => {
1631
- const key = str(app.email).toLowerCase();
1632
- if (!key || seen.has(key)) return;
1633
- seen.add(key);
1634
- try {
1635
- await syncApplicationToCrm(deps, { app, stage });
1636
- synced += 1;
1637
- } catch (err) {
1638
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1639
- }
1640
- };
1641
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1642
- for (const u of usersRes.$users ?? []) {
1643
- if (u.deleted === true) continue;
1644
- await run({ email: u.email, firstName: str(u.name) });
1645
- }
1646
- return { synced, errors };
1647
- }
1648
-
1649
1661
  // src/worker-routes-admin-people.ts
1650
1662
  async function adminGate(req, env, ctx) {
1651
1663
  const rawDb = ctx.makeDb(env);
@@ -1654,7 +1666,7 @@ async function adminGate(req, env, ctx) {
1654
1666
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1655
1667
  return { db: rawDb, actor: { userId: u.userId, email: u.email ?? void 0 } };
1656
1668
  }
1657
- var crmDeps = (db, ctx) => ({
1669
+ var crmDeps2 = (db, ctx) => ({
1658
1670
  crm: ctx.chapter.crm,
1659
1671
  db,
1660
1672
  now: () => Date.now(),
@@ -1665,7 +1677,7 @@ var handleAdminCrmSync = async (req, url, env, ctx) => {
1665
1677
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1666
1678
  const gate5 = await adminGate(req, env, ctx);
1667
1679
  if (gate5 instanceof Response) return gate5;
1668
- const result = await backfillCrm(crmDeps(gate5.db, ctx));
1680
+ const result = await backfillCrm(crmDeps2(gate5.db, ctx));
1669
1681
  return json({ ok: true, ...result });
1670
1682
  };
1671
1683
  var handleAdminPeople = async (req, url, env, ctx) => {
@@ -1945,7 +1957,6 @@ async function gate2(req, env, ctx) {
1945
1957
  return rawDb;
1946
1958
  }
1947
1959
  var calFor = (env) => initCalendar3({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1948
- var crmDeps2 = (db, ctx) => ({ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID(), chapter: ctx.chapter });
1949
1960
  var readJson = async (req) => {
1950
1961
  try {
1951
1962
  return await req.json();
@@ -2031,7 +2042,7 @@ var handleAdminApprove = async (req, url, env, ctx) => {
2031
2042
  if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
2032
2043
  const target = "approved";
2033
2044
  await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
2034
- await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
2045
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
2035
2046
  const { promoteTo, send } = ctx.chapter.operations.onApprove;
2036
2047
  let rolePromoted = false;
2037
2048
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2112,7 +2123,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
2112
2123
  if (Object.keys(attrs).length === 0) return json({ error: "nothing to update" }, 400);
2113
2124
  await db.transact([{ t: "update", ns: "applications", id, attrs }]);
2114
2125
  if (attrs.status !== void 0) {
2115
- await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
2126
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
2116
2127
  }
2117
2128
  return json({ ok: true });
2118
2129
  };