@odla-ai/chapter 0.5.0 → 0.8.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
@@ -226,7 +226,7 @@ Warmly,
226
226
  ${name}`;
227
227
  return {
228
228
  adminNotification: {
229
- subject: `New application \u2014 {{firstName}} {{lastName}}`,
229
+ subject: `New application: {{firstName}} {{lastName}}`,
230
230
  text: `A new application came in for ${name}.
231
231
 
232
232
  Name: {{firstName}} {{lastName}}
@@ -245,7 +245,7 @@ Your membership payment is confirmed. We'll be in touch to schedule your intro c
245
245
  Looking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`
246
246
  },
247
247
  onboardingInvite: {
248
- subject: `You're in \u2014 ${name}`,
248
+ subject: `You're in, ${name}`,
249
249
  text: `Hi {{firstName}},
250
250
 
251
251
  Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
@@ -470,6 +470,10 @@ function defineChapter(config) {
470
470
  const application = resolveApplication(config.application);
471
471
  const { schema, rules } = chapterDb(mode, auth);
472
472
  const services = config.services ?? ["db", "calendar", "o11y"];
473
+ const account = config.account ?? "invite";
474
+ if (account !== "invite" && account !== "create" && account !== "none") {
475
+ throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
476
+ }
473
477
  const chapter = {
474
478
  config,
475
479
  id: id2,
@@ -482,6 +486,7 @@ function defineChapter(config) {
482
486
  schema,
483
487
  rules,
484
488
  services,
489
+ account,
485
490
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
486
491
  };
487
492
  if (config.url !== void 0) chapter.url = config.url;
@@ -508,7 +513,7 @@ function createChapterIntegration(chapter, options = {}) {
508
513
  }
509
514
  return {
510
515
  id: "chapter",
511
- title: `Chapter \u2014 ${chapter.name}`,
516
+ title: `Chapter: ${chapter.name}`,
512
517
  npm: "@odla-ai/chapter",
513
518
  schema: {
514
519
  entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },
@@ -556,6 +561,72 @@ function planDelivery(input) {
556
561
  return { deliver: true, transport, to, subject, text, redirected: redirect };
557
562
  }
558
563
 
564
+ // src/notify.ts
565
+ async function sendTemplated(deps, input) {
566
+ const { emailLog: emailLog2 } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
567
+ const prior = Array.isArray(emailLog2) ? emailLog2 : [];
568
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
569
+ const cloudflareReady = Boolean(deps.sender && deps.from);
570
+ const decision = planDelivery({
571
+ envName: deps.envName,
572
+ group: input.group,
573
+ template: input.template,
574
+ to: input.to,
575
+ vars: input.vars,
576
+ cloudflareReady,
577
+ force: input.force
578
+ });
579
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
580
+ let error;
581
+ let messageId;
582
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
583
+ try {
584
+ const res = await deps.sender.send({
585
+ from: deps.from,
586
+ to: [decision.to],
587
+ subject: decision.subject,
588
+ text: decision.text,
589
+ replyTo: input.group.replyTo
590
+ });
591
+ messageId = res.messageId;
592
+ } catch (e) {
593
+ error = e instanceof Error ? e.message : String(e);
594
+ }
595
+ }
596
+ const id2 = deps.newId();
597
+ const row = {
598
+ id: id2,
599
+ groupId: input.group.id,
600
+ to: decision.to,
601
+ template: input.template,
602
+ subject: decision.subject,
603
+ body: decision.text,
604
+ transport: decision.transport,
605
+ redirected: decision.redirected,
606
+ dedupeKey: input.dedupeKey,
607
+ sentAt: deps.now(),
608
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
609
+ ...messageId ? { messageId } : {},
610
+ ...error ? { error } : {}
611
+ };
612
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id2, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
613
+ return error ? { sent: false, reason: error } : { sent: true };
614
+ }
615
+ function emailGroupFrom(row) {
616
+ const str = (v) => typeof v === "string" ? v : void 0;
617
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
618
+ return {
619
+ id: String(row.id),
620
+ name: String(row.name ?? ""),
621
+ replyTo: str(row.replyTo) ?? "",
622
+ debugEmail: str(row.debugEmail),
623
+ refundPolicyText: str(row.refundPolicyText),
624
+ commitmentText: str(row.commitmentText),
625
+ normsText: str(row.normsText),
626
+ emailTemplates: templates
627
+ };
628
+ }
629
+
559
630
  // src/payments.ts
560
631
  function parseSigHeader(header) {
561
632
  const parts = {};
@@ -665,21 +736,73 @@ function sharedPersonInput(person) {
665
736
  if (person.linkedin) input.linkedin = person.linkedin;
666
737
  return input;
667
738
  }
668
- async function projectSharedRecord(deps, person) {
669
- const email = person.email.toLowerCase();
670
- const input = sharedPersonInput(person);
739
+ async function upsertPerson(deps, opts) {
740
+ const email = opts.email.toLowerCase();
671
741
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
672
- const { crm_record } = await deps.db.query({
673
- crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
674
- });
742
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
675
743
  const existing = crm_record?.[0];
676
744
  if (existing && typeof existing.id === "string") {
677
- await updateRecord(crmDeps, { id: existing.id, input });
745
+ await updateRecord(crmDeps, { id: existing.id, input: opts.input });
678
746
  return { recordId: existing.id };
679
747
  }
680
- const created = await createRecord(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
748
+ const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
681
749
  return { recordId: created.id };
682
750
  }
751
+ async function projectSharedRecord(deps, person) {
752
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
753
+ }
754
+ async function projectApplicant(deps, applicant) {
755
+ const input = sharedPersonInput({
756
+ email: applicant.email,
757
+ firstName: applicant.firstName,
758
+ lastName: applicant.lastName,
759
+ phone: applicant.phone,
760
+ linkedin: applicant.linkedin,
761
+ hubRecordId: applicant.applicationId
762
+ });
763
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
764
+ }
765
+
766
+ // src/clerk.ts
767
+ function clerkInviteRequest(input) {
768
+ return {
769
+ path: "/v1/invitations",
770
+ body: {
771
+ email_address: input.email,
772
+ notify: true,
773
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
774
+ }
775
+ };
776
+ }
777
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
778
+ const { path, body } = clerkInviteRequest(input);
779
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
780
+ method: "POST",
781
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
782
+ body: JSON.stringify(body)
783
+ });
784
+ return { ok: res.ok, status: res.status };
785
+ }
786
+ function clerkUserRequest(input) {
787
+ return {
788
+ path: "/v1/users",
789
+ body: {
790
+ email_address: [input.email],
791
+ skip_password_requirement: true,
792
+ ...input.firstName ? { first_name: input.firstName } : {},
793
+ ...input.lastName ? { last_name: input.lastName } : {}
794
+ }
795
+ };
796
+ }
797
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
798
+ const { path, body } = clerkUserRequest(input);
799
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
800
+ method: "POST",
801
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
802
+ body: JSON.stringify(body)
803
+ });
804
+ return { ok: res.ok, status: res.status };
805
+ }
683
806
 
684
807
  // src/session.ts
685
808
  function applicationSummary(app) {
@@ -745,6 +868,38 @@ function brandTokens(brand) {
745
868
  ` : "";
746
869
  }
747
870
 
871
+ // src/reconcile.ts
872
+ function isReconcilable(meeting, now) {
873
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
874
+ }
875
+ function reconcileMeetings(meetings2, events, now) {
876
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
877
+ const decisions = [];
878
+ for (const m of meetings2) {
879
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
880
+ const g = byEvent.get(m.googleEventId);
881
+ if (!g || g.status === "cancelled") {
882
+ decisions.push({
883
+ meetingId: m.id,
884
+ applicationId: m.applicationId,
885
+ kind: "cancelled",
886
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
887
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
888
+ });
889
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
890
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
891
+ decisions.push({
892
+ meetingId: m.id,
893
+ applicationId: m.applicationId,
894
+ kind: "moved",
895
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
896
+ applicationPatch: { meetingAt: g.startAt }
897
+ });
898
+ }
899
+ }
900
+ return decisions;
901
+ }
902
+
748
903
  // src/scheduling.ts
749
904
  var SCHEDULING_DEFAULTS = {
750
905
  slotMinutes: 45,
@@ -791,10 +946,6 @@ function resolveScheduling(config) {
791
946
  if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
792
947
  return { ...c, days };
793
948
  }
794
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
795
- function canBookFrom(status) {
796
- return BOOKABLE_STATUSES.includes(status);
797
- }
798
949
  function slotWindow(now, windowDays) {
799
950
  return { from: now, to: now + windowDays * 864e5 };
800
951
  }
@@ -841,7 +992,6 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
841
992
  };
842
993
  }
843
994
  export {
844
- BOOKABLE_STATUSES,
845
995
  SCHEDULING_DEFAULTS,
846
996
  applicationBookingUpdate,
847
997
  applicationSummary,
@@ -850,14 +1000,18 @@ export {
850
1000
  buildGroupSeed,
851
1001
  canApprove,
852
1002
  canBook,
853
- canBookFrom,
854
1003
  canChangeRole,
855
1004
  canTransition,
856
1005
  canceledPatch,
857
1006
  chapterDb,
1007
+ clerkInviteRequest,
1008
+ clerkUserRequest,
858
1009
  createChapterIntegration,
1010
+ createClerkInvitation,
1011
+ createClerkUser,
859
1012
  defaultCrm,
860
1013
  defineChapter,
1014
+ emailGroupFrom,
861
1015
  endForSlot,
862
1016
  findApplicationRef,
863
1017
  firstPaymentPatch,
@@ -865,6 +1019,7 @@ export {
865
1019
  introIdempotencyKey,
866
1020
  isAdminRole,
867
1021
  isAlreadySent,
1022
+ isReconcilable,
868
1023
  isSlotAvailable,
869
1024
  joinConfig,
870
1025
  meetingCreateRow,
@@ -874,7 +1029,9 @@ export {
874
1029
  normalizeWebhookEvent,
875
1030
  paymentsReady,
876
1031
  planDelivery,
1032
+ projectApplicant,
877
1033
  projectSharedRecord,
1034
+ reconcileMeetings,
878
1035
  refundedPatch,
879
1036
  render,
880
1037
  renderSummary,
@@ -885,6 +1042,7 @@ export {
885
1042
  resolvePipeline,
886
1043
  resolveScheduling,
887
1044
  roleFromClaim,
1045
+ sendTemplated,
888
1046
  sharedPersonInput,
889
1047
  slotWindow,
890
1048
  stageIndex,