@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.
@@ -37,6 +37,7 @@ __export(worker_exports, {
37
37
  deliverSignupControl: () => deliverSignupControl,
38
38
  drainMembershipEffects: () => drainMembershipEffects,
39
39
  grantIncludedNamedSeat: () => grantIncludedNamedSeat,
40
+ liftSeatPurchaseWindow: () => liftSeatPurchaseWindow,
40
41
  lowerSeatPrice: () => lowerSeatPrice,
41
42
  namedSeatIncluded: () => namedSeatIncluded,
42
43
  namedSeatRecipientState: () => namedSeatRecipientState,
@@ -436,12 +437,12 @@ function canonical(value) {
436
437
  return value;
437
438
  }
438
439
  async function stripeEventDigest(rawBody) {
439
- const event = JSON.parse(rawBody);
440
- if (!event || typeof event !== "object" || Array.isArray(event)) throw new TypeError("invalid Stripe event");
440
+ const event2 = JSON.parse(rawBody);
441
+ if (!event2 || typeof event2 !== "object" || Array.isArray(event2)) throw new TypeError("invalid Stripe event");
441
442
  const keys3 = ["id", "type", "created", "livemode", "account", "context", "api_version", "data"];
442
443
  return stripePayloadDigest(JSON.stringify(canonical({
443
444
  digestVersion: 1,
444
- ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event, key3)).map((key3) => [key3, event[key3]]))
445
+ ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event2, key3)).map((key3) => [key3, event2[key3]]))
445
446
  })));
446
447
  }
447
448
 
@@ -708,13 +709,13 @@ function firstPrice(object2) {
708
709
  return { ...priceId ? { priceId } : {}, ...quantity !== void 0 ? { quantity } : {} };
709
710
  }
710
711
  function normalizeNetworkCommercialStripeEvent(input, digest) {
711
- const event = raw(input);
712
- const eventId = providerId(event.id, "evt_") ?? "";
713
- const eventType = text(event.type) ?? "";
714
- const object2 = raw(raw(event.data).object);
712
+ const event2 = raw(input);
713
+ const eventId = providerId(event2.id, "evt_") ?? "";
714
+ const eventType = text(event2.type) ?? "";
715
+ const object2 = raw(raw(event2.data).object);
715
716
  const objectId = providerId(object2.id) ?? "";
716
717
  const candidates = metadataCandidates(object2);
717
- const identity = {};
718
+ const identity2 = {};
718
719
  const identityKeys2 = {
719
720
  appId: "odlaAppId",
720
721
  chapterId: "chapterId",
@@ -724,7 +725,7 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
724
725
  };
725
726
  for (const [target, source] of Object.entries(identityKeys2)) {
726
727
  const value = metadataValue(candidates, source);
727
- if (value) identity[target] = value;
728
+ if (value) identity2[target] = value;
728
729
  }
729
730
  let objectKind = "ignored";
730
731
  if (eventType.startsWith("customer.subscription.")) objectKind = "subscription";
@@ -746,11 +747,11 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
746
747
  eventId,
747
748
  digest,
748
749
  eventType,
749
- eventCreated: integer(event.created) ?? 0,
750
- livemode: event.livemode === true,
750
+ eventCreated: integer(event2.created) ?? 0,
751
+ livemode: event2.livemode === true,
751
752
  objectKind,
752
753
  objectId,
753
- identity,
754
+ identity: identity2,
754
755
  ...metadataValue(candidates, "applicationId") ? { applicationId: metadataValue(candidates, "applicationId") } : {},
755
756
  ...metadataValue(candidates, "personId") ? { personId: metadataValue(candidates, "personId") } : {},
756
757
  ...metadataValue(candidates, "membershipId") ? { membershipId: metadataValue(candidates, "membershipId") } : {},
@@ -1227,35 +1228,35 @@ function findGiftRef(obj) {
1227
1228
  ...chapterId ? { chapterId } : {}
1228
1229
  };
1229
1230
  }
1230
- function normalizeWebhookEvent(event, options = {}) {
1231
- const obj = event.data?.object ?? {};
1232
- 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 };
1231
+ function normalizeWebhookEvent(event2, options = {}) {
1232
+ const obj = event2.data?.object ?? {};
1233
+ 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 };
1233
1234
  const gift = findGiftRef(obj);
1234
1235
  if (gift.giftIntentId) {
1235
- const { giftIntentId, providerObjectId, ...identity } = gift;
1236
- if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1237
- return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity };
1236
+ const { giftIntentId, providerObjectId, ...identity2 } = gift;
1237
+ if (event2.type === "payment_intent.succeeded" || event2.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1238
+ return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity2 };
1238
1239
  }
1239
- if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "refunded" };
1240
- if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "canceled" };
1241
- return { kind: "ignored", type: event.type };
1240
+ if (event2.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "refunded" };
1241
+ if (event2.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "canceled" };
1242
+ return { kind: "ignored", type: event2.type };
1242
1243
  }
1243
1244
  const ref = findApplicationRef(obj);
1244
1245
  const seatId = obj.metadata?.seatId;
1245
1246
  if (typeof seatId === "string" && seatId) {
1246
- if (!options.requireProviderPayment) return { kind: "ignored", type: event.type };
1247
- if (event.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1248
- if (event.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1249
- return { kind: "ignored", type: event.type };
1247
+ if (!options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1248
+ if (event2.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1249
+ if (event2.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1250
+ return { kind: "ignored", type: event2.type };
1250
1251
  }
1251
- if (event.type === "invoice.paid" && options.requireProviderPayment || event.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event.type };
1252
- switch (event.type) {
1252
+ if (event2.type === "invoice.paid" && options.requireProviderPayment || event2.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1253
+ switch (event2.type) {
1253
1254
  case "invoice.payment_failed":
1254
- return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event.type };
1255
+ return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event2.type };
1255
1256
  case "invoice.paid":
1256
1257
  case "invoice.payment_succeeded": {
1257
1258
  if (obj.billing_reason !== "subscription_create" && obj.billing_reason !== "subscription_cycle") {
1258
- return { kind: "ignored", type: event.type };
1259
+ return { kind: "ignored", type: event2.type };
1259
1260
  }
1260
1261
  const renewalAt = membershipInvoicePeriodEnd(obj);
1261
1262
  const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
@@ -1269,11 +1270,11 @@ function normalizeWebhookEvent(event, options = {}) {
1269
1270
  case "charge.refunded":
1270
1271
  return { kind: "refunded", ...ref };
1271
1272
  case "customer.subscription.updated":
1272
- return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event.type };
1273
+ return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event2.type };
1273
1274
  case "customer.subscription.deleted":
1274
1275
  return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "canceled", ...ref };
1275
1276
  default:
1276
- return { kind: "ignored", type: event.type };
1277
+ return { kind: "ignored", type: event2.type };
1277
1278
  }
1278
1279
  }
1279
1280
  function firstPaymentPatch(currentStatus, renewalAt) {
@@ -1632,6 +1633,41 @@ async function backfillCrm(deps) {
1632
1633
  return { synced, errors };
1633
1634
  }
1634
1635
 
1636
+ // src/email-retirement.ts
1637
+ function isRetiredEmailCopy(subject, text8) {
1638
+ const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
1639
+ const bodyCopy = text8.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1640
+ return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
1641
+ }
1642
+
1643
+ // src/email.ts
1644
+ var import_crm5 = require("@odla-ai/crm");
1645
+
1646
+ // src/email-events.ts
1647
+ var identity = ["firstName", "lastName", "email", "phone", "state"];
1648
+ var policy = ["refundPolicyText", "commitmentText", "normsText"];
1649
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
1650
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
1651
+ var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
1652
+ var CHAPTER_EMAIL_EVENTS = {
1653
+ adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
1654
+ submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
1655
+ paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
1656
+ prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
1657
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
1658
+ namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
1659
+ seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
1660
+ };
1661
+ function chapterEmailEvents(chapter) {
1662
+ const base = chapter.mode === "hub" ? Object.fromEntries(Object.entries(chapter.crm.config.templates ?? {}).map(([key3, spec]) => [key3, {
1663
+ label: key3,
1664
+ trigger: "Administrator sends a message",
1665
+ audience: "Selected contact",
1666
+ variables: spec.vars
1667
+ }])) : CHAPTER_EMAIL_EVENTS;
1668
+ return { ...base, ...chapter.config.emails?.events };
1669
+ }
1670
+
1635
1671
  // src/email.ts
1636
1672
  function render(template, vars) {
1637
1673
  return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
@@ -1655,21 +1691,233 @@ function isAlreadySent(priorRows) {
1655
1691
  function planDelivery(input) {
1656
1692
  const tpl = input.group.emailTemplates?.[input.template];
1657
1693
  if (!tpl) return { deliver: false, reason: "template-missing" };
1658
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
1694
+ if (typeof tpl.enabled !== "boolean") return { deliver: false, reason: "template-invalid", issues: ["Choose explicitly whether this email is enabled."] };
1695
+ if (tpl.enabled !== true && !input.force) return { deliver: false, reason: "disabled" };
1696
+ if (!/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(input.to)) return { deliver: false, reason: "recipient-invalid" };
1659
1697
  const vars = groupVars(input.group, input.vars);
1698
+ if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
1699
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1700
+ const issues = (0, import_crm5.validateEmailCopy)(tpl, {
1701
+ allowedVariables: event2?.variables,
1702
+ optionalVariables: event2?.optionalVariables,
1703
+ requiredVariables: event2?.requiresTier ? ["tierName"] : [],
1704
+ values: vars,
1705
+ tierId: vars.tierId
1706
+ });
1707
+ if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
1660
1708
  const isProd = input.envName === "prod";
1661
1709
  const redirect = !isProd && !!input.group.debugEmail;
1662
1710
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1663
1711
  const to = redirect ? input.group.debugEmail : input.to;
1664
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1712
+ const renderedSubject = render(tpl.subject, vars);
1713
+ const renderedText = render(tpl.text, vars);
1714
+ if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1715
+ if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1716
+ const subject = (redirect ? "[dev] " : "") + renderedSubject;
1665
1717
  const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
1666
1718
 
1667
- ` + render(tpl.text, vars) : render(tpl.text, vars);
1719
+ ` + renderedText : renderedText;
1668
1720
  return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
1669
1721
  }
1670
1722
 
1671
1723
  // src/notify.ts
1672
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1724
+ var import_crm7 = require("@odla-ai/crm");
1725
+
1726
+ // src/email-event-values.ts
1727
+ var import_crm6 = require("@odla-ai/crm");
1728
+
1729
+ // src/tiers.ts
1730
+ function tierIsFree(tier) {
1731
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
1732
+ }
1733
+ function tierPayable(tier, group, hasSecretKey) {
1734
+ if (tierIsFree(tier)) return true;
1735
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
1736
+ }
1737
+ function byOrder(a, b) {
1738
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
1739
+ }
1740
+ function resolveTiers(group, rows5 = []) {
1741
+ const stored = rows5.filter((tier) => tier.active);
1742
+ if (rows5.length) return [...stored].sort(byOrder);
1743
+ return [{
1744
+ id: "standard",
1745
+ groupId: String(group.id ?? ""),
1746
+ name: "Membership",
1747
+ priceCents: group.standardPriceCents ?? 0,
1748
+ currency: group.currency ?? "usd",
1749
+ interval: group.interval ?? "year",
1750
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
1751
+ sortOrder: 0,
1752
+ active: true
1753
+ }];
1754
+ }
1755
+ function offerableTiers(tiers, group, hasSecretKey) {
1756
+ return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
1757
+ }
1758
+ function findTier(tiers, tierId) {
1759
+ if (!tierId) return tiers.length === 1 ? tiers[0] : null;
1760
+ return tiers.find((tier) => tier.id === tierId) ?? null;
1761
+ }
1762
+ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
1763
+ return tiers.map((tier) => ({
1764
+ id: tier.id,
1765
+ name: tier.name,
1766
+ priceCents: tier.priceCents,
1767
+ currency: tier.currency ?? defaults.currency ?? "usd",
1768
+ interval: tier.interval ?? defaults.interval ?? "year",
1769
+ blurb: tier.blurb ?? "",
1770
+ free: tierIsFree(tier),
1771
+ ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
1772
+ }));
1773
+ }
1774
+
1775
+ // src/tiers-store.ts
1776
+ function rowToTier(row2, groupId) {
1777
+ 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") {
1778
+ throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
1779
+ }
1780
+ return {
1781
+ id: String(row2.id ?? ""),
1782
+ groupId: String(row2.groupId ?? groupId),
1783
+ name: String(row2.name ?? ""),
1784
+ priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
1785
+ ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
1786
+ ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
1787
+ ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
1788
+ ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
1789
+ sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
1790
+ // A row that never set `active` is offered; only an explicit false retires
1791
+ // a tier, so a half-seeded row cannot silently hide a membership.
1792
+ active: row2.active !== false
1793
+ };
1794
+ }
1795
+ async function loadTiers(db, group) {
1796
+ const groupId = String(group.id ?? "");
1797
+ let rows5;
1798
+ try {
1799
+ rows5 = (await db.query({
1800
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
1801
+ })).tiers;
1802
+ } catch {
1803
+ throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
1804
+ }
1805
+ if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
1806
+ const list = rows5.map((row2) => rowToTier(row2, groupId));
1807
+ return resolveTiers(group, list);
1808
+ }
1809
+ async function loadOfferableTiers(db, group, hasSecretKey) {
1810
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
1811
+ }
1812
+
1813
+ // src/notification-variables.ts
1814
+ function lifecycleNotifyDeps(db, env) {
1815
+ return {
1816
+ db,
1817
+ envName: env.ODLA_ENV,
1818
+ sender: env.SEND_EMAIL,
1819
+ from: env.EMAIL_FROM,
1820
+ debugEmail: env.ODLA_DEBUG_EMAIL,
1821
+ now: () => Date.now(),
1822
+ newId: () => crypto.randomUUID()
1823
+ };
1824
+ }
1825
+ async function frozenCheckoutVariables(db, app) {
1826
+ const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
1827
+ const offer = intent?.membershipOffer, contract = offer?.contract;
1828
+ const offering = contract?.offering, quote = intent?.paymentQuote;
1829
+ const amount = contract?.dueTodayCents ?? intent?.priceCents;
1830
+ const currency = offering?.currency ?? intent?.currency;
1831
+ let formatted = "";
1832
+ if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1833
+ formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1834
+ }
1835
+ return {
1836
+ paymentAmount: formatted,
1837
+ paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1838
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
1839
+ };
1840
+ }
1841
+ async function catalogTierName(db, group, app) {
1842
+ try {
1843
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
1844
+ } catch {
1845
+ return "";
1846
+ }
1847
+ }
1848
+ async function checkoutVariables(db, group, app) {
1849
+ const frozen = await frozenCheckoutVariables(db, app);
1850
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
1851
+ }
1852
+ function formatInstant(at2, timeZone) {
1853
+ return new Intl.DateTimeFormat("en-US", {
1854
+ weekday: "long",
1855
+ month: "long",
1856
+ day: "numeric",
1857
+ year: "numeric",
1858
+ hour: "numeric",
1859
+ minute: "2-digit",
1860
+ timeZoneName: "short",
1861
+ timeZone
1862
+ }).format(new Date(at2));
1863
+ }
1864
+ var adminUrlFor = (origin) => `${origin}/admin/`;
1865
+ var membersUrlFor = (origin) => `${origin}/members/`;
1866
+ function applicantVariables(app) {
1867
+ const text8 = (v) => typeof v === "string" ? v : "";
1868
+ return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
1869
+ }
1870
+ function sampleVariables(origin, timeZone, now = Date.now()) {
1871
+ const startAt = formatInstant(now + 864e5, timeZone);
1872
+ return {
1873
+ firstName: "Sample",
1874
+ lastName: "Person",
1875
+ email: "sample@example.com",
1876
+ phone: "(555) 010-0100",
1877
+ state: "CA",
1878
+ tier: "Membership",
1879
+ tierName: "Membership",
1880
+ paymentAmount: "$100.00",
1881
+ paymentAmountSummary: " of $100.00",
1882
+ paymentNextStep: "Next, schedule your introduction call.",
1883
+ submitNextStep: "Next, schedule your introduction call.",
1884
+ startAt,
1885
+ endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
1886
+ timezone: timeZone,
1887
+ meetUrl: "https://meet.google.com/sample",
1888
+ htmlLink: "https://calendar.google.com/event?eid=sample",
1889
+ meetingTime: startAt,
1890
+ meetingLink: "https://meet.google.com/sample",
1891
+ adminUrl: adminUrlFor(origin),
1892
+ membersUrl: membersUrlFor(origin),
1893
+ purchaserName: "Sample Purchaser",
1894
+ recipientName: "Sample Recipient",
1895
+ chapterName: "Sample Chapter",
1896
+ acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1897
+ joinUrl: `${origin}/join?seat=sample`
1898
+ };
1899
+ }
1900
+
1901
+ // src/email-event-values.ts
1902
+ async function emailTierVariables(db, input) {
1903
+ try {
1904
+ const [apps, groups] = await Promise.all([
1905
+ db.query({ applications: { $: { where: { id: input.applicationId }, limit: 1 } } }),
1906
+ db.query({ groups: { $: { where: { id: input.groupId }, limit: 1 } } })
1907
+ ]);
1908
+ const app = apps.applications?.[0], group = groups.groups?.[0];
1909
+ if (!app || !group || app.groupId !== input.groupId) return { tierName: "", tier: "" };
1910
+ const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
1911
+ if (!tierId) return { tierName: "", tier: "" };
1912
+ const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
1913
+ const tierName2 = (0, import_crm6.isEmailTierName)(name, tierId) ? name.trim() : "";
1914
+ return { tierName: tierName2, tier: tierName2, tierId };
1915
+ } catch {
1916
+ return { tierName: "", tier: "" };
1917
+ }
1918
+ }
1919
+
1920
+ // src/notify.ts
1673
1921
  function fromAddress(configured, name) {
1674
1922
  const display = name?.trim();
1675
1923
  if (!display) return configured;
@@ -1686,14 +1934,17 @@ async function sendTemplated(deps, input) {
1686
1934
  const cloudflareReady = Boolean(deps.sender && deps.from);
1687
1935
  const override = deps.debugEmail?.trim();
1688
1936
  const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1937
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1938
+ const vars = event2?.requiresTier && input.applicationId ? { ...input.vars, ...await emailTierVariables(deps.db, { applicationId: input.applicationId, groupId: group.id, tierId: input.tierId }) } : input.vars;
1689
1939
  const decision = planDelivery({
1690
1940
  envName: deps.envName,
1691
1941
  group,
1692
1942
  template: input.template,
1693
1943
  to: input.to,
1694
- vars: input.vars,
1944
+ vars,
1695
1945
  cloudflareReady,
1696
- force: input.force
1946
+ force: input.force,
1947
+ event: event2
1697
1948
  });
1698
1949
  if (!decision.deliver) return { sent: false, reason: decision.reason };
1699
1950
  const previous = prior[0];
@@ -1718,6 +1969,8 @@ async function sendTemplated(deps, input) {
1718
1969
  error: decision.transport === "log-only" ? "" : "delivery-pending",
1719
1970
  ...input.applicationId ? { applicationId: input.applicationId } : {}
1720
1971
  };
1972
+ if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
1973
+ if ((0, import_crm7.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
1721
1974
  try {
1722
1975
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1723
1976
  mutationId: `email-claim:${id6}:${attempt}`,
@@ -1764,17 +2017,17 @@ async function sendTemplated(deps, input) {
1764
2017
  return { sent: true };
1765
2018
  }
1766
2019
  function emailGroupFrom(row2) {
1767
- const str3 = (v) => typeof v === "string" ? v : void 0;
2020
+ const str4 = (v) => typeof v === "string" ? v : void 0;
1768
2021
  const templates = row2.emailTemplates && typeof row2.emailTemplates === "object" ? row2.emailTemplates : {};
1769
2022
  return {
1770
2023
  id: String(row2.id),
1771
2024
  name: String(row2.name ?? ""),
1772
- replyTo: str3(row2.replyTo) ?? "",
1773
- debugEmail: str3(row2.debugEmail),
1774
- fromName: str3(row2.fromName),
1775
- refundPolicyText: str3(row2.refundPolicyText),
1776
- commitmentText: str3(row2.commitmentText),
1777
- normsText: str3(row2.normsText),
2025
+ replyTo: str4(row2.replyTo) ?? "",
2026
+ debugEmail: str4(row2.debugEmail),
2027
+ fromName: str4(row2.fromName),
2028
+ refundPolicyText: str4(row2.refundPolicyText),
2029
+ commitmentText: str4(row2.commitmentText),
2030
+ normsText: str4(row2.normsText),
1778
2031
  emailTemplates: templates
1779
2032
  };
1780
2033
  }
@@ -1887,15 +2140,15 @@ function membershipDiscountPolicyVersion(value) {
1887
2140
  // src/membership-offer.ts
1888
2141
  function createMembershipCommercialOffer(input) {
1889
2142
  const offering = membershipOfferingVersion(input.offering);
1890
- const policy = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
2143
+ const policy2 = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
1891
2144
  const now = input.now;
1892
2145
  if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(input.approvedFoundingCount) || input.approvedFoundingCount < 0) {
1893
2146
  throw new TypeError("invalid offer clock or founding count");
1894
2147
  }
1895
2148
  if (offering.status !== "published" || now < offering.effectiveFrom || offering.effectiveUntil !== void 0 && now >= offering.effectiveUntil) throw new TypeError("offering is unavailable");
1896
- if (policy && policy.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
1897
- 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);
1898
- const earned = founding ? policy : null;
2149
+ if (policy2 && policy2.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
2150
+ 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);
2151
+ const earned = founding ? policy2 : null;
1899
2152
  const initialTermEndsAt = offering.term.kind === "fixed_end" ? offering.term.endsAt : addMembershipCalendarMonths(now, offering.term.months);
1900
2153
  if (initialTermEndsAt <= now) throw new TypeError("membership service term has ended");
1901
2154
  if (offering.term.kind === "rolling_months" && offering.term.months !== (offering.interval === "year" ? 12 : 1)) {
@@ -1987,15 +2240,15 @@ async function previewMembershipCommercialOffer(db, input) {
1987
2240
  }
1988
2241
  if (!Number.isSafeInteger(input.now) || input.now < 0) reject("invalid_offer_window");
1989
2242
  const state = await authoritativeState(db, input);
1990
- const { offering, policy } = await currentPolicy(db, input, false, state);
1991
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2243
+ const { offering, policy: policy2 } = await currentPolicy(db, input, false, state);
2244
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
1992
2245
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
1993
2246
  const contract = createMembershipCommercialOffer({
1994
2247
  offering,
1995
- discountPolicy: policy,
2248
+ discountPolicy: policy2,
1996
2249
  approvedFoundingCount: Number(approvedFoundingCount),
1997
2250
  now: input.now,
1998
- couponId: await membershipDiscountCouponId(input, policy?.id)
2251
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
1999
2252
  });
2000
2253
  const discount = contract.billingTerms.discount;
2001
2254
  return {
@@ -2024,12 +2277,12 @@ async function currentPolicy(db, input, restartAware, state) {
2024
2277
  });
2025
2278
  if (!Array.isArray(rows5.membershipOfferingVersion) || !Array.isArray(rows5.discountPolicyVersion) || rows5.membershipOfferingVersion.length >= 200 || rows5.discountPolicyVersion.length >= 200) return reject("policy_history_incomplete");
2026
2279
  const offerings = rows5.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));
2027
- const policies = rows5.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);
2280
+ const policies = rows5.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);
2028
2281
  const offering = offerings.sort((a, b) => b.version - a.version)[0];
2029
2282
  const restart = restartAware && input.applicationId ? await load(db, "membershipRestart", (await membershipOfferIdForRequest({ ...input, applicationId: input.applicationId, requestId: "checkout_binding" })).replace(/^offer_/, "membership_")) : void 0;
2030
- const policy = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
2031
- 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");
2032
- return { offering, policy };
2283
+ const policy2 = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
2284
+ 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");
2285
+ return { offering, policy: policy2 };
2033
2286
  }
2034
2287
  async function issueMembershipCommercialOffer(db, input) {
2035
2288
  validate(input);
@@ -2037,15 +2290,15 @@ async function issueMembershipCommercialOffer(db, input) {
2037
2290
  const state = await authoritativeState(db, input);
2038
2291
  const existing = await load(db, "membershipCheckoutOffer", input.offerId);
2039
2292
  if (existing) return restore(existing, input);
2040
- const { offering, policy } = await currentPolicy(db, input, true, state);
2041
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2293
+ const { offering, policy: policy2 } = await currentPolicy(db, input, true, state);
2294
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
2042
2295
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
2043
2296
  const contract = createMembershipCommercialOffer({
2044
2297
  offering,
2045
- discountPolicy: policy,
2298
+ discountPolicy: policy2,
2046
2299
  approvedFoundingCount: Number(approvedFoundingCount),
2047
2300
  now: input.now,
2048
- couponId: await membershipDiscountCouponId(input, policy?.id)
2301
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
2049
2302
  });
2050
2303
  const value = {
2051
2304
  id: input.offerId,
@@ -2312,90 +2565,6 @@ async function verifyMembershipPaymentReceipt(value, target) {
2312
2565
  };
2313
2566
  }
2314
2567
 
2315
- // src/tiers.ts
2316
- function tierIsFree(tier) {
2317
- return !(tier.priceCents > 0) && !tier.stripePriceId;
2318
- }
2319
- function tierPayable(tier, group, hasSecretKey) {
2320
- if (tierIsFree(tier)) return true;
2321
- return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
2322
- }
2323
- function byOrder(a, b) {
2324
- return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
2325
- }
2326
- function resolveTiers(group, rows5 = []) {
2327
- const stored = rows5.filter((tier) => tier.active);
2328
- if (rows5.length) return [...stored].sort(byOrder);
2329
- return [{
2330
- id: "standard",
2331
- groupId: String(group.id ?? ""),
2332
- name: "Membership",
2333
- priceCents: group.standardPriceCents ?? 0,
2334
- currency: group.currency ?? "usd",
2335
- interval: group.interval ?? "year",
2336
- ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
2337
- sortOrder: 0,
2338
- active: true
2339
- }];
2340
- }
2341
- function offerableTiers(tiers, group, hasSecretKey) {
2342
- return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
2343
- }
2344
- function findTier(tiers, tierId) {
2345
- if (!tierId) return tiers.length === 1 ? tiers[0] : null;
2346
- return tiers.find((tier) => tier.id === tierId) ?? null;
2347
- }
2348
- function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
2349
- return tiers.map((tier) => ({
2350
- id: tier.id,
2351
- name: tier.name,
2352
- priceCents: tier.priceCents,
2353
- currency: tier.currency ?? defaults.currency ?? "usd",
2354
- interval: tier.interval ?? defaults.interval ?? "year",
2355
- blurb: tier.blurb ?? "",
2356
- free: tierIsFree(tier),
2357
- ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
2358
- }));
2359
- }
2360
-
2361
- // src/tiers-store.ts
2362
- function rowToTier(row2, groupId) {
2363
- 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") {
2364
- throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
2365
- }
2366
- return {
2367
- id: String(row2.id ?? ""),
2368
- groupId: String(row2.groupId ?? groupId),
2369
- name: String(row2.name ?? ""),
2370
- priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
2371
- ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
2372
- ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
2373
- ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
2374
- ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
2375
- sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
2376
- // A row that never set `active` is offered; only an explicit false retires
2377
- // a tier, so a half-seeded row cannot silently hide a membership.
2378
- active: row2.active !== false
2379
- };
2380
- }
2381
- async function loadTiers(db, group) {
2382
- const groupId = String(group.id ?? "");
2383
- let rows5;
2384
- try {
2385
- rows5 = (await db.query({
2386
- tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
2387
- })).tiers;
2388
- } catch {
2389
- throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
2390
- }
2391
- if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
2392
- const list = rows5.map((row2) => rowToTier(row2, groupId));
2393
- return resolveTiers(group, list);
2394
- }
2395
- async function loadOfferableTiers(db, group, hasSecretKey) {
2396
- return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2397
- }
2398
-
2399
2568
  // src/membership-restart-seat.ts
2400
2569
  async function expireRestartedMembershipSeat(db, target, member, now) {
2401
2570
  const ops = [], guards = [];
@@ -2902,89 +3071,6 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
2902
3071
  }
2903
3072
  }
2904
3073
 
2905
- // src/notification-variables.ts
2906
- function lifecycleNotifyDeps(db, env) {
2907
- return {
2908
- db,
2909
- envName: env.ODLA_ENV,
2910
- sender: env.SEND_EMAIL,
2911
- from: env.EMAIL_FROM,
2912
- debugEmail: env.ODLA_DEBUG_EMAIL,
2913
- now: () => Date.now(),
2914
- newId: () => crypto.randomUUID()
2915
- };
2916
- }
2917
- async function frozenCheckoutVariables(db, app) {
2918
- const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
2919
- const offer = intent?.membershipOffer, contract = offer?.contract;
2920
- const offering = contract?.offering, quote = intent?.paymentQuote;
2921
- const amount = contract?.dueTodayCents ?? intent?.priceCents;
2922
- const currency = offering?.currency ?? intent?.currency;
2923
- let formatted = "";
2924
- if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
2925
- formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
2926
- }
2927
- return {
2928
- paymentAmount: formatted,
2929
- paymentAmountSummary: formatted ? ` of ${formatted}` : "",
2930
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
2931
- };
2932
- }
2933
- async function catalogTierName(db, group, app) {
2934
- try {
2935
- return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
2936
- } catch {
2937
- return "";
2938
- }
2939
- }
2940
- async function checkoutVariables(db, group, app) {
2941
- const frozen = await frozenCheckoutVariables(db, app);
2942
- return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
2943
- }
2944
- function formatInstant(at2, timeZone) {
2945
- return new Intl.DateTimeFormat("en-US", {
2946
- weekday: "long",
2947
- month: "long",
2948
- day: "numeric",
2949
- year: "numeric",
2950
- hour: "numeric",
2951
- minute: "2-digit",
2952
- timeZoneName: "short",
2953
- timeZone
2954
- }).format(new Date(at2));
2955
- }
2956
- var adminUrlFor = (origin) => `${origin}/admin/`;
2957
- var membersUrlFor = (origin) => `${origin}/members/`;
2958
- function applicantVariables(app) {
2959
- const text8 = (v) => typeof v === "string" ? v : "";
2960
- return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
2961
- }
2962
- function sampleVariables(origin, timeZone, now = Date.now()) {
2963
- const startAt = formatInstant(now + 864e5, timeZone);
2964
- return {
2965
- firstName: "Sample",
2966
- lastName: "Person",
2967
- email: "sample@example.com",
2968
- phone: "(555) 010-0100",
2969
- state: "CA",
2970
- tier: "Membership",
2971
- tierName: "Membership",
2972
- paymentAmount: "$100.00",
2973
- paymentAmountSummary: " of $100.00",
2974
- paymentNextStep: "Next, schedule your introduction call.",
2975
- submitNextStep: "Next, schedule your introduction call.",
2976
- startAt,
2977
- endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
2978
- timezone: timeZone,
2979
- meetUrl: "https://meet.google.com/sample",
2980
- htmlLink: "https://calendar.google.com/event?eid=sample",
2981
- meetingTime: startAt,
2982
- meetingLink: "https://meet.google.com/sample",
2983
- adminUrl: adminUrlFor(origin),
2984
- membersUrl: membersUrlFor(origin)
2985
- };
2986
- }
2987
-
2988
3074
  // src/payment-notifications.ts
2989
3075
  var groupFor = async (db, app) => {
2990
3076
  const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
@@ -3270,24 +3356,24 @@ async function revokeGiftClaim(db, claim, reason, now) {
3270
3356
  }
3271
3357
 
3272
3358
  // src/payments-route-support.ts
3273
- async function findPaymentApplication(db, event) {
3274
- if ("applicationId" in event && event.applicationId) {
3275
- const rows5 = await db.query({ applications: { $: { where: { id: event.applicationId }, limit: 1 } } });
3359
+ async function findPaymentApplication(db, event2) {
3360
+ if ("applicationId" in event2 && event2.applicationId) {
3361
+ const rows5 = await db.query({ applications: { $: { where: { id: event2.applicationId }, limit: 1 } } });
3276
3362
  return rows5.applications?.[0];
3277
3363
  }
3278
- if (event.kind !== "refunded" && "customerId" in event && event.customerId) {
3279
- const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event.customerId }, limit: 2 } } });
3364
+ if (event2.kind !== "refunded" && "customerId" in event2 && event2.customerId) {
3365
+ const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event2.customerId }, limit: 2 } } });
3280
3366
  return Array.isArray(applications) && applications.length === 1 ? applications[0] : void 0;
3281
3367
  }
3282
3368
  return void 0;
3283
3369
  }
3284
- function paymentEventPatch(event, status2) {
3285
- if ((status2 === "refunded" || status2 === "declined") && (event.kind === "first_payment" || event.kind === "renewal")) return {};
3286
- switch (event.kind) {
3370
+ function paymentEventPatch(event2, status2) {
3371
+ if ((status2 === "refunded" || status2 === "declined") && (event2.kind === "first_payment" || event2.kind === "renewal")) return {};
3372
+ switch (event2.kind) {
3287
3373
  case "first_payment":
3288
- return firstPaymentPatch(status2, event.renewalAt);
3374
+ return firstPaymentPatch(status2, event2.renewalAt);
3289
3375
  case "renewal":
3290
- return event.renewalAt !== void 0 ? renewalPatch(event.renewalAt) : {};
3376
+ return event2.renewalAt !== void 0 ? renewalPatch(event2.renewalAt) : {};
3291
3377
  case "refunded":
3292
3378
  return refundedPatch();
3293
3379
  case "canceled":
@@ -3308,10 +3394,10 @@ function paymentLineItems(group, tier) {
3308
3394
  }
3309
3395
 
3310
3396
  // src/member-system-contract.ts
3311
- function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
3397
+ function classifyMemberStripeEvent(identity2, target, hasProvenLegacyLocalLink) {
3312
3398
  const keys3 = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
3313
- const supplied = keys3.filter((key3) => identity[key3] !== void 0);
3314
- if (supplied.some((key3) => identity[key3] !== target[key3])) return "ignore_sibling";
3399
+ const supplied = keys3.filter((key3) => identity2[key3] !== void 0);
3400
+ if (supplied.some((key3) => identity2[key3] !== target[key3])) return "ignore_sibling";
3315
3401
  if (supplied.length === keys3.length) return "apply";
3316
3402
  return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
3317
3403
  }
@@ -3321,12 +3407,12 @@ var firstRow = async (db, ns, q) => {
3321
3407
  const rows5 = (await db.query({ [ns]: { $: q } }))[ns];
3322
3408
  return Array.isArray(rows5) ? rows5[0] : void 0;
3323
3409
  };
3324
- var stripeEventIdentity = (event) => ({
3325
- ...event.appId ? { appId: event.appId } : {},
3326
- ...event.environment ? { environment: event.environment } : {},
3327
- ...event.dataEnvironment ? { dataEnvironment: event.dataEnvironment } : {},
3328
- ...event.runtime ? { runtime: event.runtime } : {},
3329
- ...event.chapterId ? { chapterId: event.chapterId } : {}
3410
+ var stripeEventIdentity = (event2) => ({
3411
+ ...event2.appId ? { appId: event2.appId } : {},
3412
+ ...event2.environment ? { environment: event2.environment } : {},
3413
+ ...event2.dataEnvironment ? { dataEnvironment: event2.dataEnvironment } : {},
3414
+ ...event2.runtime ? { runtime: event2.runtime } : {},
3415
+ ...event2.chapterId ? { chapterId: event2.chapterId } : {}
3330
3416
  });
3331
3417
  var stripeEventTarget = (env, ctx) => ({
3332
3418
  appId: env.ODLA_APP_ID,
@@ -3336,12 +3422,12 @@ var stripeEventTarget = (env, ctx) => ({
3336
3422
  chapterId: ctx.chapter.id
3337
3423
  });
3338
3424
  var receiptFor = (db, eventId) => firstRow(db, "stripeEventReceipts", { where: { eventId }, limit: 1 });
3339
- function reportStripePartition(level, eventId, event, disposition) {
3425
+ function reportStripePartition(level, eventId, event2, disposition) {
3340
3426
  console[level]("chapter stripe partition", JSON.stringify({
3341
3427
  eventId,
3342
- kind: event.kind,
3428
+ kind: event2.kind,
3343
3429
  disposition,
3344
- ...stripeEventIdentity(event)
3430
+ ...stripeEventIdentity(event2)
3345
3431
  }));
3346
3432
  }
3347
3433
  var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
@@ -3360,8 +3446,8 @@ async function alertStripeQuarantine(db, ctx, eventId, disposition) {
3360
3446
  artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
3361
3447
  });
3362
3448
  }
3363
- async function reportStripeCollision(db, ctx, eventId, event) {
3364
- reportStripePartition("error", eventId, event, "quarantine_collision");
3449
+ async function reportStripeCollision(db, ctx, eventId, event2) {
3450
+ reportStripePartition("error", eventId, event2, "quarantine_collision");
3365
3451
  await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
3366
3452
  }
3367
3453
  async function storeStripeReceipt(db, input) {
@@ -3396,18 +3482,18 @@ async function storeStripeReceipt(db, input) {
3396
3482
  const raced = await receiptFor(db, input.eventId);
3397
3483
  return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
3398
3484
  }
3399
- async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
3485
+ async function quarantineStripeEvent(db, eventId, digest, event2, ctx) {
3400
3486
  const disposition = "quarantine_unmatched";
3401
- const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
3487
+ const stored = await storeStripeReceipt(db, { eventId, digest, event: event2, disposition, matched: false });
3402
3488
  if (stored === "collision") {
3403
- await reportStripeCollision(db, ctx, eventId, event);
3489
+ await reportStripeCollision(db, ctx, eventId, event2);
3404
3490
  return json({ ok: true, matched: false, collision: true });
3405
3491
  }
3406
- reportStripePartition("warn", eventId, event, disposition);
3492
+ reportStripePartition("warn", eventId, event2, disposition);
3407
3493
  if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
3408
3494
  return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
3409
3495
  }
3410
- async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
3496
+ async function replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx) {
3411
3497
  const existing = await receiptFor(db, eventId);
3412
3498
  if (!existing) return null;
3413
3499
  if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
@@ -3417,7 +3503,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, c
3417
3503
  collisionDigest: digest,
3418
3504
  retryReady: false
3419
3505
  } }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
3420
- await reportStripeCollision(db, ctx, eventId, event);
3506
+ await reportStripeCollision(db, ctx, eventId, event2);
3421
3507
  return json({ ok: true, matched: false, collision: true });
3422
3508
  }
3423
3509
  return json({ ok: true, matched: existing.matched === true, replayed: true });
@@ -3431,42 +3517,42 @@ async function firstRow2(db, ns, q) {
3431
3517
  }
3432
3518
  async function reconcileChapterStripeEvent(input, env, ctx) {
3433
3519
  const db = ctx.makeDb(env);
3434
- const { rawBody, eventId, event } = input;
3435
- if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3520
+ const { rawBody, eventId, event: event2 } = input;
3521
+ if (event2.kind === "ignored") return json({ ok: true, ignored: event2.type });
3436
3522
  const digest = await stripeEventDigest(rawBody);
3437
3523
  const legacyDigest = await stripePayloadDigest(rawBody);
3438
- const identity = stripeEventIdentity(event);
3524
+ const identity2 = stripeEventIdentity(event2);
3439
3525
  const target = stripeEventTarget(env, ctx);
3440
- const preliminary = classifyMemberStripeEvent(identity, target, false);
3526
+ const preliminary = classifyMemberStripeEvent(identity2, target, false);
3441
3527
  if (preliminary === "ignore_sibling") {
3442
- reportStripePartition("warn", eventId, event, preliminary);
3528
+ reportStripePartition("warn", eventId, event2, preliminary);
3443
3529
  return json({ ok: true, matched: false, disposition: preliminary });
3444
3530
  }
3445
- if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3446
- const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3531
+ if (event2.kind === "gift_paid" || event2.kind === "gift_revoked") {
3532
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event2.giftIntentId }, limit: 1 });
3447
3533
  if (intent && typeof intent.runtime === "string" && intent.runtime !== target.runtime) {
3448
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3534
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3449
3535
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3450
3536
  }
3451
- const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3537
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3452
3538
  if (replay2) return replay2;
3453
3539
  const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
3454
- const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
3540
+ const disposition2 = classifyMemberStripeEvent(identity2, target, localIntent);
3455
3541
  if (!localIntent || disposition2 === "quarantine_unmatched") {
3456
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3542
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3457
3543
  }
3458
- if (event.kind === "gift_paid") {
3459
- if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3460
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3544
+ if (event2.kind === "gift_paid") {
3545
+ if (intent.providerObjectId !== event2.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3546
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3461
3547
  }
3462
3548
  try {
3463
3549
  const issued = await issueGiftClaim(db, intent, {
3464
3550
  eventId,
3465
- objectId: event.providerObjectId
3551
+ objectId: event2.providerObjectId
3466
3552
  }, Date.now());
3467
- const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
3553
+ const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: true });
3468
3554
  if (stored3 === "collision") {
3469
- await reportStripeCollision(db, ctx, eventId, event);
3555
+ await reportStripeCollision(db, ctx, eventId, event2);
3470
3556
  return json({ ok: true, matched: false, collision: true });
3471
3557
  }
3472
3558
  return json({ ok: true, matched: true, claimId: issued.claimId });
@@ -3474,57 +3560,57 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3474
3560
  return json({ error: "gift issuance failed" }, 503);
3475
3561
  }
3476
3562
  }
3477
- await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3478
- status: event.reason,
3563
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event2.giftIntentId, attrs: {
3564
+ status: event2.reason,
3479
3565
  updatedAt: Date.now()
3480
3566
  } }], { mutationId: `gift-revoked-intent:${eventId}` });
3481
- const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3482
- if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3483
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
3567
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event2.giftIntentId }, limit: 1 });
3568
+ if (claim) await revokeGiftClaim(db, claim, event2.reason, Date.now());
3569
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: Boolean(claim) });
3484
3570
  if (stored2 === "collision") {
3485
- await reportStripeCollision(db, ctx, eventId, event);
3571
+ await reportStripeCollision(db, ctx, eventId, event2);
3486
3572
  return json({ ok: true, matched: false, collision: true });
3487
3573
  }
3488
3574
  return json({ ok: true, matched: Boolean(claim) });
3489
3575
  }
3490
- const app = await findPaymentApplication(db, event);
3576
+ const app = await findPaymentApplication(db, event2);
3491
3577
  if (app && typeof app.stripeRuntime === "string" && app.stripeRuntime !== target.runtime) {
3492
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3578
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3493
3579
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3494
3580
  }
3495
- const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3581
+ const replay = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3496
3582
  if (replay) return replay;
3497
3583
  const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
3498
- const disposition = classifyMemberStripeEvent(identity, target, localApplication);
3584
+ const disposition = classifyMemberStripeEvent(identity2, target, localApplication);
3499
3585
  if (!localApplication || disposition === "quarantine_unmatched") {
3500
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3586
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3501
3587
  }
3502
3588
  if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
3503
- await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3589
+ await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3504
3590
  return json({ ok: true, historical: true });
3505
3591
  }
3506
- if (event.kind === "seat_paid" || event.kind === "seat_refunded") {
3592
+ if (event2.kind === "seat_paid" || event2.kind === "seat_refunded") {
3507
3593
  const seats = ctx.membershipAuthority?.namedSeats;
3508
3594
  if (!seats) return json({ error: "named seat reconciliation is not configured" }, 503);
3509
3595
  try {
3510
3596
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3511
3597
  if (!intent) return json({ error: "membership checkout reconciliation pending" }, 503);
3512
3598
  const target2 = await membershipTargetForIntent(intent, app, env, ctx);
3513
- const receipt = await seats[event.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event.seatId, eventId }, env);
3514
- if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3515
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3599
+ const receipt = await seats[event2.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event2.seatId, eventId }, env);
3600
+ if (receipt.seatId !== event2.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3601
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3516
3602
  if (stored2 === "collision") {
3517
- await reportStripeCollision(db, ctx, eventId, event);
3603
+ await reportStripeCollision(db, ctx, eventId, event2);
3518
3604
  return json({ ok: true, matched: false, collision: true });
3519
3605
  }
3520
- return json({ ok: true, seatId: event.seatId });
3606
+ return json({ ok: true, seatId: event2.seatId });
3521
3607
  } catch {
3522
3608
  return json({ error: "named seat reconciliation pending" }, 503);
3523
3609
  }
3524
3610
  }
3525
- let authoritativeEvent = event;
3611
+ let authoritativeEvent = event2;
3526
3612
  let membershipProjection = {};
3527
- if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event.kind)) {
3613
+ if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event2.kind)) {
3528
3614
  try {
3529
3615
  const receipt = await renewChapterNetworkMembership(db, env, ctx, app, eventId);
3530
3616
  membershipProjection = { membershipStatus: receipt.status, membershipGraceEndsAt: receipt.graceEndsAt, membershipAutoRenew: receipt.autoRenew };
@@ -3533,13 +3619,13 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3533
3619
  return json({ error: "network membership renewal reconciliation pending" }, 503);
3534
3620
  }
3535
3621
  }
3536
- if (event.kind === "first_payment") {
3622
+ if (event2.kind === "first_payment") {
3537
3623
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3538
3624
  if (intent) {
3539
- const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
3625
+ const evidence = verifyInitialMembershipInvoice(intent, event2.invoice);
3540
3626
  if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
3541
- if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
3542
- if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
3627
+ if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3628
+ if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event2, renewalAt: evidence.renewalAt };
3543
3629
  }
3544
3630
  if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
3545
3631
  try {
@@ -3549,7 +3635,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3549
3635
  }
3550
3636
  }
3551
3637
  }
3552
- const invoiceEvent = event.kind === "first_payment" || event.kind === "renewal" || event.kind === "renewal_failed";
3638
+ const invoiceEvent = event2.kind === "first_payment" || event2.kind === "renewal" || event2.kind === "renewal_failed";
3553
3639
  const patch = { ...invoiceEvent && app.canceled === true ? {} : paymentEventPatch(authoritativeEvent, String(app.status ?? "")), ...membershipProjection };
3554
3640
  if (invoiceEvent && typeof patch.renewalAt === "number" && patch.renewalAt > 0 && typeof app.renewalAt === "number" && patch.renewalAt < app.renewalAt) delete patch.renewalAt;
3555
3641
  let next = app;
@@ -3583,7 +3669,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3583
3669
  return json({ error: "refund account access reconciliation pending" }, 503);
3584
3670
  }
3585
3671
  }
3586
- if (event.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3672
+ if (event2.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3587
3673
  try {
3588
3674
  await notifyPaymentConfirmed(db, env, eventId, next);
3589
3675
  } catch {
@@ -3602,9 +3688,9 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3602
3688
  if (!approval.ok) return json({ error: "membership approval reconciliation pending" }, 503);
3603
3689
  }
3604
3690
  }
3605
- const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3691
+ const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3606
3692
  if (stored === "collision") {
3607
- await reportStripeCollision(db, ctx, eventId, event);
3693
+ await reportStripeCollision(db, ctx, eventId, event2);
3608
3694
  return json({ ok: true, matched: false, collision: true });
3609
3695
  }
3610
3696
  return json({ ok: true });
@@ -3762,8 +3848,8 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3762
3848
  if (!String(row2.disposition).startsWith("quarantine_") && row2.retryReady !== true) return json({ error: "event is already reconciled" }, 409);
3763
3849
  try {
3764
3850
  const provider = await readStripeRecoveryEvent(db, eventId);
3765
- const event = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3766
- if (provider.event.livemode !== (env.ODLA_ENV === "prod") || classifyMemberStripeEvent(stripeEventIdentity(event), target, false) !== "apply") return json({ error: "provider event belongs to another or unknown runtime" }, 409);
3851
+ const event2 = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3852
+ if (provider.event.livemode !== (env.ODLA_ENV === "prod") || classifyMemberStripeEvent(stripeEventIdentity(event2), target, false) !== "apply") return json({ error: "provider event belongs to another or unknown runtime" }, 409);
3767
3853
  const id6 = crypto.randomUUID();
3768
3854
  await db.transact([
3769
3855
  { t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
@@ -3784,7 +3870,7 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3784
3870
  equals: { digest: row2.digest, disposition: row2.disposition, ...row2.reviewId ? { reviewId: row2.reviewId } : {} },
3785
3871
  ...!row2.reviewId ? { absent: ["reviewId"] } : {}
3786
3872
  }] });
3787
- return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event, origin: url.origin }, env, ctx);
3873
+ return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event: event2, origin: url.origin }, env, ctx);
3788
3874
  } catch {
3789
3875
  return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
3790
3876
  }
@@ -4344,7 +4430,7 @@ function stateOf(item, all, now, window, rivals) {
4344
4430
  if (closed) dates.push(mine.until);
4345
4431
  return { state: "superseded", supersededAt: Math.min(...dates) };
4346
4432
  }
4347
- var mechanism = (policy) => policy.percentOffBps === 0 || policy.discountEligibleSkuIds.length === 0 ? "none" : policy.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
4433
+ var mechanism = (policy2) => policy2.percentOffBps === 0 || policy2.discountEligibleSkuIds.length === 0 ? "none" : policy2.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy2.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
4348
4434
  var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
4349
4435
  var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
4350
4436
  function normalize(rows5, collection, parse, invalid2) {
@@ -4456,16 +4542,16 @@ var couponOf = (discount) => {
4456
4542
  async function retermPolicy(db, scope2, id6) {
4457
4543
  const row2 = await membershipLoad(db, "discountPolicyVersion", id6);
4458
4544
  if (!row2) membershipFail("membership_reterm_policy_unknown");
4459
- let policy;
4545
+ let policy2;
4460
4546
  try {
4461
- policy = membershipDiscountPolicyVersion(row2.snapshot);
4547
+ policy2 = membershipDiscountPolicyVersion(row2.snapshot);
4462
4548
  } catch {
4463
4549
  return membershipFail("membership_reterm_policy_ineligible");
4464
4550
  }
4465
- if (policy.id !== id6 || policy.chapterId !== scope2.chapterId || policy.status !== "published" || policy.benefitDuration.kind !== "while_active" || policy.percentOffBps <= 0 || policy.discountEligibleSkuIds.length === 0) membershipFail("membership_reterm_policy_ineligible");
4466
- return policy;
4551
+ if (policy2.id !== id6 || policy2.chapterId !== scope2.chapterId || policy2.status !== "published" || policy2.benefitDuration.kind !== "while_active" || policy2.percentOffBps <= 0 || policy2.discountEligibleSkuIds.length === 0) membershipFail("membership_reterm_policy_ineligible");
4552
+ return policy2;
4467
4553
  }
4468
- async function attachProviderDiscount(sk, scope2, member, policy, couponId, productId) {
4554
+ async function attachProviderDiscount(sk, scope2, member, policy2, couponId, productId) {
4469
4555
  const subId = String(member.providerSubscriptionId), path = `/v1/subscriptions/${encodeURIComponent(subId)}`;
4470
4556
  const read2 = await (0, import_stripe8.stripeRequest)({ secretKey: sk }, "GET", path, { "expand[0]": "discounts" });
4471
4557
  const sub = read2.body, items = sub?.items?.data;
@@ -4481,8 +4567,8 @@ async function attachProviderDiscount(sk, scope2, member, policy, couponId, prod
4481
4567
  const written = await (0, import_stripe8.stripeRequest)({ secretKey: sk }, "POST", path, {
4482
4568
  "discounts[0][coupon]": couponId,
4483
4569
  "expand[0]": "discounts",
4484
- "metadata[discountPolicyVersionId]": policy.id
4485
- }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy.id}` });
4570
+ "metadata[discountPolicyVersionId]": policy2.id
4571
+ }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy2.id}` });
4486
4572
  const after = written.body?.discounts;
4487
4573
  if (!written.ok || written.body?.id !== subId || !Array.isArray(after) || after.length !== 1 || couponOf(after[0]) !== couponId) {
4488
4574
  membershipFail("membership_reterm_provider_unconfirmed");
@@ -4496,8 +4582,8 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4496
4582
  if (typeof input.policyVersionId !== "string" || !SAFE.test(input.policyVersionId)) throw new TypeError("invalid policy version id");
4497
4583
  if (input.membershipIds !== void 0 && (!Array.isArray(input.membershipIds) || input.membershipIds.length === 0 || input.membershipIds.length > limit || input.membershipIds.some((id6) => typeof id6 !== "string" || !SAFE.test(id6)))) throw new TypeError("invalid membership ids");
4498
4584
  const state = await loadMembershipAuthorityState(db, scope2);
4499
- const policy = await retermPolicy(db, scope2, input.policyVersionId);
4500
- const receipt = { policyVersionId: policy.id, retermed: [], unchanged: [], skipped: [], more: false };
4585
+ const policy2 = await retermPolicy(db, scope2, input.policyVersionId);
4586
+ const receipt = { policyVersionId: policy2.id, retermed: [], unchanged: [], skipped: [], more: false };
4501
4587
  const skip = (membershipId, reason) => {
4502
4588
  receipt.skipped.push({ membershipId, reason });
4503
4589
  };
@@ -4518,7 +4604,7 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4518
4604
  }
4519
4605
  for (const member of rows5) {
4520
4606
  const id6 = String(member.id);
4521
- if (member.discountPolicyVersionId === policy.id) {
4607
+ if (member.discountPolicyVersionId === policy2.id) {
4522
4608
  receipt.unchanged.push(id6);
4523
4609
  continue;
4524
4610
  }
@@ -4530,22 +4616,22 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4530
4616
  skip(id6, "not_founding");
4531
4617
  continue;
4532
4618
  }
4533
- if (member.cohort !== policy.cohortLabel) {
4619
+ if (member.cohort !== policy2.cohortLabel) {
4534
4620
  skip(id6, "cohort_mismatch");
4535
4621
  continue;
4536
4622
  }
4537
4623
  const skuId = String(member.baseSkuId);
4538
- if (!policy.discountEligibleSkuIds.includes(skuId)) {
4624
+ if (!policy2.discountEligibleSkuIds.includes(skuId)) {
4539
4625
  skip(id6, "sku_not_discounted");
4540
4626
  continue;
4541
4627
  }
4542
4628
  const base = Number(member.baseAmountCents), current = Number(member.renewalAmountCents);
4543
- const renewal = Math.round(base * (1e4 - policy.percentOffBps) / 1e4);
4629
+ const renewal = Math.round(base * (1e4 - policy2.percentOffBps) / 1e4);
4544
4630
  if (!Number.isSafeInteger(base) || base <= 0 || !Number.isSafeInteger(current) || renewal > current) {
4545
4631
  skip(id6, "would_not_widen");
4546
4632
  continue;
4547
4633
  }
4548
- const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy.id);
4634
+ const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy2.id);
4549
4635
  let providerUpdated;
4550
4636
  try {
4551
4637
  const productId = await verifySubscriptionPrice(stripeSecretKey, {
@@ -4563,10 +4649,10 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4563
4649
  discountProductId: productId,
4564
4650
  billingTerms: {
4565
4651
  offeringVersionId: String(member.offeringVersionId),
4566
- discount: { policyVersionId: policy.id, couponId, percentOffBps: policy.percentOffBps, duration: "forever" }
4652
+ discount: { policyVersionId: policy2.id, couponId, percentOffBps: policy2.percentOffBps, duration: "forever" }
4567
4653
  }
4568
4654
  }, false);
4569
- providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy, couponId, productId);
4655
+ providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy2, couponId, productId);
4570
4656
  } catch (error) {
4571
4657
  const code = error.code;
4572
4658
  if (code === "provider_mismatch" || code === "stripe_contract_mismatch") {
@@ -4577,17 +4663,17 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4577
4663
  }
4578
4664
  try {
4579
4665
  await db.transact([membershipUpdate("networkMembership", id6, {
4580
- discountPolicyVersionId: policy.id,
4581
- discountSnapshot: policy,
4666
+ discountPolicyVersionId: policy2.id,
4667
+ discountSnapshot: policy2,
4582
4668
  renewalAmountCents: renewal,
4583
4669
  revision: Number(member.revision) + 1,
4584
4670
  updatedAt: now
4585
4671
  })], {
4586
- mutationId: `membership-reterm:${id6}:${policy.id}:${member.revision}`,
4672
+ mutationId: `membership-reterm:${id6}:${policy2.id}:${member.revision}`,
4587
4673
  guards: [
4588
4674
  { ns: "networkMembership", id: id6, exists: true, equals: { revision: member.revision, status: member.status } },
4589
4675
  { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } },
4590
- { ns: "discountPolicyVersion", id: policy.id, exists: true }
4676
+ { ns: "discountPolicyVersion", id: policy2.id, exists: true }
4591
4677
  ]
4592
4678
  });
4593
4679
  } catch (error) {
@@ -4833,18 +4919,18 @@ function memberApplication(app, meeting, defaultTimezone) {
4833
4919
 
4834
4920
  // src/member-session-application.ts
4835
4921
  var local = (row2, chapterId, runtime) => row2.groupId === chapterId && (runtime ? row2.stripeRuntime === runtime : row2.stripeRuntime === void 0 || row2.stripeRuntime === "default");
4836
- async function memberSessionApplication(db, chapterId, runtime, identity) {
4922
+ async function memberSessionApplication(db, chapterId, runtime, identity2) {
4837
4923
  const lookup = async (where) => {
4838
4924
  const rows5 = (await db.query({ applications: { $: { where: { groupId: chapterId, ...where }, limit: 100 } } })).applications;
4839
4925
  if (!Array.isArray(rows5) || rows5.length >= 100) throw new Error("member identity lookup incomplete");
4840
4926
  return rows5.filter((row2) => local(row2, chapterId, runtime));
4841
4927
  };
4842
- let apps = await lookup({ clerkUserId: identity.userId });
4928
+ let apps = await lookup({ clerkUserId: identity2.userId });
4843
4929
  let emailFallback = false;
4844
- if (!apps.length && identity.email) {
4845
- const normalized = normalizeChapterEmail(identity.email);
4930
+ if (!apps.length && identity2.email) {
4931
+ const normalized = normalizeChapterEmail(identity2.email);
4846
4932
  const normalizedRows = await lookup({ emailNormalized: normalized });
4847
- const legacyRows = await lookup({ email: identity.email });
4933
+ const legacyRows = await lookup({ email: identity2.email });
4848
4934
  apps = [...new Map([...normalizedRows, ...legacyRows].map((row2) => [row2.id, row2])).values()];
4849
4935
  if (apps.length !== 1 || apps[0]?.clerkUserId !== void 0 || apps[0]?.clerkAccountPending === true || ["declined", "canceled", "refunded"].includes(String(apps[0]?.status))) return null;
4850
4936
  emailFallback = true;
@@ -4854,8 +4940,8 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4854
4940
  if (emailFallback) {
4855
4941
  const keys3 = ["groupId", "stripeRuntime", "email", "emailNormalized", "status", "clerkAccountPending"];
4856
4942
  try {
4857
- await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity.userId } }], {
4858
- mutationId: `member-account-link:${app.id}:${identity.userId}`,
4943
+ await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity2.userId } }], {
4944
+ mutationId: `member-account-link:${app.id}:${identity2.userId}`,
4859
4945
  guards: [{
4860
4946
  ns: "applications",
4861
4947
  id: String(app.id),
@@ -4868,7 +4954,7 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4868
4954
  if (error.code !== "transact_guard_failed") throw error;
4869
4955
  }
4870
4956
  const saved = (await db.query({ applications: { $: { where: { id: app.id }, limit: 1 } } })).applications?.[0];
4871
- if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4957
+ if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity2.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4872
4958
  app = saved;
4873
4959
  }
4874
4960
  const meeting = (await db.query({ meetings: { $: {
@@ -5000,7 +5086,7 @@ function belongsToSignup(account, applicationId) {
5000
5086
  }
5001
5087
 
5002
5088
  // src/member.ts
5003
- var import_crm5 = require("@odla-ai/crm");
5089
+ var import_crm8 = require("@odla-ai/crm");
5004
5090
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
5005
5091
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
5006
5092
  var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
@@ -5034,7 +5120,7 @@ function resolveApplication(a) {
5034
5120
  if (typeof expression !== "string" || !expression.trim()) {
5035
5121
  throw new Error(`defineChapter.application.conditions.${field}.${key3}: must be a non-empty CEL condition`);
5036
5122
  }
5037
- (0, import_crm5.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
5123
+ (0, import_crm8.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
5038
5124
  }
5039
5125
  }
5040
5126
  return {
@@ -5077,7 +5163,7 @@ function applicantProfile(chapter, fields) {
5077
5163
  }
5078
5164
  async function submitApplication(db, chapter, fields, opts) {
5079
5165
  const app = chapter.application;
5080
- const fieldStates = (0, import_crm5.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
5166
+ const fieldStates = (0, import_crm8.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
5081
5167
  for (const [f, state] of Object.entries(fieldStates)) {
5082
5168
  if (!state.required) continue;
5083
5169
  const v = fields[f];
@@ -5207,7 +5293,7 @@ async function rejectUnprovisionedSignup(db, applicationId, status2) {
5207
5293
  }
5208
5294
 
5209
5295
  // src/worker-routes.ts
5210
- var import_crm6 = require("@odla-ai/crm");
5296
+ var import_crm9 = require("@odla-ai/crm");
5211
5297
 
5212
5298
  // src/discussion-reference-security.ts
5213
5299
  async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
@@ -5808,7 +5894,7 @@ function foundingOf(offer) {
5808
5894
  return offer?.contract?.founding === true && offer.contract.discountPolicy ? { cohortLabel: offer.contract.discountPolicy.cohortLabel } : void 0;
5809
5895
  }
5810
5896
  async function createPaymentQuote(input) {
5811
- const { group, tier, policy } = input;
5897
+ const { group, tier, policy: policy2 } = input;
5812
5898
  const terms2 = input.billingTerms ? subscriptionBillingTerms(input.billingTerms) : void 0;
5813
5899
  const compareAt = input.presentation?.compareAtPriceCents;
5814
5900
  const presentation = Number.isSafeInteger(compareAt) && Number(compareAt) > tier.priceCents ? { compareAtPriceCents: Number(compareAt) } : void 0;
@@ -5831,9 +5917,9 @@ async function createPaymentQuote(input) {
5831
5917
  dueTodayCents: lines.dueTodayCents
5832
5918
  } : lines,
5833
5919
  ...presentation ? { presentation } : {},
5834
- refundPolicyText: policy?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5920
+ refundPolicyText: policy2?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5835
5921
  merchantDisclosureText: String(group.merchantDisclosureText ?? ""),
5836
- trustCopy: policy ? policy.trustCopy ?? "" : String(group.trustCopy ?? ""),
5922
+ trustCopy: policy2 ? policy2.trustCopy ?? "" : String(group.trustCopy ?? ""),
5837
5923
  ...terms2 ? { commercial: {
5838
5924
  offeringVersionId: terms2.offeringVersionId,
5839
5925
  ...terms2.discount ? {
@@ -5905,13 +5991,13 @@ async function prepareMembershipQuote(db, input, issue) {
5905
5991
  if (error.code !== "membership_quote_expired") throw error;
5906
5992
  }
5907
5993
  }
5908
- const identity = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5994
+ const identity2 = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5909
5995
  const guards = [{
5910
5996
  ns: "applications",
5911
5997
  id: id6,
5912
5998
  exists: true,
5913
- equals: Object.fromEntries(identity.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
5914
- absent: identity.filter((key3) => app[key3] === void 0)
5999
+ equals: Object.fromEntries(identity2.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
6000
+ absent: identity2.filter((key3) => app[key3] === void 0)
5915
6001
  }, { ns: "subscriptionCheckoutIntents", id: id6, exists: false }];
5916
6002
  if (!row2 || row2.status === "ready" || row2.status === "restart") {
5917
6003
  const revision = Number(row2?.revision ?? 0) + 1;
@@ -6047,11 +6133,11 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
6047
6133
  const tier = findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null);
6048
6134
  if (!tier || tierIsFree(tier) || !tier.stripePriceId) throw Object.assign(new Error("tier unavailable"), { code: "tier_unresolved" });
6049
6135
  const control3 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
6050
- const policy = control3?.document.policy?.tierPolicies?.[tier.id];
6136
+ const policy2 = control3?.document.policy?.tierPolicies?.[tier.id];
6051
6137
  const presentation = tierPresentationFor(control3?.document, tier.id);
6052
6138
  if (ctx.membershipAuthority) return prepareMembershipQuote(
6053
6139
  db,
6054
- { ...target, app, group, tier, policy, presentation, now: Date.now() },
6140
+ { ...target, app, group, tier, policy: policy2, presentation, now: Date.now() },
6055
6141
  (input) => ctx.membershipAuthority.issueOffer(input, env)
6056
6142
  );
6057
6143
  const quote = await createPaymentQuote({
@@ -6062,7 +6148,7 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
6062
6148
  runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
6063
6149
  group,
6064
6150
  tier,
6065
- policy,
6151
+ policy: policy2,
6066
6152
  presentation
6067
6153
  });
6068
6154
  return { tier, quote, billingTerms: void 0 };
@@ -6705,7 +6791,7 @@ var handleCrm = async (req, url, env, ctx) => {
6705
6791
  const crmBase = ctx.crmBase;
6706
6792
  if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
6707
6793
  const db = ctx.makeDb(env);
6708
- const routes = (0, import_crm6.createCrmRoutes)({
6794
+ const routes = (0, import_crm9.createCrmRoutes)({
6709
6795
  crm: ctx.chapter.crm,
6710
6796
  db,
6711
6797
  authorize: async (r) => {
@@ -7209,15 +7295,15 @@ function policyOf(value, errors, tiers) {
7209
7295
  if (!overrides || Object.keys(overrides).length > 20) errors.push("document.policy.tierPolicies must be a bounded tier map");
7210
7296
  else for (const [id6, raw2] of Object.entries(overrides)) {
7211
7297
  const path = `document.policy.tierPolicies.${id6}`;
7212
- const policy = recordOf(raw2);
7213
- if (!tiers.some((tier) => tier.id === id6) || !policy) {
7298
+ const policy2 = recordOf(raw2);
7299
+ if (!tiers.some((tier) => tier.id === id6) || !policy2) {
7214
7300
  errors.push(`${path} must name a declared tier and policy`);
7215
7301
  continue;
7216
7302
  }
7217
- unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7303
+ unknownKeys(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7218
7304
  tierPolicies[id6] = {
7219
- refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
7220
- ...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
7305
+ refundPolicyText: text4(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
7306
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text4(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
7221
7307
  };
7222
7308
  }
7223
7309
  }
@@ -7272,7 +7358,7 @@ function validateSignupControlDocument(input) {
7272
7358
  errors.push("document.membershipPage.tiers must follow the available tier display order");
7273
7359
  }
7274
7360
  const application = applicationOf(root.application, errors);
7275
- const policy = policyOf(root.policy, errors, tiers);
7361
+ const policy2 = policyOf(root.policy, errors, tiers);
7276
7362
  let scheduling;
7277
7363
  if (root.scheduling !== void 0) {
7278
7364
  const checked = validateScheduling(root.scheduling);
@@ -7292,7 +7378,7 @@ function validateSignupControlDocument(input) {
7292
7378
  tiers,
7293
7379
  membershipPage: page.value,
7294
7380
  ...application ? { application } : {},
7295
- ...policy ? { policy } : {},
7381
+ ...policy2 ? { policy: policy2 } : {},
7296
7382
  ...scheduling ? { scheduling } : {},
7297
7383
  ...theme ? { theme } : {},
7298
7384
  ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
@@ -8109,7 +8195,7 @@ async function networkWriteAllowed(req, db, user, ctx, sensitiveRead = false) {
8109
8195
 
8110
8196
  // src/network-person-reference.ts
8111
8197
  var import_db6 = require("@odla-ai/db");
8112
- var import_crm8 = require("@odla-ai/crm");
8198
+ var import_crm11 = require("@odla-ai/crm");
8113
8199
 
8114
8200
  // src/worker-network-fetch.ts
8115
8201
  var NetworkTargetTransportError = class extends Error {
@@ -8145,7 +8231,7 @@ function fetchNetworkTarget(env, target, destination, init) {
8145
8231
  }
8146
8232
 
8147
8233
  // src/worker-network-admission-state.ts
8148
- var import_crm7 = require("@odla-ai/crm");
8234
+ var import_crm10 = require("@odla-ai/crm");
8149
8235
  var MAX_ASSIGNMENTS = 2e3;
8150
8236
  var admissionRows = (value) => Array.isArray(value) ? value : [];
8151
8237
  function assignmentId(targetId, personId) {
@@ -8172,7 +8258,7 @@ function personAdmissionEmail(ctx, record4) {
8172
8258
  return isValidEmail(normalized) ? normalized : null;
8173
8259
  }
8174
8260
  async function grantInput(db, ctx, assignment, resolveEmail) {
8175
- const person = resolveEmail ? null : await (0, import_crm7.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
8261
+ const person = resolveEmail ? null : await (0, import_crm10.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
8176
8262
  const email = resolveEmail ? await resolveEmail(assignment.personId) : person ? personAdmissionEmail(ctx, person) : null;
8177
8263
  if (!email) throw new Error(`person ${assignment.personId} has no valid authoritative email`);
8178
8264
  return {
@@ -8378,7 +8464,7 @@ function chapterPersonReference(targetId, recordId) {
8378
8464
  }
8379
8465
  async function resolveAdmissionPersonEmail(db, env, ctx, target, personId) {
8380
8466
  if (!personId.startsWith("chapter-person:")) {
8381
- const person2 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, personId);
8467
+ const person2 = await (0, import_crm11.getRecord)({ crm: ctx.chapter.crm, db }, personId);
8382
8468
  return person2 ? personAdmissionEmail(ctx, person2) : null;
8383
8469
  }
8384
8470
  const prefix = `chapter-person:${target.id}:`;
@@ -9036,8 +9122,8 @@ function createStripeProvider(config) {
9036
9122
  if (!webhookSecret || !await (0, import_stripe11.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
9037
9123
  return { ok: false, reason: "bad_signature" };
9038
9124
  }
9039
- const event = JSON.parse(rawBody);
9040
- return { ok: true, eventId: event.id, event: normalizeWebhookEvent(event, { requireProviderPayment: config.requireProviderPayment }) };
9125
+ const event2 = JSON.parse(rawBody);
9126
+ return { ok: true, eventId: event2.id, event: normalizeWebhookEvent(event2, { requireProviderPayment: config.requireProviderPayment }) };
9041
9127
  },
9042
9128
  async refund(input) {
9043
9129
  return refundSubscriptionPayment(requireSecret(secretKey), input);
@@ -9151,7 +9237,7 @@ async function startGiftIntent(db, input) {
9151
9237
  throw new Error("gift purchase intent does not match this checkout");
9152
9238
  }
9153
9239
  if (stored.status === "paid") throw new Error("gift purchase is already paid");
9154
- const checkout = await startGiftProviderCheckout(
9240
+ const checkout2 = await startGiftProviderCheckout(
9155
9241
  input.stripeSecret,
9156
9242
  stored,
9157
9243
  input.purchaserEmail,
@@ -9161,15 +9247,15 @@ async function startGiftIntent(db, input) {
9161
9247
  );
9162
9248
  await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
9163
9249
  status: "checkout_ready",
9164
- providerObjectId: checkout.providerObjectId,
9165
- ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
9250
+ providerObjectId: checkout2.providerObjectId,
9251
+ ...checkout2.customerId ? { providerCustomerId: checkout2.customerId } : {},
9166
9252
  checkoutStartedAt: input.now,
9167
9253
  updatedAt: input.now
9168
- } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
9254
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout2.providerObjectId}` });
9169
9255
  return {
9170
9256
  gift: true,
9171
9257
  intentId: input.id,
9172
- clientSecret: checkout.clientSecret,
9258
+ clientSecret: checkout2.clientSecret,
9173
9259
  publishableKey: input.publishableKey,
9174
9260
  lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
9175
9261
  ...giftCode ? { giftCode } : {}
@@ -9427,15 +9513,15 @@ async function startSubscription(req, env, ctx) {
9427
9513
  return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
9428
9514
  }
9429
9515
  }
9430
- let checkout;
9516
+ let checkout2;
9431
9517
  try {
9432
- checkout = await checkoutQuoteForApplication(db, app, group, env, ctx);
9518
+ checkout2 = await checkoutQuoteForApplication(db, app, group, env, ctx);
9433
9519
  } catch (error) {
9434
9520
  const expired = error.code === "membership_quote_expired";
9435
9521
  return json({ error: "payment quote unavailable", ...expired ? { code: "payment_quote_changed" } : {} }, expired ? 409 : 503);
9436
9522
  }
9437
- const { tier, quote, billingTerms } = checkout;
9438
- const membershipOffer = "membershipOffer" in checkout ? checkout.membershipOffer : void 0;
9523
+ const { tier, quote, billingTerms } = checkout2;
9524
+ const membershipOffer = "membershipOffer" in checkout2 ? checkout2.membershipOffer : void 0;
9439
9525
  if (ctx.membershipAuthority && !membershipOffer) return json({ error: "checkout requires reconciliation", code: "checkout_recovery_required" }, 409);
9440
9526
  if (!tier) return json({ error: "tier not found", code: "tier_unresolved" }, 400);
9441
9527
  if (tierIsFree(tier)) return json({ error: "tier is free", code: "tier_not_payable" }, 409);
@@ -9857,13 +9943,6 @@ var handleNamedSeat = async (req, url, env, ctx) => {
9857
9943
  }
9858
9944
  };
9859
9945
 
9860
- // src/named-seat-accepted-template.ts
9861
- var SEAT_ACCEPTED_TEMPLATE = {
9862
- enabled: true,
9863
- subject: "{{recipientName}} accepted your {{chapterName}} seat",
9864
- text: "Hi {{firstName}},\n\n{{recipientName}} has accepted the {{tierName}} seat you purchased at {{chapterName}}. They will complete their own application and any membership requirements from here; nothing more is needed from you.\n\nWarmly,\n{{chapterName}}"
9865
- };
9866
-
9867
9946
  // src/named-seat-accepted-notice.ts
9868
9947
  var text5 = (value) => typeof value === "string" ? value : "";
9869
9948
  async function notifyNamedSeatAccepted(db, env, ctx, input) {
@@ -9874,7 +9953,6 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9874
9953
  const group = await membershipLoad(db, "groups", ctx.chapter.id);
9875
9954
  if (!group) return { sent: false, reason: "group-missing" };
9876
9955
  const config = emailGroupFrom(group);
9877
- config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
9878
9956
  const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
9879
9957
  return sendTemplated({
9880
9958
  db,
@@ -9890,6 +9968,7 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9890
9968
  to: text5(purchaser.email),
9891
9969
  dedupeKey: `named-seat-accepted:${input.seatId}`,
9892
9970
  applicationId: input.primaryApplicationId,
9971
+ tierId: input.skuId,
9893
9972
  vars: {
9894
9973
  firstName: text5(purchaser.firstName),
9895
9974
  purchaserName,
@@ -9906,7 +9985,7 @@ async function tierName(db, group, skuId) {
9906
9985
  if (tier) return tier.name;
9907
9986
  } catch {
9908
9987
  }
9909
- return skuId;
9988
+ return "";
9910
9989
  }
9911
9990
 
9912
9991
  // src/worker-routes-named-seat-recipient.ts
@@ -10103,10 +10182,10 @@ var handleEmailDeliveryResolution = async (req, url, env, ctx) => {
10103
10182
  };
10104
10183
 
10105
10184
  // src/worker-routes-people-profile.ts
10106
- var import_crm10 = require("@odla-ai/crm");
10185
+ var import_crm13 = require("@odla-ai/crm");
10107
10186
 
10108
10187
  // src/people-profile.ts
10109
- var import_crm9 = require("@odla-ai/crm");
10188
+ var import_crm12 = require("@odla-ai/crm");
10110
10189
  var sameValue = (a, b) => (a == null || a === "") && (b == null || b === "") || JSON.stringify(a) === JSON.stringify(b);
10111
10190
  function profileReadOnlyFields(record4, ctx) {
10112
10191
  return [
@@ -10121,17 +10200,17 @@ async function updateChapterPerson(opts) {
10121
10200
  const { db, env, ctx, record: record4 } = opts;
10122
10201
  const input = Object.fromEntries(Object.entries(opts.input).filter(([key3, value]) => !sameValue(value, record4.fields[key3])));
10123
10202
  const forbidden = profileReadOnlyFields(record4, ctx).filter((key3) => key3 in input);
10124
- if (forbidden.length) throw new import_crm9.CrmInputError(
10203
+ if (forbidden.length) throw new import_crm12.CrmInputError(
10125
10204
  "Use the verified identity or application workflow for protected fields",
10126
10205
  Object.fromEntries(forbidden.map((key3) => [key3, "This field is managed by its authoritative workflow"]))
10127
10206
  );
10128
10207
  const appId = record4.fields.applicationId;
10129
10208
  const app = typeof appId === "string" && appId ? (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0] : null;
10130
10209
  if (appId && (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME) || String(app.email).trim().toLowerCase() !== String(record4.primaryEmail).trim().toLowerCase())) {
10131
- throw new import_crm9.CrmInputError("The application identity must be repaired before editing this profile");
10210
+ throw new import_crm12.CrmInputError("The application identity must be repaired before editing this profile");
10132
10211
  }
10133
10212
  const prepared = ctx.chapter.crm.prepare(record4.type, input, record4);
10134
- if (!prepared.ok) throw new import_crm9.CrmInputError("invalid input", prepared.errors);
10213
+ if (!prepared.ok) throw new import_crm12.CrmInputError("invalid input", prepared.errors);
10135
10214
  const appAttrs = {};
10136
10215
  if (app) {
10137
10216
  const mirrored = /* @__PURE__ */ new Set(["firstName", "lastName", "phone", "linkedin", ...ctx.chapter.application.crmFields]);
@@ -10160,7 +10239,7 @@ async function updateChapterPerson(opts) {
10160
10239
  }] });
10161
10240
  }
10162
10241
  };
10163
- return (0, import_crm9.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
10242
+ return (0, import_crm12.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
10164
10243
  id: record4.id,
10165
10244
  input,
10166
10245
  expectedUpdatedAt: opts.expectedRevision ?? record4.updatedAt,
@@ -10204,7 +10283,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
10204
10283
  } catch {
10205
10284
  return json({ error: "invalid record id" }, 422);
10206
10285
  }
10207
- const record4 = await (0, import_crm10.getRecord)({ crm: ctx.chapter.crm, db }, id6);
10286
+ const record4 = await (0, import_crm13.getRecord)({ crm: ctx.chapter.crm, db }, id6);
10208
10287
  if (!record4) return json({ error: "not found" }, 404);
10209
10288
  if (record4.type !== "person") return dedicated ? json({ error: "person required" }, 422) : null;
10210
10289
  if (match?.[2]) {
@@ -10256,7 +10335,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
10256
10335
  });
10257
10336
  return json(result);
10258
10337
  } catch (error) {
10259
- if (error instanceof import_crm10.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
10338
+ if (error instanceof import_crm13.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
10260
10339
  if (error.code === "transact_guard_failed") return json({ error: "profile changed; reload before saving" }, 409);
10261
10340
  return json({ error: "profile update failed" }, 503);
10262
10341
  }
@@ -10413,10 +10492,10 @@ async function repairAccountPeoplePage(deps, after) {
10413
10492
  }
10414
10493
 
10415
10494
  // src/crm-application-repair.ts
10416
- var import_crm11 = require("@odla-ai/crm");
10495
+ var import_crm14 = require("@odla-ai/crm");
10417
10496
  async function repairApplicationLink(db, chapter, recordId, actorId) {
10418
10497
  const deps = { crm: chapter.crm, db };
10419
- const record4 = await (0, import_crm11.getRecord)(deps, recordId);
10498
+ const record4 = await (0, import_crm14.getRecord)(deps, recordId);
10420
10499
  if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
10421
10500
  if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
10422
10501
  const fields = record4.fields;
@@ -10434,7 +10513,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
10434
10513
  }
10435
10514
  const app = [...applications.values()][0];
10436
10515
  if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
10437
- await (0, import_crm11.updateRecord)(deps, {
10516
+ await (0, import_crm14.updateRecord)(deps, {
10438
10517
  id: recordId,
10439
10518
  input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
10440
10519
  expectedUpdatedAt: record4.updatedAt,
@@ -11121,7 +11200,122 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
11121
11200
  return patchApplication(db, env, ctx, app, body);
11122
11201
  };
11123
11202
 
11203
+ // src/email-admin-config.ts
11204
+ var import_crm16 = require("@odla-ai/crm");
11205
+
11206
+ // src/email-readiness.ts
11207
+ var import_crm15 = require("@odla-ai/crm");
11208
+ function validateEmailReadiness(input) {
11209
+ const issues = [];
11210
+ for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
11211
+ issues.push({ code: "event-undeclared", message: `Stored email ${key3} has no declared event. Review its trigger before using it.` });
11212
+ }
11213
+ if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
11214
+ for (const tier of input.tiers ?? []) {
11215
+ if (!(0, import_crm15.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
11216
+ }
11217
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm15.isEmailTierName)(tier.name, tier.id));
11218
+ if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
11219
+ const events = Object.entries(input.events).map(([key3, event2]) => {
11220
+ const template = input.templates[key3];
11221
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm15.validateEmailCopy)(template, { allowedVariables: event2.variables });
11222
+ if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
11223
+ if (template) {
11224
+ const used = [...`${template.subject ?? ""}
11225
+ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
11226
+ for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
11227
+ problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
11228
+ }
11229
+ }
11230
+ if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
11231
+ else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
11232
+ return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
11233
+ });
11234
+ return { checkedAt: input.now ?? Date.now(), events, issues };
11235
+ }
11236
+
11237
+ // src/email-admin-config.ts
11238
+ var str2 = (value) => typeof value === "string" ? value : "";
11239
+ async function emailSettings(db, ctx, env) {
11240
+ const hub = ctx.chapter.mode === "hub";
11241
+ const crmDeps3 = { db, crm: ctx.chapter.crm };
11242
+ const group = hub ? await (0, import_crm16.getConfig)(crmDeps3) : (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
11243
+ const raw2 = group?.emailTemplates;
11244
+ const stored = raw2 && typeof raw2 === "object" && !Array.isArray(raw2) ? raw2 : {};
11245
+ const events = chapterEmailEvents(ctx.chapter);
11246
+ const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
11247
+ subject: str2(stored[key3]?.subject),
11248
+ text: str2(stored[key3]?.text),
11249
+ enabled: stored[key3]?.enabled
11250
+ }]));
11251
+ let tiers = [], tiersUnavailable = false;
11252
+ if (!hub) {
11253
+ try {
11254
+ tiers = await loadTiers(db, group ?? { id: ctx.chapter.id });
11255
+ } catch {
11256
+ tiersUnavailable = true;
11257
+ }
11258
+ }
11259
+ const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
11260
+ const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
11261
+ return { group, events, data: {
11262
+ groupId: ctx.chapter.id,
11263
+ name: ctx.chapter.name,
11264
+ emailTemplates,
11265
+ validation,
11266
+ replyTo: str2(group?.replyTo),
11267
+ fromName: str2(group?.fromName),
11268
+ notificationEmail: str2(group?.notificationEmail),
11269
+ debugEmail: str2(group?.debugEmail),
11270
+ ...groupValues,
11271
+ envName: env.ODLA_ENV,
11272
+ testAvailable: !hub,
11273
+ transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11274
+ fromEmail: env.EMAIL_FROM ?? null
11275
+ } };
11276
+ }
11277
+ async function updateEmailSettings(db, ctx, env, body) {
11278
+ const { group, events } = await emailSettings(db, ctx, env);
11279
+ if (!group && ctx.chapter.mode !== "hub") return json({ error: "Email settings have not been configured for this chapter." }, 409);
11280
+ const templates = body.emailTemplates;
11281
+ if (!templates || typeof templates !== "object" || Array.isArray(templates)) return json({ error: "emailTemplates object required" }, 400);
11282
+ const clean = {};
11283
+ for (const [key3, value] of Object.entries(templates)) {
11284
+ if (!events[key3]) continue;
11285
+ if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
11286
+ const t = value;
11287
+ if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
11288
+ const subject = str2(t.subject).trim(), text8 = str2(t.text);
11289
+ const issues = (0, import_crm16.validateEmailCopy)({ subject, text: text8 }, { allowedVariables: events[key3].variables });
11290
+ if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
11291
+ return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
11292
+ }
11293
+ clean[key3] = { subject, text: text8, enabled: t.enabled };
11294
+ }
11295
+ const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
11296
+ const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
11297
+ if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11298
+ if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11299
+ if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11300
+ const fromName = str2(body.fromName).trim();
11301
+ if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
11302
+ const commitmentText = str2(body.commitmentText), normsText = str2(body.normsText);
11303
+ if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11304
+ if (ctx.chapter.mode === "hub") {
11305
+ const missing = Object.fromEntries(Object.keys(events).filter((key3) => !group?.emailTemplates?.[key3]).map((key3) => [key3, { subject: "", text: "", enabled: false }]));
11306
+ await (0, import_crm16.saveConfig)({ db, crm: ctx.chapter.crm }, { emailTemplates: { ...missing, ...clean }, notificationEmail, replyTo, debugEmail });
11307
+ } else {
11308
+ const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11309
+ await db.transact([
11310
+ { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11311
+ { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11312
+ ]);
11313
+ }
11314
+ return json({ ok: true });
11315
+ }
11316
+
11124
11317
  // src/worker-routes-admin-comms.ts
11318
+ var import_crm17 = require("@odla-ai/crm");
11125
11319
  async function gate5(req, env, ctx) {
11126
11320
  const rawDb = ctx.makeDb(env);
11127
11321
  const u = await ctx.verifyUser(req, env);
@@ -11133,39 +11327,13 @@ async function loadGroup2(db, id6) {
11133
11327
  const { groups } = await db.query({ groups: { $: { where: { id: id6 }, limit: 1 } } });
11134
11328
  return groups?.[0] ?? null;
11135
11329
  }
11136
- var str2 = (v, d = "") => typeof v === "string" ? v : d;
11330
+ var str3 = (v, d = "") => typeof v === "string" ? v : d;
11137
11331
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
11138
11332
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
11139
11333
  const got = await gate5(req, env, ctx);
11140
11334
  if (got instanceof Response) return got;
11141
11335
  const db = got;
11142
- const group = await loadGroup2(db, ctx.chapter.id);
11143
- if (!group) return json({ error: "not found" }, 404);
11144
- if (req.method === "GET") {
11145
- const stored = group.emailTemplates ?? {};
11146
- const emailTemplates = {};
11147
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11148
- const t = stored[key3];
11149
- if (t) emailTemplates[key3] = { subject: str2(t.subject), text: str2(t.text), enabled: t.enabled !== false };
11150
- }
11151
- return json({
11152
- groupId: group.id,
11153
- name: group.name,
11154
- replyTo: str2(group.replyTo),
11155
- fromName: str2(group.fromName),
11156
- notificationEmail: str2(group.notificationEmail),
11157
- debugEmail: str2(group.debugEmail),
11158
- emailTemplates,
11159
- commitmentText: str2(group.commitmentText),
11160
- normsText: str2(group.normsText),
11161
- refundPolicyText: str2(group.refundPolicyText),
11162
- // Read-only delivery wiring — surfaced so "why did this not send?" is
11163
- // answerable without logs.
11164
- envName: env.ODLA_ENV,
11165
- transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11166
- fromEmail: env.EMAIL_FROM ?? null
11167
- });
11168
- }
11336
+ if (req.method === "GET") return json((await emailSettings(db, ctx, env)).data);
11169
11337
  let body;
11170
11338
  try {
11171
11339
  body = await req.json();
@@ -11173,41 +11341,16 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
11173
11341
  return json({ error: "invalid JSON body" }, 400);
11174
11342
  }
11175
11343
  if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "object body required" }, 400);
11176
- const templates = body.emailTemplates;
11177
- if (!templates || typeof templates !== "object") return json({ error: "emailTemplates object required" }, 400);
11178
- const clean = {};
11179
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11180
- const t = templates[key3];
11181
- const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
11182
- const text8 = typeof t?.text === "string" ? t.text : "";
11183
- if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
11184
- if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
11185
- if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
11186
- clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
11187
- }
11188
- const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11189
- const notificationEmail = str2(body.notificationEmail).trim();
11190
- const replyTo = str2(body.replyTo).trim();
11191
- const debugEmail = str2(body.debugEmail).trim();
11192
- if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11193
- if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11194
- if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11195
- const fromName = str2(body.fromName).trim();
11196
- if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
11197
- const commitmentText = str2(body.commitmentText);
11198
- const normsText = str2(body.normsText);
11199
- if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11200
- const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11201
- await db.transact([
11202
- { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11203
- { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11204
- ]);
11205
- return json({ ok: true });
11344
+ return updateEmailSettings(db, ctx, env, body);
11206
11345
  };
11207
11346
  var handleAdminEmailLog = async (req, url, env, ctx) => {
11208
11347
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
11209
11348
  const got = await gate5(req, env, ctx);
11210
11349
  if (got instanceof Response) return got;
11350
+ if (ctx.chapter.mode === "hub") {
11351
+ const rows5 = await (0, import_crm17.listEmailLog)({ db: got, crm: ctx.chapter.crm }, { limit: 50 });
11352
+ return json({ sends: rows5.map((row2) => ({ ...row2, template: row2.templateId })) });
11353
+ }
11211
11354
  const { emailLog } = await got.query({ emailLog: { $: { where: { groupId: ctx.chapter.id }, order: { sentAt: "desc" }, limit: 50 } } });
11212
11355
  const visible = [];
11213
11356
  for (const row2 of emailLog ?? []) {
@@ -11243,21 +11386,22 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
11243
11386
  } catch {
11244
11387
  return json({ error: "invalid JSON body" }, 400);
11245
11388
  }
11246
- const template = str2(body.template);
11247
- if (!EMAIL_TEMPLATE_NAMES.includes(template)) {
11248
- return json({ error: `template must be one of: ${EMAIL_TEMPLATE_NAMES.join(", ")}` }, 400);
11249
- }
11389
+ if (ctx.chapter.mode === "hub") return json({ error: "Send CRM mail from a selected contact." }, 400);
11390
+ const template = str3(body.template);
11391
+ const events = chapterEmailEvents(ctx.chapter);
11392
+ if (!events[template]) return json({ error: "Unknown email event." }, 400);
11250
11393
  const group = await loadGroup2(db, ctx.chapter.id);
11251
11394
  if (!group) return json({ error: "not found" }, 404);
11252
11395
  const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
11253
11396
  group: emailGroupFrom(group),
11254
11397
  template,
11255
- to: str2(group.notificationEmail),
11398
+ to: str3(group.notificationEmail),
11256
11399
  vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
11257
11400
  dedupeKey: `test:${template}:${Date.now()}`,
11258
- force: true
11401
+ force: true,
11402
+ event: events[template]
11259
11403
  });
11260
- return json({ ok: res.sent, reason: res.reason ?? null, to: str2(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11404
+ return json({ ok: res.sent, reason: res.reason ?? null, to: str3(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11261
11405
  };
11262
11406
  var handleAdminComms = async (req, url, env, ctx) => {
11263
11407
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
@@ -11279,13 +11423,13 @@ var handleAdminComms = async (req, url, env, ctx) => {
11279
11423
  });
11280
11424
  const app = data.applications?.[0] ?? null;
11281
11425
  if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
11282
- const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
11283
- const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
11426
+ const group = await loadGroup2(db, str3(app?.groupId, ctx.chapter.id));
11427
+ const vars = app ? { firstName: str3(app.firstName), lastName: str3(app.lastName), email: str3(app.email), phone: str3(app.phone), state: str3(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
11284
11428
  const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
11285
11429
  let mailBody = typeof r.body === "string" ? r.body : null;
11286
11430
  if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
11287
11431
  const channel = r.error ? "email (failed)" : r.redirected ? "email (dev-redirected)" : r.transport === "log-only" ? "email (not delivered)" : "email";
11288
- return { kind: "email", channel, label: String(r.template), subject: str2(r.subject), to: r.to ?? null, body: mailBody, at: r.sentAt, error: r.error ?? null };
11432
+ return { kind: "email", channel, label: String(r.template), subject: str3(r.subject), to: r.to ?? null, body: mailBody, at: r.sentAt, error: r.error ?? null };
11289
11433
  });
11290
11434
  const calendar = (data.meetings ?? []).map((m2) => ({
11291
11435
  kind: "calendar",
@@ -11301,7 +11445,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
11301
11445
  };
11302
11446
 
11303
11447
  // src/worker-routes-network.ts
11304
- var import_crm13 = require("@odla-ai/crm");
11448
+ var import_crm19 = require("@odla-ai/crm");
11305
11449
  var import_db9 = require("@odla-ai/db");
11306
11450
 
11307
11451
  // src/network-edge.ts
@@ -11327,7 +11471,7 @@ function unpairedResponse(unpaired) {
11327
11471
  }
11328
11472
 
11329
11473
  // src/worker-network-push.ts
11330
- var import_crm12 = require("@odla-ai/crm");
11474
+ var import_crm18 = require("@odla-ai/crm");
11331
11475
  var import_db8 = require("@odla-ai/db");
11332
11476
  async function pushNetworkRecord(db, env, ctx, target, record4) {
11333
11477
  const secret = await getVaultSecret(db, target.secretName);
@@ -11339,7 +11483,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11339
11483
  };
11340
11484
  if (!secret) {
11341
11485
  const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
11342
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11486
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11343
11487
  recordId: record4.id,
11344
11488
  targetId: target.id,
11345
11489
  status: "failed",
@@ -11378,7 +11522,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11378
11522
  const responseBody = await res.json().catch(() => ({}));
11379
11523
  if (!res.ok) {
11380
11524
  const error = responseBody.error ?? "follower rejected the record";
11381
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11525
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11382
11526
  recordId: record4.id,
11383
11527
  targetId: target.id,
11384
11528
  status: "failed",
@@ -11393,7 +11537,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11393
11537
  error
11394
11538
  };
11395
11539
  }
11396
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11540
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11397
11541
  recordId: record4.id,
11398
11542
  targetId: target.id,
11399
11543
  status: "delivered",
@@ -11409,7 +11553,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11409
11553
  };
11410
11554
  } catch (err) {
11411
11555
  const error = err instanceof Error ? err.message : "delivery failed";
11412
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11556
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11413
11557
  recordId: record4.id,
11414
11558
  targetId: target.id,
11415
11559
  status: "failed",
@@ -11462,7 +11606,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
11462
11606
  const verified = await (0, import_db9.verifyFederatedRequest)(req, { secret });
11463
11607
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
11464
11608
  const types = await Promise.all(
11465
- Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm13.typeSummary)({ crm: ctx.chapter.crm, db }, type))
11609
+ Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm19.typeSummary)({ crm: ctx.chapter.crm, db }, type))
11466
11610
  );
11467
11611
  const activeConfiguration = ctx.chapter.signupControl ? await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV) : null;
11468
11612
  const snapshot = {
@@ -11609,7 +11753,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
11609
11753
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
11610
11754
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
11611
11755
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
11612
- const record4 = await (0, import_crm13.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
11756
+ const record4 = await (0, import_crm19.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
11613
11757
  if (!record4) return json({ error: "record not found" }, 404);
11614
11758
  const results = await Promise.all(
11615
11759
  targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record4))
@@ -11618,11 +11762,11 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
11618
11762
  };
11619
11763
 
11620
11764
  // src/worker-routes-network-records-follower.ts
11621
- var import_crm17 = require("@odla-ai/crm");
11765
+ var import_crm23 = require("@odla-ai/crm");
11622
11766
  var import_db10 = require("@odla-ai/db");
11623
11767
 
11624
11768
  // src/network-application-action.ts
11625
- var import_crm14 = require("@odla-ai/crm");
11769
+ var import_crm20 = require("@odla-ai/crm");
11626
11770
  var ACTION_META = "chapterNetworkMutation";
11627
11771
  async function networkApplicationAction(opts) {
11628
11772
  const { db, env, ctx, record: record4, input, sourceId, activityId, fingerprint } = opts;
@@ -11711,7 +11855,7 @@ async function networkApplicationAction(opts) {
11711
11855
  const current = (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0];
11712
11856
  if (!current || current.status !== input.stage) return json({ error: "application reconciliation pending", code: "action_pending" }, 503);
11713
11857
  await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) });
11714
- const projected = await (0, import_crm14.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11858
+ const projected = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11715
11859
  if (!projected || projected.stage !== input.stage) return json({ error: "CRM reconciliation pending", code: "action_pending" }, 503);
11716
11860
  const audit = (await db.query({ crm_activity: { $: { where: { id: activityId }, limit: 1 } } })).crm_activity?.[0];
11717
11861
  const priorMeta = audit?.meta;
@@ -11721,7 +11865,7 @@ async function networkApplicationAction(opts) {
11721
11865
  } },
11722
11866
  { t: "update", ns: "crm_activity", id: lockId, attrs: { meta: { state: "complete", activityId } } }
11723
11867
  ], { guards: [{ ns: "crm_activity", id: lockId, exists: true, equals: { meta: { state: "pending", activityId } } }] });
11724
- const updated = await (0, import_crm14.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11868
+ const updated = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11725
11869
  if (!updated) return json({ error: "record not found" }, 404);
11726
11870
  return { record: updated, duplicate: Boolean(receipt) };
11727
11871
  }
@@ -11757,7 +11901,7 @@ async function operationalReaderRecord(record4, reader, db, env, ctx) {
11757
11901
  }
11758
11902
 
11759
11903
  // src/worker-routes-network-records-search.ts
11760
- var import_crm15 = require("@odla-ai/crm");
11904
+ var import_crm21 = require("@odla-ai/crm");
11761
11905
  var SEARCH_BATCH_SIZE = 1e3;
11762
11906
  var MAX_READER_SEARCH_POPULATION = 1e4;
11763
11907
  var ReaderSearchPopulationError = class extends Error {
@@ -11798,19 +11942,19 @@ async function listVisibleSearchPopulation(db, where, window, count) {
11798
11942
  (_, index2) => index2 * SEARCH_BATCH_SIZE
11799
11943
  );
11800
11944
  const pages = await Promise.all(offsets.map((offset) => db.query({
11801
- [import_crm15.CRM_NS.record]: { $: {
11945
+ [import_crm21.CRM_NS.record]: { $: {
11802
11946
  where,
11803
11947
  order: { [window.sort.field]: window.sort.dir },
11804
11948
  limit: Math.min(SEARCH_BATCH_SIZE, count - offset),
11805
11949
  offset
11806
11950
  } }
11807
11951
  })));
11808
- return pages.flatMap((page) => (page[import_crm15.CRM_NS.record] ?? []).map(import_crm15.rowToRecord));
11952
+ return pages.flatMap((page) => (page[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord));
11809
11953
  }
11810
11954
  async function listReaderRecords(db, type, allowed2, emailField, window) {
11811
11955
  const where = baseWhere(type, window.stage);
11812
11956
  const crmDb = db;
11813
- const aggregate = await crmDb.aggregate(import_crm15.CRM_NS.record, { count: true }, { where });
11957
+ const aggregate = await crmDb.aggregate(import_crm21.CRM_NS.record, { count: true }, { where });
11814
11958
  const candidateCount = aggregate.count ?? 0;
11815
11959
  if (window.search) {
11816
11960
  const population = await listVisibleSearchPopulation(crmDb, where, window, candidateCount);
@@ -11822,7 +11966,7 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
11822
11966
  };
11823
11967
  }
11824
11968
  const result = await crmDb.query({
11825
- [import_crm15.CRM_NS.record]: { $: {
11969
+ [import_crm21.CRM_NS.record]: { $: {
11826
11970
  where,
11827
11971
  order: { [window.sort.field]: window.sort.dir },
11828
11972
  limit: window.limit,
@@ -11830,19 +11974,19 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
11830
11974
  } }
11831
11975
  });
11832
11976
  return {
11833
- records: (result[import_crm15.CRM_NS.record] ?? []).map(import_crm15.rowToRecord),
11977
+ records: (result[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord),
11834
11978
  total: candidateCount
11835
11979
  };
11836
11980
  }
11837
11981
 
11838
11982
  // src/network-mutation-receipt.ts
11839
- var import_crm16 = require("@odla-ai/crm");
11983
+ var import_crm22 = require("@odla-ai/crm");
11840
11984
  var NETWORK_MUTATION_META = "chapterNetworkMutation";
11841
11985
  async function priorMutation(db, id6) {
11842
11986
  const result = await db.query({
11843
- [import_crm16.CRM_NS.activity]: { $: { where: { id: id6 }, limit: 1 } }
11987
+ [import_crm22.CRM_NS.activity]: { $: { where: { id: id6 }, limit: 1 } }
11844
11988
  });
11845
- const row2 = (result[import_crm16.CRM_NS.activity] ?? [])[0];
11989
+ const row2 = (result[import_crm22.CRM_NS.activity] ?? [])[0];
11846
11990
  const meta = row2?.meta;
11847
11991
  const receipt = meta?.[NETWORK_MUTATION_META];
11848
11992
  return typeof receipt?.fingerprint === "string" ? { fingerprint: receipt.fingerprint, pending: receipt.state === "pending", failed: receipt.state === "failed" } : null;
@@ -11866,7 +12010,7 @@ function mutationError(error) {
11866
12010
  if (error?.code === "transact_guard_failed") {
11867
12011
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
11868
12012
  }
11869
- if (error instanceof import_crm17.CrmInputError) {
12013
+ if (error instanceof import_crm23.CrmInputError) {
11870
12014
  return json({ error: error.message, code: "invalid_transition_or_fields", ...error.fields ? { fields: error.fields } : {} }, 422);
11871
12015
  }
11872
12016
  return json({ error: "follower mutation failed", code: "mutation_failed" }, 500);
@@ -11921,7 +12065,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11921
12065
  }
11922
12066
  if (prior?.failed) return json({ error: "this operation was not applied; reload before choosing a new action", code: "action_failed" }, 409);
11923
12067
  if (prior && !prior.pending) {
11924
- const replayed = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
12068
+ const replayed = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
11925
12069
  if (!replayed || replayed.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
11926
12070
  const result = {
11927
12071
  version: 1,
@@ -11931,7 +12075,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11931
12075
  };
11932
12076
  return json(result);
11933
12077
  }
11934
- const record4 = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
12078
+ const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
11935
12079
  if (!record4 || record4.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
11936
12080
  if (!prior?.pending && record4.updatedAt !== input.expectedRevision) {
11937
12081
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
@@ -11994,7 +12138,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11994
12138
  record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
11995
12139
  });
11996
12140
  }
11997
- const updated = hasFields ? await (0, import_crm17.updateRecord)(deps, {
12141
+ const updated = hasFields ? await (0, import_crm23.updateRecord)(deps, {
11998
12142
  id: record4.id,
11999
12143
  input: fields,
12000
12144
  mutationId: activityId,
@@ -12005,7 +12149,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
12005
12149
  authorId: `network:${verified.sender}`,
12006
12150
  meta: auditMeta
12007
12151
  }
12008
- }) : await (0, import_crm17.setStage)(deps, {
12152
+ }) : await (0, import_crm23.setStage)(deps, {
12009
12153
  id: record4.id,
12010
12154
  to: input.stage,
12011
12155
  authorId: `network:${verified.sender}`,
@@ -12032,7 +12176,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
12032
12176
  const recordId = url.searchParams.get("recordId");
12033
12177
  if (recordId !== null) {
12034
12178
  if (!RECORD_ID.test(recordId)) return json({ error: "invalid record id" }, 400);
12035
- const record4 = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12179
+ const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12036
12180
  if (!record4 || record4.type !== type || record4.archived) return json({ error: "record not found" }, 404);
12037
12181
  return json({
12038
12182
  version: 1,
@@ -12282,13 +12426,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12282
12426
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
12283
12427
  const got = await gate8(req, env, ctx);
12284
12428
  if ("response" in got) return got.response;
12285
- const identity = noteIdentity(url, ctx);
12286
- if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12429
+ const identity2 = noteIdentity(url, ctx);
12430
+ if (!identity2) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12287
12431
  if (req.method === "GET") {
12288
12432
  const result = await got.db.query({
12289
12433
  networkNotes: {
12290
12434
  $: {
12291
- where: { recordKey: identity.recordKey },
12435
+ where: { recordKey: identity2.recordKey },
12292
12436
  order: { createdAt: "desc" },
12293
12437
  limit: 100
12294
12438
  }
@@ -12309,9 +12453,9 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12309
12453
  const id6 = crypto.randomUUID();
12310
12454
  const note = {
12311
12455
  id: id6,
12312
- targetId: identity.targetId,
12313
- recordType: identity.recordType,
12314
- recordId: identity.recordId,
12456
+ targetId: identity2.targetId,
12457
+ recordType: identity2.recordType,
12458
+ recordId: identity2.recordId,
12315
12459
  body,
12316
12460
  authorId: got.user.userId,
12317
12461
  ...got.user.email ? { authorEmail: got.user.email } : {},
@@ -12321,13 +12465,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12321
12465
  t: "update",
12322
12466
  ns: "networkNotes",
12323
12467
  id: id6,
12324
- attrs: { ...note, recordKey: identity.recordKey }
12468
+ attrs: { ...note, recordKey: identity2.recordKey }
12325
12469
  }]);
12326
12470
  return json({ note }, 201);
12327
12471
  };
12328
12472
 
12329
12473
  // src/worker-routes-network-shared-notes.ts
12330
- var import_crm18 = require("@odla-ai/crm");
12474
+ var import_crm24 = require("@odla-ai/crm");
12331
12475
  var import_db12 = require("@odla-ai/db");
12332
12476
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
12333
12477
  var RECORD_ID3 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
@@ -12358,7 +12502,7 @@ function sharedNote(activity, sourceId) {
12358
12502
  }
12359
12503
  async function listSharedNotes(db, sourceId, recordId) {
12360
12504
  const result = await db.query({
12361
- [import_crm18.CRM_NS.activity]: {
12505
+ [import_crm24.CRM_NS.activity]: {
12362
12506
  $: {
12363
12507
  where: {
12364
12508
  and: [
@@ -12371,7 +12515,7 @@ async function listSharedNotes(db, sourceId, recordId) {
12371
12515
  }
12372
12516
  }
12373
12517
  });
12374
- return (result[import_crm18.CRM_NS.activity] ?? []).flatMap((activity) => {
12518
+ return (result[import_crm24.CRM_NS.activity] ?? []).flatMap((activity) => {
12375
12519
  const note = sharedNote(activity, sourceId);
12376
12520
  return note ? [note] : [];
12377
12521
  });
@@ -12399,7 +12543,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
12399
12543
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
12400
12544
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
12401
12545
  }
12402
- const record4 = await (0, import_crm18.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12546
+ const record4 = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12403
12547
  if (!record4 || record4.type !== recordType) return json({ error: "record not found" }, 404);
12404
12548
  if (req.method === "GET") {
12405
12549
  return json({
@@ -12421,7 +12565,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
12421
12565
  }
12422
12566
  const createdAt = Date.now();
12423
12567
  const id6 = `network:${verified.sender}:${mutationId}`;
12424
- const result = await (0, import_crm18.addActivity)(
12568
+ const result = await (0, import_crm24.addActivity)(
12425
12569
  {
12426
12570
  crm: ctx.chapter.crm,
12427
12571
  db,
@@ -12583,8 +12727,8 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
12583
12727
  }
12584
12728
  }
12585
12729
  const seen = /* @__PURE__ */ new Set();
12586
- for (const identity of identities) {
12587
- const key3 = `${identity.kind}:${identity.value}`;
12730
+ for (const identity2 of identities) {
12731
+ const key3 = `${identity2.kind}:${identity2.value}`;
12588
12732
  if (seen.has(key3)) errors.push("duplicate identity in import");
12589
12733
  seen.add(key3);
12590
12734
  }
@@ -12640,8 +12784,8 @@ async function networkChapterLinkId(personId, chapterId, chapterPersonId) {
12640
12784
  async function networkChapterPersonSourceKey(chapterId, chapterPersonId) {
12641
12785
  return `chapter_source_${await sha256(`${chapterId}\0${chapterPersonId}`)}`;
12642
12786
  }
12643
- function projectNetworkContactPolicy(policy, links) {
12644
- return links.map((link) => ({ ...link, contactPolicy: { ...policy } }));
12787
+ function projectNetworkContactPolicy(policy2, links) {
12788
+ return links.map((link) => ({ ...link, contactPolicy: { ...policy2 } }));
12645
12789
  }
12646
12790
  function canonicalNetworkPersonId(personId, persons) {
12647
12791
  const byId = new Map(persons.map((person) => [person.id, person]));
@@ -12947,11 +13091,11 @@ async function suppressNetworkPerson(db, input, mutationId, actor, now = Date.no
12947
13091
  if (!person || person.state !== "active" || canonicalNetworkPersonId(person.id, state.persons) !== person.id || person.revision !== input.expectedRevision) {
12948
13092
  throw new NetworkPersonRegistryError("stale_suppression", "active canonical Person changed; reload before updating suppression");
12949
13093
  }
12950
- const policy = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
12951
- const next = { ...person, contactPolicy: policy, revision: person.revision + 1, updatedAt: now };
13094
+ const policy2 = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
13095
+ const next = { ...person, contactPolicy: policy2, revision: person.revision + 1, updatedAt: now };
12952
13096
  await db.transact([
12953
13097
  { t: "update", ns: "networkPersons", id: person.id, attrs: { ...next } },
12954
- networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy.marketingSuppressed, discretionarySuppressed: policy.discretionarySuppressed }, { personId: person.id })
13098
+ networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy2.marketingSuppressed, discretionarySuppressed: policy2.discretionarySuppressed }, { personId: person.id })
12955
13099
  ], { mutationId, guards: [{ ns: "networkPersons", id: person.id, exists: true, equals: { revision: person.revision } }] });
12956
13100
  return next;
12957
13101
  }
@@ -13076,13 +13220,13 @@ async function derivedMutationId(batchMutationId, part) {
13076
13220
  return `bulk_${await networkPersonBulkSha256(`${batchMutationId}\0${part}`)}`;
13077
13221
  }
13078
13222
  function matchingEvent(events, mutationId, kind) {
13079
- return events.find((event) => event.mutationId === mutationId && event.kind === kind);
13223
+ return events.find((event2) => event2.mutationId === mutationId && event2.kind === kind);
13080
13224
  }
13081
- function eventString(event, key3) {
13082
- return typeof event.detail[key3] === "string" ? String(event.detail[key3]) : "";
13225
+ function eventString(event2, key3) {
13226
+ return typeof event2.detail[key3] === "string" ? String(event2.detail[key3]) : "";
13083
13227
  }
13084
- function eventCounts(event) {
13085
- const value = event.detail.counts;
13228
+ function eventCounts(event2) {
13229
+ const value = event2.detail.counts;
13086
13230
  return value && typeof value === "object" && !Array.isArray(value) ? { ...networkPersonBulkEmptyCounts(), ...value } : networkPersonBulkEmptyCounts();
13087
13231
  }
13088
13232
  async function applyNetworkPersonBulkImport(db, input, batchMutationId, actor, allowedChapterIds, now = Date.now()) {
@@ -13209,11 +13353,11 @@ var redactedNetworkPersonLink = async (link) => ({
13209
13353
  state: link.state,
13210
13354
  createdAt: link.createdAt
13211
13355
  });
13212
- var safeNetworkContactPolicy = (policy) => ({
13213
- marketingSuppressed: policy.marketingSuppressed,
13214
- discretionarySuppressed: policy.discretionarySuppressed,
13215
- hasReason: Boolean(policy.reason),
13216
- updatedAt: policy.updatedAt
13356
+ var safeNetworkContactPolicy = (policy2) => ({
13357
+ marketingSuppressed: policy2.marketingSuppressed,
13358
+ discretionarySuppressed: policy2.discretionarySuppressed,
13359
+ hasReason: Boolean(policy2.reason),
13360
+ updatedAt: policy2.updatedAt
13217
13361
  });
13218
13362
  var safeNetworkPersonAlias = async (alias) => ({
13219
13363
  id: alias.id,
@@ -13268,14 +13412,14 @@ async function projectNetworkPersonRegistryState(state) {
13268
13412
  aliases,
13269
13413
  reviewQueue: aliases.filter((alias) => alias.status === "ambiguous"),
13270
13414
  excluded: aliases.filter((alias) => alias.status === "excluded"),
13271
- recentEvents: state.events.slice(0, 100).map((event) => ({
13272
- id: event.id,
13273
- kind: event.kind,
13274
- personId: event.personId ?? null,
13275
- aliasId: event.aliasId ?? null,
13276
- actorId: event.actorId,
13277
- mutationId: event.mutationId,
13278
- createdAt: event.createdAt
13415
+ recentEvents: state.events.slice(0, 100).map((event2) => ({
13416
+ id: event2.id,
13417
+ kind: event2.kind,
13418
+ personId: event2.personId ?? null,
13419
+ aliasId: event2.aliasId ?? null,
13420
+ actorId: event2.actorId,
13421
+ mutationId: event2.mutationId,
13422
+ createdAt: event2.createdAt
13279
13423
  }))
13280
13424
  };
13281
13425
  }
@@ -13290,7 +13434,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13290
13434
  const target = input.kind === "person" ? state.persons.find((row2) => row2.id === input.id) : state.aliases.find((row2) => row2.id === input.id);
13291
13435
  if (!target) throw new NetworkPersonRegistryError("reveal_not_found", "registry record not found");
13292
13436
  const ids2 = input.kind === "person" ? { personId: input.id } : { aliasId: input.id };
13293
- const event = {
13437
+ const event2 = {
13294
13438
  t: "update",
13295
13439
  ns: "networkPersonEvents",
13296
13440
  id: `network_person_event:${mutationId}`,
@@ -13304,7 +13448,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13304
13448
  createdAt: now
13305
13449
  }
13306
13450
  };
13307
- await db.transact([event], {
13451
+ await db.transact([event2], {
13308
13452
  mutationId,
13309
13453
  guards: [{ ns: "networkPersonEvents", id: `network_person_event:${mutationId}`, exists: false }]
13310
13454
  });
@@ -13888,8 +14032,8 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
13888
14032
  return json({ error: "commercial webhook is not configured" }, 503);
13889
14033
  }
13890
14034
  try {
13891
- const event = await (0, import_stripe12.constructStripeEvent)(body, signature, secret);
13892
- const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
14035
+ const event2 = await (0, import_stripe12.constructStripeEvent)(body, signature, secret);
14036
+ const fact = normalizeNetworkCommercialStripeEvent(event2, await stripeEventDigest(body));
13893
14037
  if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
13894
14038
  const result = await ingestNetworkCommercialFact(
13895
14039
  db,
@@ -14000,7 +14144,7 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
14000
14144
  };
14001
14145
 
14002
14146
  // src/worker-routes-formation.ts
14003
- var import_crm19 = require("@odla-ai/crm");
14147
+ var import_crm25 = require("@odla-ai/crm");
14004
14148
  var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
14005
14149
  var handleFormation = async (req, url, env, ctx) => {
14006
14150
  const formation = ctx.chapter.formation;
@@ -14052,7 +14196,7 @@ var handleFormation = async (req, url, env, ctx) => {
14052
14196
  const db = ctx.makeDb(env);
14053
14197
  const id6 = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
14054
14198
  try {
14055
- const created = await (0, import_crm19.createRecord)(
14199
+ const created = await (0, import_crm25.createRecord)(
14056
14200
  {
14057
14201
  crm: ctx.chapter.crm,
14058
14202
  db,
@@ -15488,7 +15632,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15488
15632
  };
15489
15633
  if (action === "seat-accept") return acceptNamedSeatRecipient(db, recipientInput, now);
15490
15634
  if (action === "seat-approve") return approveNamedSeatRecipient(db, recipientInput, now);
15491
- const checkout = {
15635
+ const checkout2 = {
15492
15636
  ...target,
15493
15637
  priceId: peer.seatPriceId,
15494
15638
  recipientName: String(input.recipientName),
@@ -15496,7 +15640,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15496
15640
  purchaserEmail: String(input.purchaserEmail),
15497
15641
  purchaserUserId: String(input.purchaserUserId)
15498
15642
  };
15499
- if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout, now))) return grantIncludedNamedSeat(db, checkout, now);
15643
+ if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout2, now))) return grantIncludedNamedSeat(db, checkout2, now);
15500
15644
  const secret = await getVaultSecret(db, stripeSecretName);
15501
15645
  if (!secret) throw new Error("network Stripe provider is unavailable");
15502
15646
  if (action === "seat-decline") {
@@ -15512,7 +15656,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15512
15656
  return { seatId: seat.id, status: "refunded", refundedCents: refunded.refundedCents, subscriptionCanceled: false };
15513
15657
  }
15514
15658
  if (action === "seat-operator-refund") return refundNamedSeatByOperator(db, { ...target, seatId: String(input.seatId) }, secret, now);
15515
- if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout, secret, now);
15659
+ if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout2, secret, now);
15516
15660
  const evidence = { ...target, seatId: String(input.seatId), eventId: String(input.eventId) };
15517
15661
  if (action === "seat-payments") {
15518
15662
  const receipt = await confirmNamedMembershipSeatPayment(db, evidence, now);
@@ -15583,9 +15727,9 @@ function createMembershipAuthorityHandler(options) {
15583
15727
  const identities = /* @__PURE__ */ new Set();
15584
15728
  for (const peer of peers) {
15585
15729
  if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(peer.appId) || !SAFE2.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");
15586
- const identity = `${peer.appId}:${peer.runtime}`;
15587
- if (identities.has(identity)) throw new TypeError("ambiguous authority peer");
15588
- identities.add(identity);
15730
+ const identity2 = `${peer.appId}:${peer.runtime}`;
15731
+ if (identities.has(identity2)) throw new TypeError("ambiguous authority peer");
15732
+ identities.add(identity2);
15589
15733
  if (peer.seatPriceId !== void 0 && !/^price_[A-Za-z0-9]+$/.test(peer.seatPriceId)) throw new TypeError("invalid seat Price");
15590
15734
  }
15591
15735
  return async (req) => {
@@ -16189,6 +16333,9 @@ async function readBoundedJson(response, signal, maxBytes, label) {
16189
16333
  }
16190
16334
  }
16191
16335
 
16336
+ // src/membership-chapter-delivery.ts
16337
+ var import_crm26 = require("@odla-ai/crm");
16338
+
16192
16339
  // src/invitation-test-delivery.ts
16193
16340
  function invitationDelivery(env, recipientEmail) {
16194
16341
  const normal = { sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL };
@@ -16251,16 +16398,13 @@ function createMembershipChapterEffectRoute(options) {
16251
16398
  const join = new URL(applicationPath, origin);
16252
16399
  join.searchParams.set("seat", effect.seatId);
16253
16400
  join.searchParams.set("tier", effect.skuId);
16254
- config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: config.emailTemplates?.namedSeatInvitation ?? {
16255
- enabled: true,
16256
- subject: "{{purchaserName}} invited you to {{chapterName}}",
16257
- text: "Hello {{firstName}},\n\n{{purchaserName}} has purchased a membership for you at {{chapterName}}. Your seat is already paid for.\n\nAccept your invitation here:\n{{joinUrl}}\n\nYou will complete your own application and membership requirements. You will not be asked to pay for this seat."
16258
- } };
16259
16401
  const template = config.emailTemplates.namedSeatInvitation;
16260
- if (!template.text.includes("{{purchaserName}}")) config.emailTemplates.namedSeatInvitation = {
16261
- ...template,
16262
- text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16263
- };
16402
+ if (template && !(0, import_crm26.validateEmailCopy)(template).length && !template.text.includes("{{purchaserName}}")) {
16403
+ config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: {
16404
+ ...template,
16405
+ text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16406
+ } };
16407
+ }
16264
16408
  stage = "invitation_delivery";
16265
16409
  const result = await sendTemplated({
16266
16410
  db,
@@ -16280,7 +16424,8 @@ function createMembershipChapterEffectRoute(options) {
16280
16424
  joinUrl: join.href
16281
16425
  },
16282
16426
  dedupeKey: `membership-effect:${effect.effectId}`,
16283
- applicationId: input.applicationId
16427
+ applicationId: input.applicationId,
16428
+ tierId: effect.skuId
16284
16429
  });
16285
16430
  if (!result.sent) throw new Error("invitation delivery unconfirmed");
16286
16431
  } else {