@odla-ai/chapter 0.54.0 → 0.54.2

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
@@ -1101,6 +1101,7 @@ Warmly,
1101
1101
  ${name}`;
1102
1102
  return {
1103
1103
  adminNotification: {
1104
+ enabled: true,
1104
1105
  subject: `New application: {{firstName}} {{lastName}}`,
1105
1106
  text: `A new application came in for ${name}.
1106
1107
 
@@ -1111,18 +1112,21 @@ Tier: {{tier}}
1111
1112
  Review it: {{adminUrl}}`
1112
1113
  },
1113
1114
  submitConfirmation: {
1115
+ enabled: true,
1114
1116
  subject: `We received your application to ${name}`,
1115
1117
  text: `Hi {{firstName}},
1116
1118
 
1117
1119
  Thanks for applying to ${name}. {{submitNextStep}}${sign}`
1118
1120
  },
1119
1121
  paymentConfirmation: {
1122
+ enabled: true,
1120
1123
  subject: `Welcome to ${name}`,
1121
1124
  text: `Hi {{firstName}},
1122
1125
 
1123
1126
  Your membership payment{{paymentAmountSummary}} is confirmed. {{paymentNextStep}}${sign}`
1124
1127
  },
1125
1128
  prepEmail: {
1129
+ enabled: true,
1126
1130
  subject: `Your ${name} intro call`,
1127
1131
  text: `Hi {{firstName}},
1128
1132
 
@@ -1131,18 +1135,19 @@ You are all set{{paymentAmountSummary}}. Looking forward to our call on {{startA
1131
1135
  Join here: {{meetingLink}}${sign}`
1132
1136
  },
1133
1137
  onboardingInvite: {
1138
+ enabled: true,
1134
1139
  subject: `You're in, ${name}`,
1135
1140
  text: `Hi {{firstName}},
1136
1141
 
1137
1142
  Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
1138
1143
  },
1139
- seatAccepted: SEAT_ACCEPTED_TEMPLATE
1144
+ seatAccepted: { ...SEAT_ACCEPTED_TEMPLATE, enabled: true }
1140
1145
  };
1141
1146
  }
1142
1147
  function buildGroupSeed(config) {
1143
1148
  const prices = config.prices;
1144
1149
  const emails = config.emails;
1145
- const policy = config.policy ?? {};
1150
+ const policy2 = config.policy ?? {};
1146
1151
  const scheduling = {
1147
1152
  ...DEFAULT_SCHEDULING,
1148
1153
  ...config.scheduling ?? {},
@@ -1157,17 +1162,17 @@ function buildGroupSeed(config) {
1157
1162
  interval: prices?.interval ?? "year",
1158
1163
  notificationEmail: emails?.notificationEmail ?? "",
1159
1164
  replyTo: emails?.replyTo ?? emails?.notificationEmail ?? "",
1160
- disclaimerText: policy.disclaimerText ?? "",
1161
- refundPolicyText: policy.refundPolicyText ?? "",
1162
- merchantDisclosureText: policy.merchantDisclosureText ?? "",
1163
- trustCopy: policy.trustCopy ?? "",
1165
+ disclaimerText: policy2.disclaimerText ?? "",
1166
+ refundPolicyText: policy2.refundPolicyText ?? "",
1167
+ merchantDisclosureText: policy2.merchantDisclosureText ?? "",
1168
+ trustCopy: policy2.trustCopy ?? "",
1164
1169
  emailTemplates: emails?.templates ?? defaultEmailTemplates(config.name),
1165
1170
  schedulingJson: scheduling
1166
1171
  };
1167
1172
  if (emails?.debugEmail) row2.debugEmail = emails.debugEmail;
1168
1173
  if (emails?.fromName?.trim()) row2.fromName = emails.fromName.trim();
1169
- if (policy.commitmentText) row2.commitmentText = policy.commitmentText;
1170
- if (policy.normsText) row2.normsText = policy.normsText;
1174
+ if (policy2.commitmentText) row2.commitmentText = policy2.commitmentText;
1175
+ if (policy2.normsText) row2.normsText = policy2.normsText;
1171
1176
  return row2;
1172
1177
  }
1173
1178
 
@@ -2476,13 +2481,13 @@ function firstPrice(object2) {
2476
2481
  return { ...priceId ? { priceId } : {}, ...quantity !== void 0 ? { quantity } : {} };
2477
2482
  }
2478
2483
  function normalizeNetworkCommercialStripeEvent(input, digest) {
2479
- const event = raw(input);
2480
- const eventId = providerId(event.id, "evt_") ?? "";
2481
- const eventType = text(event.type) ?? "";
2482
- const object2 = raw(raw(event.data).object);
2484
+ const event2 = raw(input);
2485
+ const eventId = providerId(event2.id, "evt_") ?? "";
2486
+ const eventType = text(event2.type) ?? "";
2487
+ const object2 = raw(raw(event2.data).object);
2483
2488
  const objectId = providerId(object2.id) ?? "";
2484
2489
  const candidates = metadataCandidates(object2);
2485
- const identity = {};
2490
+ const identity2 = {};
2486
2491
  const identityKeys2 = {
2487
2492
  appId: "odlaAppId",
2488
2493
  chapterId: "chapterId",
@@ -2492,7 +2497,7 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
2492
2497
  };
2493
2498
  for (const [target, source] of Object.entries(identityKeys2)) {
2494
2499
  const value = metadataValue(candidates, source);
2495
- if (value) identity[target] = value;
2500
+ if (value) identity2[target] = value;
2496
2501
  }
2497
2502
  let objectKind = "ignored";
2498
2503
  if (eventType.startsWith("customer.subscription.")) objectKind = "subscription";
@@ -2514,11 +2519,11 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
2514
2519
  eventId,
2515
2520
  digest,
2516
2521
  eventType,
2517
- eventCreated: integer(event.created) ?? 0,
2518
- livemode: event.livemode === true,
2522
+ eventCreated: integer(event2.created) ?? 0,
2523
+ livemode: event2.livemode === true,
2519
2524
  objectKind,
2520
2525
  objectId,
2521
- identity,
2526
+ identity: identity2,
2522
2527
  ...metadataValue(candidates, "applicationId") ? { applicationId: metadataValue(candidates, "applicationId") } : {},
2523
2528
  ...metadataValue(candidates, "personId") ? { personId: metadataValue(candidates, "personId") } : {},
2524
2529
  ...metadataValue(candidates, "membershipId") ? { membershipId: metadataValue(candidates, "membershipId") } : {},
@@ -3365,15 +3370,15 @@ function membershipDiscountPolicyVersion(value) {
3365
3370
  // src/membership-offer.ts
3366
3371
  function createMembershipCommercialOffer(input) {
3367
3372
  const offering = membershipOfferingVersion(input.offering);
3368
- const policy = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
3373
+ const policy2 = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
3369
3374
  const now = input.now;
3370
3375
  if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(input.approvedFoundingCount) || input.approvedFoundingCount < 0) {
3371
3376
  throw new TypeError("invalid offer clock or founding count");
3372
3377
  }
3373
3378
  if (offering.status !== "published" || now < offering.effectiveFrom || offering.effectiveUntil !== void 0 && now >= offering.effectiveUntil) throw new TypeError("offering is unavailable");
3374
- if (policy && policy.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
3375
- const founding = Boolean(policy && policy.status === "published" && offering.basePriceCents > 0 && policy.cohortEligibleSkuIds.includes(offering.baseSkuId) && now >= policy.qualifyingStartsAt && now < policy.qualifyingEndsAt && input.approvedFoundingCount < policy.softTarget);
3376
- const earned = founding ? policy : null;
3379
+ if (policy2 && policy2.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
3380
+ const founding = Boolean(policy2 && policy2.status === "published" && offering.basePriceCents > 0 && policy2.cohortEligibleSkuIds.includes(offering.baseSkuId) && now >= policy2.qualifyingStartsAt && now < policy2.qualifyingEndsAt && input.approvedFoundingCount < policy2.softTarget);
3381
+ const earned = founding ? policy2 : null;
3377
3382
  const initialTermEndsAt = offering.term.kind === "fixed_end" ? offering.term.endsAt : addMembershipCalendarMonths(now, offering.term.months);
3378
3383
  if (initialTermEndsAt <= now) throw new TypeError("membership service term has ended");
3379
3384
  if (offering.term.kind === "rolling_months" && offering.term.months !== (offering.interval === "year" ? 12 : 1)) {
@@ -3465,15 +3470,15 @@ async function previewMembershipCommercialOffer(db, input) {
3465
3470
  }
3466
3471
  if (!Number.isSafeInteger(input.now) || input.now < 0) reject("invalid_offer_window");
3467
3472
  const state = await authoritativeState(db, input);
3468
- const { offering, policy } = await currentPolicy(db, input, false, state);
3469
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
3473
+ const { offering, policy: policy2 } = await currentPolicy(db, input, false, state);
3474
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
3470
3475
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
3471
3476
  const contract = createMembershipCommercialOffer({
3472
3477
  offering,
3473
- discountPolicy: policy,
3478
+ discountPolicy: policy2,
3474
3479
  approvedFoundingCount: Number(approvedFoundingCount),
3475
3480
  now: input.now,
3476
- couponId: await membershipDiscountCouponId(input, policy?.id)
3481
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
3477
3482
  });
3478
3483
  const discount = contract.billingTerms.discount;
3479
3484
  return {
@@ -3502,12 +3507,12 @@ async function currentPolicy(db, input, restartAware, state) {
3502
3507
  });
3503
3508
  if (!Array.isArray(rows3.membershipOfferingVersion) || !Array.isArray(rows3.discountPolicyVersion) || rows3.membershipOfferingVersion.length >= 200 || rows3.discountPolicyVersion.length >= 200) return reject("policy_history_incomplete");
3504
3509
  const offerings = rows3.membershipOfferingVersion.map((row2) => membershipOfferingVersion(row2.snapshot)).filter((offer) => offer.chapterId === input.chapterId && offer.baseSkuId === input.skuId && offer.status === "published" && input.now >= offer.effectiveFrom && (offer.effectiveUntil === void 0 || input.now < offer.effectiveUntil));
3505
- const policies = rows3.discountPolicyVersion.map((row2) => membershipDiscountPolicyVersion(row2.snapshot)).filter((policy2) => policy2.chapterId === input.chapterId && policy2.status === "published" && policy2.cohortEligibleSkuIds.includes(input.skuId) && input.now >= policy2.qualifyingStartsAt && input.now < policy2.qualifyingEndsAt);
3510
+ const policies = rows3.discountPolicyVersion.map((row2) => membershipDiscountPolicyVersion(row2.snapshot)).filter((policy3) => policy3.chapterId === input.chapterId && policy3.status === "published" && policy3.cohortEligibleSkuIds.includes(input.skuId) && input.now >= policy3.qualifyingStartsAt && input.now < policy3.qualifyingEndsAt);
3506
3511
  const offering = offerings.sort((a, b) => b.version - a.version)[0];
3507
3512
  const restart = restartAware && input.applicationId ? await load(db, "membershipRestart", (await membershipOfferIdForRequest({ ...input, applicationId: input.applicationId, requestId: "checkout_binding" })).replace(/^offer_/, "membership_")) : void 0;
3508
- const policy = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
3509
- if (!offering || offerings.filter((item) => item.version === offering.version).length !== 1 || policy && policies.filter((item) => item.version === policy.version).length !== 1) reject("ambiguous_membership_policy");
3510
- return { offering, policy };
3513
+ const policy2 = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
3514
+ if (!offering || offerings.filter((item) => item.version === offering.version).length !== 1 || policy2 && policies.filter((item) => item.version === policy2.version).length !== 1) reject("ambiguous_membership_policy");
3515
+ return { offering, policy: policy2 };
3511
3516
  }
3512
3517
  async function issueMembershipCommercialOffer(db, input) {
3513
3518
  validate(input);
@@ -3515,15 +3520,15 @@ async function issueMembershipCommercialOffer(db, input) {
3515
3520
  const state = await authoritativeState(db, input);
3516
3521
  const existing = await load(db, "membershipCheckoutOffer", input.offerId);
3517
3522
  if (existing) return restore(existing, input);
3518
- const { offering, policy } = await currentPolicy(db, input, true, state);
3519
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
3523
+ const { offering, policy: policy2 } = await currentPolicy(db, input, true, state);
3524
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
3520
3525
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
3521
3526
  const contract = createMembershipCommercialOffer({
3522
3527
  offering,
3523
- discountPolicy: policy,
3528
+ discountPolicy: policy2,
3524
3529
  approvedFoundingCount: Number(approvedFoundingCount),
3525
3530
  now: input.now,
3526
- couponId: await membershipDiscountCouponId(input, policy?.id)
3531
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
3527
3532
  });
3528
3533
  const value = {
3529
3534
  id: input.offerId,
@@ -5305,7 +5310,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5305
5310
  };
5306
5311
  if (action === "seat-accept") return acceptNamedSeatRecipient(db, recipientInput, now);
5307
5312
  if (action === "seat-approve") return approveNamedSeatRecipient(db, recipientInput, now);
5308
- const checkout = {
5313
+ const checkout2 = {
5309
5314
  ...target,
5310
5315
  priceId: peer.seatPriceId,
5311
5316
  recipientName: String(input.recipientName),
@@ -5313,7 +5318,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5313
5318
  purchaserEmail: String(input.purchaserEmail),
5314
5319
  purchaserUserId: String(input.purchaserUserId)
5315
5320
  };
5316
- if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout, now))) return grantIncludedNamedSeat(db, checkout, now);
5321
+ if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout2, now))) return grantIncludedNamedSeat(db, checkout2, now);
5317
5322
  const secret = await getVaultSecret(db, stripeSecretName);
5318
5323
  if (!secret) throw new Error("network Stripe provider is unavailable");
5319
5324
  if (action === "seat-decline") {
@@ -5329,7 +5334,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5329
5334
  return { seatId: seat.id, status: "refunded", refundedCents: refunded.refundedCents, subscriptionCanceled: false };
5330
5335
  }
5331
5336
  if (action === "seat-operator-refund") return refundNamedSeatByOperator(db, { ...target, seatId: String(input.seatId) }, secret, now);
5332
- if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout, secret, now);
5337
+ if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout2, secret, now);
5333
5338
  const evidence = { ...target, seatId: String(input.seatId), eventId: String(input.eventId) };
5334
5339
  if (action === "seat-payments") {
5335
5340
  const receipt = await confirmNamedMembershipSeatPayment(db, evidence, now);
@@ -5400,9 +5405,9 @@ function createMembershipAuthorityHandler(options) {
5400
5405
  const identities = /* @__PURE__ */ new Set();
5401
5406
  for (const peer of peers) {
5402
5407
  if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(peer.appId) || !SAFE.test(peer.chapterId) || !/^[a-z0-9][a-z0-9-]{1,31}$/.test(peer.environment) || !/^[a-z0-9]{2,12}$/.test(peer.runtime) || !/^[A-Za-z][A-Za-z0-9_-]{1,127}$/.test(peer.secretName)) throw new TypeError("invalid authority peer");
5403
- const identity = `${peer.appId}:${peer.runtime}`;
5404
- if (identities.has(identity)) throw new TypeError("ambiguous authority peer");
5405
- identities.add(identity);
5408
+ const identity2 = `${peer.appId}:${peer.runtime}`;
5409
+ if (identities.has(identity2)) throw new TypeError("ambiguous authority peer");
5410
+ identities.add(identity2);
5406
5411
  if (peer.seatPriceId !== void 0 && !/^price_[A-Za-z0-9]+$/.test(peer.seatPriceId)) throw new TypeError("invalid seat Price");
5407
5412
  }
5408
5413
  return async (req) => {
@@ -6714,6 +6719,41 @@ async function createBuilderLibraryEnvelope(document) {
6714
6719
  return { document, digest: await builderLibraryDigest(document) };
6715
6720
  }
6716
6721
 
6722
+ // src/email-retirement.ts
6723
+ function isRetiredEmailCopy(subject, text6) {
6724
+ const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
6725
+ const bodyCopy = text6.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
6726
+ return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
6727
+ }
6728
+
6729
+ // src/email.ts
6730
+ import { validateEmailCopy } from "@odla-ai/crm";
6731
+
6732
+ // src/email-events.ts
6733
+ var identity = ["firstName", "lastName", "email", "phone", "state"];
6734
+ var policy = ["refundPolicyText", "commitmentText", "normsText"];
6735
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
6736
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
6737
+ var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
6738
+ var CHAPTER_EMAIL_EVENTS = {
6739
+ adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
6740
+ submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
6741
+ paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
6742
+ prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
6743
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
6744
+ namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
6745
+ seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
6746
+ };
6747
+ function chapterEmailEvents(chapter) {
6748
+ const base = chapter.mode === "hub" ? Object.fromEntries(Object.entries(chapter.crm.config.templates ?? {}).map(([key3, spec]) => [key3, {
6749
+ label: key3,
6750
+ trigger: "Administrator sends a message",
6751
+ audience: "Selected contact",
6752
+ variables: spec.vars
6753
+ }])) : CHAPTER_EMAIL_EVENTS;
6754
+ return { ...base, ...chapter.config.emails?.events };
6755
+ }
6756
+
6717
6757
  // src/email.ts
6718
6758
  function render(template, vars) {
6719
6759
  return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
@@ -6737,19 +6777,158 @@ function isAlreadySent(priorRows) {
6737
6777
  function planDelivery(input) {
6738
6778
  const tpl = input.group.emailTemplates?.[input.template];
6739
6779
  if (!tpl) return { deliver: false, reason: "template-missing" };
6740
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
6780
+ if (typeof tpl.enabled !== "boolean") return { deliver: false, reason: "template-invalid", issues: ["Choose explicitly whether this email is enabled."] };
6781
+ if (tpl.enabled !== true && !input.force) return { deliver: false, reason: "disabled" };
6782
+ if (!/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(input.to)) return { deliver: false, reason: "recipient-invalid" };
6741
6783
  const vars = groupVars(input.group, input.vars);
6784
+ if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
6785
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
6786
+ const issues = validateEmailCopy(tpl, {
6787
+ allowedVariables: event2?.variables,
6788
+ optionalVariables: event2?.optionalVariables,
6789
+ requiredVariables: event2?.requiresTier ? ["tierName"] : [],
6790
+ values: vars,
6791
+ tierId: vars.tierId
6792
+ });
6793
+ if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
6742
6794
  const isProd = input.envName === "prod";
6743
6795
  const redirect = !isProd && !!input.group.debugEmail;
6744
6796
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
6745
6797
  const to = redirect ? input.group.debugEmail : input.to;
6746
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
6798
+ const renderedSubject = render(tpl.subject, vars);
6799
+ const renderedText = render(tpl.text, vars);
6800
+ if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
6801
+ if (validateEmailCopy({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
6802
+ const subject = (redirect ? "[dev] " : "") + renderedSubject;
6747
6803
  const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
6748
6804
 
6749
- ` + render(tpl.text, vars) : render(tpl.text, vars);
6805
+ ` + renderedText : renderedText;
6750
6806
  return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
6751
6807
  }
6752
6808
 
6809
+ // src/email-readiness.ts
6810
+ import { isEmailTierName, validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
6811
+ function validateEmailReadiness(input) {
6812
+ const issues = [];
6813
+ for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
6814
+ issues.push({ code: "event-undeclared", message: `Stored email ${key3} has no declared event. Review its trigger before using it.` });
6815
+ }
6816
+ if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
6817
+ for (const tier of input.tiers ?? []) {
6818
+ if (!isEmailTierName(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
6819
+ }
6820
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => isEmailTierName(tier.name, tier.id));
6821
+ if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
6822
+ const events2 = Object.entries(input.events).map(([key3, event2]) => {
6823
+ const template = input.templates[key3];
6824
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy2(template, { allowedVariables: event2.variables });
6825
+ if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
6826
+ if (template) {
6827
+ const used = [...`${template.subject ?? ""}
6828
+ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
6829
+ for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
6830
+ problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
6831
+ }
6832
+ }
6833
+ if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
6834
+ else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
6835
+ return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
6836
+ });
6837
+ return { checkedAt: input.now ?? Date.now(), events: events2, issues };
6838
+ }
6839
+
6840
+ // src/notify.ts
6841
+ import { validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
6842
+
6843
+ // src/email-event-values.ts
6844
+ import { isEmailTierName as isEmailTierName2 } from "@odla-ai/crm";
6845
+
6846
+ // src/tiers-store.ts
6847
+ function rowToTier(row2, groupId) {
6848
+ if (!row2 || typeof row2 !== "object" || Array.isArray(row2) || typeof row2.id !== "string" || !row2.id || row2.groupId !== void 0 && row2.groupId !== groupId || !Number.isSafeInteger(row2.priceCents) || Number(row2.priceCents) < 0 || row2.currency !== void 0 && (typeof row2.currency !== "string" || !/^[a-z]{3}$/.test(row2.currency)) || row2.interval !== void 0 && row2.interval !== "month" && row2.interval !== "year") {
6849
+ throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
6850
+ }
6851
+ return {
6852
+ id: String(row2.id ?? ""),
6853
+ groupId: String(row2.groupId ?? groupId),
6854
+ name: String(row2.name ?? ""),
6855
+ priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
6856
+ ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
6857
+ ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
6858
+ ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
6859
+ ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
6860
+ sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
6861
+ // A row that never set `active` is offered; only an explicit false retires
6862
+ // a tier, so a half-seeded row cannot silently hide a membership.
6863
+ active: row2.active !== false
6864
+ };
6865
+ }
6866
+ async function loadTiers(db, group) {
6867
+ const groupId = String(group.id ?? "");
6868
+ let rows3;
6869
+ try {
6870
+ rows3 = (await db.query({
6871
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
6872
+ })).tiers;
6873
+ } catch {
6874
+ throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
6875
+ }
6876
+ if (!Array.isArray(rows3)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
6877
+ const list = rows3.map((row2) => rowToTier(row2, groupId));
6878
+ return resolveTiers(group, list);
6879
+ }
6880
+ async function loadOfferableTiers(db, group, hasSecretKey) {
6881
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
6882
+ }
6883
+
6884
+ // src/notification-variables.ts
6885
+ async function frozenCheckoutVariables(db, app) {
6886
+ const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
6887
+ const offer = intent?.membershipOffer, contract = offer?.contract;
6888
+ const offering = contract?.offering, quote = intent?.paymentQuote;
6889
+ const amount = contract?.dueTodayCents ?? intent?.priceCents;
6890
+ const currency = offering?.currency ?? intent?.currency;
6891
+ let formatted = "";
6892
+ if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
6893
+ formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
6894
+ }
6895
+ return {
6896
+ paymentAmount: formatted,
6897
+ paymentAmountSummary: formatted ? ` of ${formatted}` : "",
6898
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
6899
+ };
6900
+ }
6901
+ async function catalogTierName(db, group, app) {
6902
+ try {
6903
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
6904
+ } catch {
6905
+ return "";
6906
+ }
6907
+ }
6908
+ async function checkoutVariables(db, group, app) {
6909
+ const frozen = await frozenCheckoutVariables(db, app);
6910
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
6911
+ }
6912
+
6913
+ // src/email-event-values.ts
6914
+ async function emailTierVariables(db, input) {
6915
+ try {
6916
+ const [apps, groups2] = await Promise.all([
6917
+ db.query({ applications: { $: { where: { id: input.applicationId }, limit: 1 } } }),
6918
+ db.query({ groups: { $: { where: { id: input.groupId }, limit: 1 } } })
6919
+ ]);
6920
+ const app = apps.applications?.[0], group = groups2.groups?.[0];
6921
+ if (!app || !group || app.groupId !== input.groupId) return { tierName: "", tier: "" };
6922
+ const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
6923
+ if (!tierId) return { tierName: "", tier: "" };
6924
+ const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
6925
+ const tierName = isEmailTierName2(name, tierId) ? name.trim() : "";
6926
+ return { tierName, tier: tierName, tierId };
6927
+ } catch {
6928
+ return { tierName: "", tier: "" };
6929
+ }
6930
+ }
6931
+
6753
6932
  // src/notify.ts
6754
6933
  function fromAddress(configured, name) {
6755
6934
  const display = name?.trim();
@@ -6767,14 +6946,17 @@ async function sendTemplated(deps, input) {
6767
6946
  const cloudflareReady = Boolean(deps.sender && deps.from);
6768
6947
  const override = deps.debugEmail?.trim();
6769
6948
  const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
6949
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
6950
+ const vars = event2?.requiresTier && input.applicationId ? { ...input.vars, ...await emailTierVariables(deps.db, { applicationId: input.applicationId, groupId: group.id, tierId: input.tierId }) } : input.vars;
6770
6951
  const decision = planDelivery({
6771
6952
  envName: deps.envName,
6772
6953
  group,
6773
6954
  template: input.template,
6774
6955
  to: input.to,
6775
- vars: input.vars,
6956
+ vars,
6776
6957
  cloudflareReady,
6777
- force: input.force
6958
+ force: input.force,
6959
+ event: event2
6778
6960
  });
6779
6961
  if (!decision.deliver) return { sent: false, reason: decision.reason };
6780
6962
  const previous = prior[0];
@@ -6799,6 +6981,8 @@ async function sendTemplated(deps, input) {
6799
6981
  error: decision.transport === "log-only" ? "" : "delivery-pending",
6800
6982
  ...input.applicationId ? { applicationId: input.applicationId } : {}
6801
6983
  };
6984
+ if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
6985
+ if (validateEmailCopy3({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
6802
6986
  try {
6803
6987
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id10, attrs: row2 }], {
6804
6988
  mutationId: `email-claim:${id10}:${attempt}`,
@@ -6952,35 +7136,35 @@ function findGiftRef(obj) {
6952
7136
  ...chapterId ? { chapterId } : {}
6953
7137
  };
6954
7138
  }
6955
- function normalizeWebhookEvent(event, options = {}) {
6956
- const obj = event.data?.object ?? {};
6957
- if (event.type === "charge.refunded" && (!Number.isSafeInteger(obj.amount) || Number(obj.amount) <= 0 || obj.amount_refunded !== obj.amount || obj.refunded !== true)) return { kind: "ignored", type: event.type };
7139
+ function normalizeWebhookEvent(event2, options = {}) {
7140
+ const obj = event2.data?.object ?? {};
7141
+ if (event2.type === "charge.refunded" && (!Number.isSafeInteger(obj.amount) || Number(obj.amount) <= 0 || obj.amount_refunded !== obj.amount || obj.refunded !== true)) return { kind: "ignored", type: event2.type };
6958
7142
  const gift = findGiftRef(obj);
6959
7143
  if (gift.giftIntentId) {
6960
- const { giftIntentId, providerObjectId, ...identity } = gift;
6961
- if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
6962
- return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity };
7144
+ const { giftIntentId, providerObjectId, ...identity2 } = gift;
7145
+ if (event2.type === "payment_intent.succeeded" || event2.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
7146
+ return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity2 };
6963
7147
  }
6964
- if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "refunded" };
6965
- if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "canceled" };
6966
- return { kind: "ignored", type: event.type };
7148
+ if (event2.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "refunded" };
7149
+ if (event2.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "canceled" };
7150
+ return { kind: "ignored", type: event2.type };
6967
7151
  }
6968
7152
  const ref = findApplicationRef(obj);
6969
7153
  const seatId = obj.metadata?.seatId;
6970
7154
  if (typeof seatId === "string" && seatId) {
6971
- if (!options.requireProviderPayment) return { kind: "ignored", type: event.type };
6972
- if (event.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
6973
- if (event.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
6974
- return { kind: "ignored", type: event.type };
7155
+ if (!options.requireProviderPayment) return { kind: "ignored", type: event2.type };
7156
+ if (event2.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
7157
+ if (event2.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
7158
+ return { kind: "ignored", type: event2.type };
6975
7159
  }
6976
- if (event.type === "invoice.paid" && options.requireProviderPayment || event.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event.type };
6977
- switch (event.type) {
7160
+ if (event2.type === "invoice.paid" && options.requireProviderPayment || event2.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event2.type };
7161
+ switch (event2.type) {
6978
7162
  case "invoice.payment_failed":
6979
- return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event.type };
7163
+ return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event2.type };
6980
7164
  case "invoice.paid":
6981
7165
  case "invoice.payment_succeeded": {
6982
7166
  if (obj.billing_reason !== "subscription_create" && obj.billing_reason !== "subscription_cycle") {
6983
- return { kind: "ignored", type: event.type };
7167
+ return { kind: "ignored", type: event2.type };
6984
7168
  }
6985
7169
  const renewalAt = membershipInvoicePeriodEnd(obj);
6986
7170
  const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
@@ -6994,11 +7178,11 @@ function normalizeWebhookEvent(event, options = {}) {
6994
7178
  case "charge.refunded":
6995
7179
  return { kind: "refunded", ...ref };
6996
7180
  case "customer.subscription.updated":
6997
- return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event.type };
7181
+ return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event2.type };
6998
7182
  case "customer.subscription.deleted":
6999
7183
  return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "canceled", ...ref };
7000
7184
  default:
7001
- return { kind: "ignored", type: event.type };
7185
+ return { kind: "ignored", type: event2.type };
7002
7186
  }
7003
7187
  }
7004
7188
  function firstPaymentPatch(currentStatus, renewalAt) {
@@ -8376,44 +8560,6 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
8376
8560
  };
8377
8561
  }
8378
8562
 
8379
- // src/tiers-store.ts
8380
- function rowToTier(row2, groupId) {
8381
- if (!row2 || typeof row2 !== "object" || Array.isArray(row2) || typeof row2.id !== "string" || !row2.id || row2.groupId !== void 0 && row2.groupId !== groupId || !Number.isSafeInteger(row2.priceCents) || Number(row2.priceCents) < 0 || row2.currency !== void 0 && (typeof row2.currency !== "string" || !/^[a-z]{3}$/.test(row2.currency)) || row2.interval !== void 0 && row2.interval !== "month" && row2.interval !== "year") {
8382
- throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
8383
- }
8384
- return {
8385
- id: String(row2.id ?? ""),
8386
- groupId: String(row2.groupId ?? groupId),
8387
- name: String(row2.name ?? ""),
8388
- priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
8389
- ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
8390
- ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
8391
- ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
8392
- ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
8393
- sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
8394
- // A row that never set `active` is offered; only an explicit false retires
8395
- // a tier, so a half-seeded row cannot silently hide a membership.
8396
- active: row2.active !== false
8397
- };
8398
- }
8399
- async function loadTiers(db, group) {
8400
- const groupId = String(group.id ?? "");
8401
- let rows3;
8402
- try {
8403
- rows3 = (await db.query({
8404
- tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
8405
- })).tiers;
8406
- } catch {
8407
- throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
8408
- }
8409
- if (!Array.isArray(rows3)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
8410
- const list = rows3.map((row2) => rowToTier(row2, groupId));
8411
- return resolveTiers(group, list);
8412
- }
8413
- async function loadOfferableTiers(db, group, hasSecretKey) {
8414
- return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
8415
- }
8416
-
8417
8563
  // src/signup-control-contract.ts
8418
8564
  var SIGNUP_CONTROL_SCHEMA_VERSION = 1;
8419
8565
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
@@ -8721,15 +8867,15 @@ function policyOf(value, errors, tiers2) {
8721
8867
  if (!overrides || Object.keys(overrides).length > 20) errors.push("document.policy.tierPolicies must be a bounded tier map");
8722
8868
  else for (const [id10, raw2] of Object.entries(overrides)) {
8723
8869
  const path = `document.policy.tierPolicies.${id10}`;
8724
- const policy = recordOf(raw2);
8725
- if (!tiers2.some((tier) => tier.id === id10) || !policy) {
8870
+ const policy2 = recordOf(raw2);
8871
+ if (!tiers2.some((tier) => tier.id === id10) || !policy2) {
8726
8872
  errors.push(`${path} must name a declared tier and policy`);
8727
8873
  continue;
8728
8874
  }
8729
- unknownKeys3(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
8875
+ unknownKeys3(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
8730
8876
  tierPolicies[id10] = {
8731
- refundPolicyText: text5(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
8732
- ...policy.trustCopy !== void 0 ? { trustCopy: text5(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
8877
+ refundPolicyText: text5(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
8878
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text5(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
8733
8879
  };
8734
8880
  }
8735
8881
  }
@@ -8784,7 +8930,7 @@ function validateSignupControlDocument(input) {
8784
8930
  errors.push("document.membershipPage.tiers must follow the available tier display order");
8785
8931
  }
8786
8932
  const application = applicationOf(root.application, errors);
8787
- const policy = policyOf(root.policy, errors, tiers2);
8933
+ const policy2 = policyOf(root.policy, errors, tiers2);
8788
8934
  let scheduling;
8789
8935
  if (root.scheduling !== void 0) {
8790
8936
  const checked = validateScheduling(root.scheduling);
@@ -8804,7 +8950,7 @@ function validateSignupControlDocument(input) {
8804
8950
  tiers: tiers2,
8805
8951
  membershipPage: page.value,
8806
8952
  ...application ? { application } : {},
8807
- ...policy ? { policy } : {},
8953
+ ...policy2 ? { policy: policy2 } : {},
8808
8954
  ...scheduling ? { scheduling } : {},
8809
8955
  ...theme ? { theme } : {},
8810
8956
  ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
@@ -9519,23 +9665,23 @@ function memberSystemAvailability(input) {
9519
9665
  ...!input.bnfFrontendReachable ? { reason: "bnf_frontend_unavailable" } : {}
9520
9666
  };
9521
9667
  }
9522
- function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
9668
+ function classifyMemberStripeEvent(identity2, target, hasProvenLegacyLocalLink) {
9523
9669
  const keys2 = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
9524
- const supplied = keys2.filter((key3) => identity[key3] !== void 0);
9525
- if (supplied.some((key3) => identity[key3] !== target[key3])) return "ignore_sibling";
9670
+ const supplied = keys2.filter((key3) => identity2[key3] !== void 0);
9671
+ if (supplied.some((key3) => identity2[key3] !== target[key3])) return "ignore_sibling";
9526
9672
  if (supplied.length === keys2.length) return "apply";
9527
9673
  return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
9528
9674
  }
9529
9675
  function planMemberSystemEventReplay(events2) {
9530
9676
  const seen = /* @__PURE__ */ new Map();
9531
9677
  const plan = { accepted: [], replayed: [], collisions: [] };
9532
- for (const event of events2) {
9533
- const digest = seen.get(event.id);
9678
+ for (const event2 of events2) {
9679
+ const digest = seen.get(event2.id);
9534
9680
  if (digest === void 0) {
9535
- seen.set(event.id, event.digest);
9536
- plan.accepted.push(event.id);
9537
- } else if (digest === event.digest) plan.replayed.push(event.id);
9538
- else plan.collisions.push(event.id);
9681
+ seen.set(event2.id, event2.digest);
9682
+ plan.accepted.push(event2.id);
9683
+ } else if (digest === event2.digest) plan.replayed.push(event2.id);
9684
+ else plan.collisions.push(event2.id);
9539
9685
  }
9540
9686
  return plan;
9541
9687
  }
@@ -9615,6 +9761,7 @@ export {
9615
9761
  BUILDER_LIBRARY_MANIFEST_PATH,
9616
9762
  BUILDER_LIBRARY_PROMOTE_PATH,
9617
9763
  BUILDER_LIBRARY_SCHEMA_VERSION,
9764
+ CHAPTER_EMAIL_EVENTS,
9618
9765
  CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
9619
9766
  CHAPTER_PROMOTION_ADAPTER,
9620
9767
  DEFAULT_CHAPTER_COPY,
@@ -9671,6 +9818,7 @@ export {
9671
9818
  canonicalSignupControl,
9672
9819
  chapterBrandFromTokens,
9673
9820
  chapterDb,
9821
+ chapterEmailEvents,
9674
9822
  chapterFlowFieldContract,
9675
9823
  clampArray,
9676
9824
  classifyMemberStripeEvent,
@@ -9850,6 +9998,7 @@ export {
9850
9998
  updateClerkUserMetadataByEmail,
9851
9999
  validateAdmissionGrantDocument,
9852
10000
  validateBuilderLibraryDocument,
10001
+ validateEmailReadiness,
9853
10002
  validateGiftOfferCatalog,
9854
10003
  validateMemberContentDocument,
9855
10004
  validateMemberSystemAuthority,