@odla-ai/chapter 0.59.1 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1707,20 +1707,74 @@ function isRetiredEmailCopy(subject, text10) {
1707
1707
  }
1708
1708
 
1709
1709
  // src/email.ts
1710
+ var import_crm6 = require("@odla-ai/crm");
1711
+
1712
+ // src/email-render.ts
1710
1713
  var import_crm5 = require("@odla-ai/crm");
1711
1714
 
1715
+ // src/email-html.ts
1716
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1717
+ function escapeHtml(s) {
1718
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1719
+ }
1720
+ function lineHtml(line) {
1721
+ let out = "", last = 0;
1722
+ for (const match of line.matchAll(URL_RE)) {
1723
+ out += escapeHtml(line.slice(last, match.index));
1724
+ const url = escapeHtml(match[0]);
1725
+ out += `<a href="${url}">${url}</a>`;
1726
+ last = match.index + match[0].length;
1727
+ }
1728
+ return out + escapeHtml(line.slice(last));
1729
+ }
1730
+ function textToHtml(text10) {
1731
+ const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1732
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1733
+ return `<!doctype html>
1734
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1735
+ <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
1736
+ ${body}
1737
+ </body></html>`;
1738
+ }
1739
+
1740
+ // src/email-render.ts
1741
+ function render(template, vars) {
1742
+ return (0, import_crm5.renderTemplate)(template, vars);
1743
+ }
1744
+ var HTML_BODY_LIMIT = 2e5;
1745
+ function renderHtmlBody(template, vars) {
1746
+ const escaped = Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, escapeHtml(value).replace(/\r?\n/g, "<br>")]));
1747
+ return (0, import_crm5.renderTemplate)(template, escaped);
1748
+ }
1749
+ function htmlBodyIssues(html, options) {
1750
+ const issues = [];
1751
+ if (html.length > HTML_BODY_LIMIT) issues.push({ code: "html-invalid", message: `Keep the HTML body within ${HTML_BODY_LIMIT.toLocaleString("en-US")} characters.` });
1752
+ for (const message of (0, import_crm5.templateSectionIssues)(html)) issues.push({ code: "section-invalid", message: `${message} (HTML body)` });
1753
+ const unknown = new Set((0, import_crm5.templateVars)(html).filter((variable) => options.allowedVariables && !options.allowedVariables.includes(variable)));
1754
+ for (const variable of unknown) issues.push({ code: "variable-unknown", variable, message: `This event does not supply {{${variable}}} (HTML body).` });
1755
+ if (options.values) {
1756
+ for (const name of (0, import_crm5.templateSectionNames)(html)) if (typeof options.values[name] !== "string") issues.push({ code: "value-missing", variable: name, message: `Missing event value: ${name} (HTML body).` });
1757
+ for (const variable of (0, import_crm5.templateLiveVars)(html, options.values)) {
1758
+ if (unknown.has(variable)) continue;
1759
+ const value = options.values[variable];
1760
+ if (typeof value !== "string" || !value.trim() && !options.optionalVariables?.includes(variable)) issues.push({ code: "value-missing", variable, message: `Missing event value: ${variable} (HTML body).` });
1761
+ }
1762
+ }
1763
+ return issues;
1764
+ }
1765
+
1712
1766
  // src/email-events.ts
1713
1767
  var identity = ["firstName", "lastName", "email", "phone", "state"];
1714
1768
  var policy = ["refundPolicyText", "commitmentText", "normsText"];
1715
- var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
1716
- var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
1769
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
1770
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
1717
1771
  var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
1718
1772
  var CHAPTER_EMAIL_EVENTS = {
1719
1773
  adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
1720
1774
  submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
1721
1775
  paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
1722
1776
  prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
1723
- onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
1777
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName", "namedSeat", "namedSeatRecipientName"]),
1724
1778
  namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
1725
1779
  seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
1726
1780
  };
@@ -1735,9 +1789,6 @@ function chapterEmailEvents(chapter) {
1735
1789
  }
1736
1790
 
1737
1791
  // src/email.ts
1738
- function render(template, vars) {
1739
- return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
1740
- }
1741
1792
  function groupVars(group, vars) {
1742
1793
  return {
1743
1794
  ...vars,
@@ -1763,14 +1814,16 @@ function planDelivery(input) {
1763
1814
  const vars = groupVars(input.group, input.vars);
1764
1815
  if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
1765
1816
  const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1766
- const issues = (0, import_crm5.validateEmailCopy)(tpl, {
1817
+ const issues = (0, import_crm6.validateEmailCopy)(tpl, {
1767
1818
  allowedVariables: event2?.variables,
1768
1819
  optionalVariables: event2?.optionalVariables,
1769
1820
  requiredVariables: event2?.requiresTier ? ["tierName"] : [],
1770
1821
  values: vars,
1771
1822
  tierId: vars.tierId
1772
1823
  });
1773
- if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
1824
+ const html = typeof tpl.html === "string" && tpl.html.trim() ? tpl.html : void 0;
1825
+ const htmlIssues = html ? htmlBodyIssues(html, { allowedVariables: event2?.variables, optionalVariables: event2?.optionalVariables, values: vars }) : [];
1826
+ if (issues.length || htmlIssues.length) return { deliver: false, reason: "template-invalid", issues: [...issues, ...htmlIssues].map((issue) => issue.message) };
1774
1827
  const isProd = input.envName === "prod";
1775
1828
  const redirect = !isProd && !!input.group.debugEmail;
1776
1829
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
@@ -1778,19 +1831,37 @@ function planDelivery(input) {
1778
1831
  const renderedSubject = render(tpl.subject, vars);
1779
1832
  const renderedText = render(tpl.text, vars);
1780
1833
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1781
- if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1834
+ if ((0, import_crm6.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1782
1835
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
1783
1836
  const text10 = redirect ? `(dev redirect; original recipient: ${input.to})
1784
1837
 
1785
1838
  ` + renderedText : renderedText;
1786
- return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1839
+ const renderedHtml = html ? renderHtmlBody(html, vars) : void 0;
1840
+ const htmlOut = renderedHtml && redirect ? `<p style="font:13px monospace;color:#555;margin:0 0 12px">(dev redirect; original recipient: ${escapeHtml(input.to)})</p>` + renderedHtml : renderedHtml;
1841
+ return { deliver: true, transport, to, subject, text: text10, ...htmlOut ? { html: htmlOut } : {}, redirected: redirect };
1842
+ }
1843
+
1844
+ // src/email-deliverability.ts
1845
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1846
+ function isRecipientFacing(event2) {
1847
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1848
+ }
1849
+ function listUnsubscribeHeader(replyTo) {
1850
+ const address = replyTo.trim();
1851
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1852
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1853
+ }
1854
+ function deliverabilityHeaders(input) {
1855
+ if (!isRecipientFacing(input.event)) return void 0;
1856
+ const value = listUnsubscribeHeader(input.replyTo);
1857
+ return value ? { "List-Unsubscribe": value } : void 0;
1787
1858
  }
1788
1859
 
1789
1860
  // src/notify.ts
1790
- var import_crm7 = require("@odla-ai/crm");
1861
+ var import_crm8 = require("@odla-ai/crm");
1791
1862
 
1792
1863
  // src/email-event-values.ts
1793
- var import_crm6 = require("@odla-ai/crm");
1864
+ var import_crm7 = require("@odla-ai/crm");
1794
1865
 
1795
1866
  // src/tiers.ts
1796
1867
  function tierIsFree(tier) {
@@ -1899,10 +1970,14 @@ async function frozenCheckoutVariables(db, app) {
1899
1970
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1900
1971
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1901
1972
  }
1973
+ const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
1974
+ const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
1902
1975
  return {
1903
1976
  paymentAmount: formatted,
1904
1977
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1905
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
1978
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : "",
1979
+ namedSeat: namedSeatRecipientName ? Number.isSafeInteger(seatCents) && Number(seatCents) > 0 ? "paid" : "included" : "",
1980
+ namedSeatRecipientName
1906
1981
  };
1907
1982
  }
1908
1983
  async function catalogTierName(db, group, app) {
@@ -1960,6 +2035,8 @@ function sampleVariables(origin, timeZone, now = Date.now()) {
1960
2035
  purchaserName: "Sample Purchaser",
1961
2036
  recipientName: "Sample Recipient",
1962
2037
  chapterName: "Sample Chapter",
2038
+ namedSeat: "paid",
2039
+ namedSeatRecipientName: "Sample Recipient",
1963
2040
  acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1964
2041
  joinUrl: `${origin}/join?seat=sample`
1965
2042
  };
@@ -1977,7 +2054,7 @@ async function emailTierVariables(db, input) {
1977
2054
  const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
1978
2055
  if (!tierId) return { tierName: "", tier: "" };
1979
2056
  const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
1980
- const tierName2 = (0, import_crm6.isEmailTierName)(name, tierId) ? name.trim() : "";
2057
+ const tierName2 = (0, import_crm7.isEmailTierName)(name, tierId) ? name.trim() : "";
1981
2058
  return { tierName: tierName2, tier: tierName2, tierId };
1982
2059
  } catch {
1983
2060
  return { tierName: "", tier: "" };
@@ -2037,7 +2114,7 @@ async function sendTemplated(deps, input) {
2037
2114
  ...input.applicationId ? { applicationId: input.applicationId } : {}
2038
2115
  };
2039
2116
  if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
2040
- if ((0, import_crm7.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
2117
+ if ((0, import_crm8.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
2041
2118
  try {
2042
2119
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id7, attrs: row2 }], {
2043
2120
  mutationId: `email-claim:${id7}:${attempt}`,
@@ -2057,11 +2134,16 @@ async function sendTemplated(deps, input) {
2057
2134
  if (decision.transport === "log-only") return { sent: true };
2058
2135
  let messageId;
2059
2136
  try {
2137
+ const text10 = String(row2.body);
2138
+ const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text10);
2139
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
2060
2140
  const response = await deps.sender.send({
2061
2141
  from: fromAddress(deps.from, input.group.fromName),
2062
2142
  to: [decision.to],
2063
2143
  subject: String(row2.subject),
2064
- text: String(row2.body),
2144
+ text: text10,
2145
+ ...html ? { html } : {},
2146
+ ...headers ? { headers } : {},
2065
2147
  replyTo: input.group.replyTo
2066
2148
  });
2067
2149
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -5957,7 +6039,7 @@ function belongsToSignup(account, applicationId) {
5957
6039
  }
5958
6040
 
5959
6041
  // src/member.ts
5960
- var import_crm8 = require("@odla-ai/crm");
6042
+ var import_crm9 = require("@odla-ai/crm");
5961
6043
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
5962
6044
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
5963
6045
  var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
@@ -5991,7 +6073,7 @@ function resolveApplication(a) {
5991
6073
  if (typeof expression !== "string" || !expression.trim()) {
5992
6074
  throw new Error(`defineChapter.application.conditions.${field}.${key3}: must be a non-empty CEL condition`);
5993
6075
  }
5994
- (0, import_crm8.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
6076
+ (0, import_crm9.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
5995
6077
  }
5996
6078
  }
5997
6079
  return {
@@ -6034,7 +6116,7 @@ function applicantProfile(chapter, fields) {
6034
6116
  }
6035
6117
  async function submitApplication(db, chapter, fields, opts) {
6036
6118
  const app = chapter.application;
6037
- const fieldStates = (0, import_crm8.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
6119
+ const fieldStates = (0, import_crm9.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
6038
6120
  for (const [f, state] of Object.entries(fieldStates)) {
6039
6121
  if (!state.required) continue;
6040
6122
  const v = fields[f];
@@ -6175,7 +6257,7 @@ async function rejectUnprovisionedSignup(db, applicationId, status2) {
6175
6257
  }
6176
6258
 
6177
6259
  // src/worker-routes.ts
6178
- var import_crm9 = require("@odla-ai/crm");
6260
+ var import_crm10 = require("@odla-ai/crm");
6179
6261
 
6180
6262
  // src/discussion-reference-security.ts
6181
6263
  async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
@@ -7776,7 +7858,7 @@ var handleCrm = async (req, url, env, ctx) => {
7776
7858
  const crmBase = ctx.crmBase;
7777
7859
  if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
7778
7860
  const db = ctx.makeDb(env);
7779
- const routes = (0, import_crm9.createCrmRoutes)({
7861
+ const routes = (0, import_crm10.createCrmRoutes)({
7780
7862
  crm: ctx.chapter.crm,
7781
7863
  db,
7782
7864
  authorize: async (r) => {
@@ -9187,7 +9269,7 @@ async function networkWriteAllowed(req, db, user, ctx, sensitiveRead = false) {
9187
9269
 
9188
9270
  // src/network-person-reference.ts
9189
9271
  var import_db6 = require("@odla-ai/db");
9190
- var import_crm11 = require("@odla-ai/crm");
9272
+ var import_crm12 = require("@odla-ai/crm");
9191
9273
 
9192
9274
  // src/worker-network-fetch.ts
9193
9275
  var NetworkTargetTransportError = class extends Error {
@@ -9223,7 +9305,7 @@ function fetchNetworkTarget(env, target, destination, init) {
9223
9305
  }
9224
9306
 
9225
9307
  // src/worker-network-admission-state.ts
9226
- var import_crm10 = require("@odla-ai/crm");
9308
+ var import_crm11 = require("@odla-ai/crm");
9227
9309
  var MAX_ASSIGNMENTS = 2e3;
9228
9310
  var admissionRows = (value) => Array.isArray(value) ? value : [];
9229
9311
  function assignmentId(targetId, personId) {
@@ -9250,7 +9332,7 @@ function personAdmissionEmail(ctx, record4) {
9250
9332
  return isValidEmail(normalized) ? normalized : null;
9251
9333
  }
9252
9334
  async function grantInput(db, ctx, assignment, resolveEmail) {
9253
- const person = resolveEmail ? null : await (0, import_crm10.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
9335
+ const person = resolveEmail ? null : await (0, import_crm11.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
9254
9336
  const email = resolveEmail ? await resolveEmail(assignment.personId) : person ? personAdmissionEmail(ctx, person) : null;
9255
9337
  if (!email) throw new Error(`person ${assignment.personId} has no valid authoritative email`);
9256
9338
  return {
@@ -9456,7 +9538,7 @@ function chapterPersonReference(targetId, recordId) {
9456
9538
  }
9457
9539
  async function resolveAdmissionPersonEmail(db, env, ctx, target, personId) {
9458
9540
  if (!personId.startsWith("chapter-person:")) {
9459
- const person2 = await (0, import_crm11.getRecord)({ crm: ctx.chapter.crm, db }, personId);
9541
+ const person2 = await (0, import_crm12.getRecord)({ crm: ctx.chapter.crm, db }, personId);
9460
9542
  return person2 ? personAdmissionEmail(ctx, person2) : null;
9461
9543
  }
9462
9544
  const prefix = `chapter-person:${target.id}:`;
@@ -11355,10 +11437,10 @@ var handleEmailDeliveryResolution = async (req, url, env, ctx) => {
11355
11437
  };
11356
11438
 
11357
11439
  // src/worker-routes-people-profile.ts
11358
- var import_crm13 = require("@odla-ai/crm");
11440
+ var import_crm14 = require("@odla-ai/crm");
11359
11441
 
11360
11442
  // src/people-profile.ts
11361
- var import_crm12 = require("@odla-ai/crm");
11443
+ var import_crm13 = require("@odla-ai/crm");
11362
11444
  var sameValue = (a, b) => (a == null || a === "") && (b == null || b === "") || JSON.stringify(a) === JSON.stringify(b);
11363
11445
  function profileReadOnlyFields(record4, ctx) {
11364
11446
  return [
@@ -11373,17 +11455,17 @@ async function updateChapterPerson(opts) {
11373
11455
  const { db, env, ctx, record: record4 } = opts;
11374
11456
  const input = Object.fromEntries(Object.entries(opts.input).filter(([key3, value]) => !sameValue(value, record4.fields[key3])));
11375
11457
  const forbidden = profileReadOnlyFields(record4, ctx).filter((key3) => key3 in input);
11376
- if (forbidden.length) throw new import_crm12.CrmInputError(
11458
+ if (forbidden.length) throw new import_crm13.CrmInputError(
11377
11459
  "Use the verified identity or application workflow for protected fields",
11378
11460
  Object.fromEntries(forbidden.map((key3) => [key3, "This field is managed by its authoritative workflow"]))
11379
11461
  );
11380
11462
  const appId = record4.fields.applicationId;
11381
11463
  const app = typeof appId === "string" && appId ? (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0] : null;
11382
11464
  if (appId && (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME) || String(app.email).trim().toLowerCase() !== String(record4.primaryEmail).trim().toLowerCase())) {
11383
- throw new import_crm12.CrmInputError("The application identity must be repaired before editing this profile");
11465
+ throw new import_crm13.CrmInputError("The application identity must be repaired before editing this profile");
11384
11466
  }
11385
11467
  const prepared = ctx.chapter.crm.prepare(record4.type, input, record4);
11386
- if (!prepared.ok) throw new import_crm12.CrmInputError("invalid input", prepared.errors);
11468
+ if (!prepared.ok) throw new import_crm13.CrmInputError("invalid input", prepared.errors);
11387
11469
  const appAttrs = {};
11388
11470
  if (app) {
11389
11471
  const mirrored = /* @__PURE__ */ new Set(["firstName", "lastName", "phone", "linkedin", ...ctx.chapter.application.crmFields]);
@@ -11412,7 +11494,7 @@ async function updateChapterPerson(opts) {
11412
11494
  }] });
11413
11495
  }
11414
11496
  };
11415
- return (0, import_crm12.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
11497
+ return (0, import_crm13.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
11416
11498
  id: record4.id,
11417
11499
  input,
11418
11500
  expectedUpdatedAt: opts.expectedRevision ?? record4.updatedAt,
@@ -11457,7 +11539,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
11457
11539
  } catch {
11458
11540
  return json({ error: "invalid record id" }, 422);
11459
11541
  }
11460
- const record4 = await (0, import_crm13.getRecord)({ crm: ctx.chapter.crm, db }, id7);
11542
+ const record4 = await (0, import_crm14.getRecord)({ crm: ctx.chapter.crm, db }, id7);
11461
11543
  if (!record4) return json({ error: "not found" }, 404);
11462
11544
  if (record4.type !== "person") return dedicated ? json({ error: "person required" }, 422) : null;
11463
11545
  if (match?.[2]) {
@@ -11509,7 +11591,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
11509
11591
  });
11510
11592
  return json(result);
11511
11593
  } catch (error) {
11512
- if (error instanceof import_crm13.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
11594
+ if (error instanceof import_crm14.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
11513
11595
  if (error.code === "transact_guard_failed") return json({ error: "profile changed; reload before saving" }, 409);
11514
11596
  return json({ error: "profile update failed" }, 503);
11515
11597
  }
@@ -11684,10 +11766,10 @@ async function repairAccountPeoplePage(deps, after) {
11684
11766
  }
11685
11767
 
11686
11768
  // src/crm-application-repair.ts
11687
- var import_crm14 = require("@odla-ai/crm");
11769
+ var import_crm15 = require("@odla-ai/crm");
11688
11770
  async function repairApplicationLink(db, chapter, recordId, actorId) {
11689
11771
  const deps = { crm: chapter.crm, db };
11690
- const record4 = await (0, import_crm14.getRecord)(deps, recordId);
11772
+ const record4 = await (0, import_crm15.getRecord)(deps, recordId);
11691
11773
  if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
11692
11774
  if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
11693
11775
  const fields = record4.fields;
@@ -11705,7 +11787,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
11705
11787
  }
11706
11788
  const app = [...applications.values()][0];
11707
11789
  if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
11708
- await (0, import_crm14.updateRecord)(deps, {
11790
+ await (0, import_crm15.updateRecord)(deps, {
11709
11791
  id: recordId,
11710
11792
  input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
11711
11793
  expectedUpdatedAt: record4.updatedAt,
@@ -12526,10 +12608,90 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
12526
12608
  };
12527
12609
 
12528
12610
  // src/email-admin-config.ts
12611
+ var import_crm17 = require("@odla-ai/crm");
12612
+
12613
+ // src/email-readiness.ts
12529
12614
  var import_crm16 = require("@odla-ai/crm");
12530
12615
 
12616
+ // src/email-spam-signals.ts
12617
+ var TRIGGER_PHRASES = [
12618
+ "act now",
12619
+ "click here",
12620
+ "limited time",
12621
+ "guaranteed",
12622
+ "risk-free",
12623
+ "risk free",
12624
+ "no obligation",
12625
+ "100% free",
12626
+ "winner",
12627
+ "congratulations",
12628
+ "urgent",
12629
+ "double your",
12630
+ "earn money",
12631
+ "make money",
12632
+ "cash bonus",
12633
+ "once in a lifetime",
12634
+ "exclusive deal",
12635
+ "order now",
12636
+ "buy now",
12637
+ "this is not spam",
12638
+ "dear friend",
12639
+ "dear customer"
12640
+ ];
12641
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12642
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12643
+ var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
12644
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12645
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12646
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12647
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12648
+ function emailSpamSignals(copy) {
12649
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12650
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12651
+ const html = typeof copy.html === "string" ? copy.html : "";
12652
+ const out = [];
12653
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12654
+ const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
12655
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12656
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12657
+ if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
12658
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12659
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12660
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12661
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12662
+ if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text10))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
12663
+ if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12664
+ const urls = [...`${text10}
12665
+ ${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/&quot;.*$/, ""));
12666
+ const hosts = urls.map((url) => {
12667
+ try {
12668
+ return new URL(url).hostname;
12669
+ } catch {
12670
+ return "";
12671
+ }
12672
+ });
12673
+ if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
12674
+ if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
12675
+ if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
12676
+ if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
12677
+ if (urls.length && words(text10).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
12678
+ return out;
12679
+ }
12680
+ function emailSenderWarnings(input) {
12681
+ const out = [];
12682
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12683
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12684
+ const domain = (address) => address.split("@")[1] ?? "";
12685
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12686
+ out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
12687
+ }
12688
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12689
+ out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
12690
+ }
12691
+ return out;
12692
+ }
12693
+
12531
12694
  // src/email-readiness.ts
12532
- var import_crm15 = require("@odla-ai/crm");
12533
12695
  function validateEmailReadiness(input) {
12534
12696
  const issues = [];
12535
12697
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12537,26 +12699,36 @@ function validateEmailReadiness(input) {
12537
12699
  }
12538
12700
  if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
12539
12701
  for (const tier of input.tiers ?? []) {
12540
- 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.` });
12702
+ if (!(0, import_crm16.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
12541
12703
  }
12542
- const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm15.isEmailTierName)(tier.name, tier.id));
12704
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm16.isEmailTierName)(tier.name, tier.id));
12543
12705
  if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
12544
12706
  const events = Object.entries(input.events).map(([key3, event2]) => {
12545
12707
  const template = input.templates[key3];
12546
- const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm15.validateEmailCopy)(template, { allowedVariables: event2.variables });
12708
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm16.validateEmailCopy)(template, { allowedVariables: event2.variables });
12547
12709
  if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
12710
+ const html = typeof template?.html === "string" && template.html.trim() ? template.html : "";
12711
+ if (html) problems.push(...htmlBodyIssues(html, { allowedVariables: event2.variables }));
12548
12712
  if (template) {
12549
- const used = [...`${template.subject ?? ""}
12550
- ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12551
- for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
12713
+ const copy = `${template.subject ?? ""}
12714
+ ${template.text ?? ""}
12715
+ ${html}`;
12716
+ const sectionOnly = new Set((0, import_crm16.templateSectionNames)(copy));
12717
+ for (const name of (0, import_crm16.templateVars)(copy)) if (name in input.groupValues && !input.groupValues[name]?.trim() && !sectionOnly.has(name)) {
12552
12718
  problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
12553
12719
  }
12554
12720
  }
12555
12721
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12556
12722
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
12557
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
12723
+ return {
12724
+ ...event2,
12725
+ key: key3,
12726
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12727
+ issues: problems,
12728
+ warnings: template ? emailSpamSignals(template) : []
12729
+ };
12558
12730
  });
12559
- return { checkedAt: input.now ?? Date.now(), events, issues };
12731
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12560
12732
  }
12561
12733
 
12562
12734
  // src/email-admin-config.ts
@@ -12564,13 +12736,14 @@ var str2 = (value) => typeof value === "string" ? value : "";
12564
12736
  async function emailSettings(db, ctx, env) {
12565
12737
  const hub = ctx.chapter.mode === "hub";
12566
12738
  const crmDeps3 = { db, crm: ctx.chapter.crm };
12567
- const group = hub ? await (0, import_crm16.getConfig)(crmDeps3) : (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
12739
+ const group = hub ? await (0, import_crm17.getConfig)(crmDeps3) : (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
12568
12740
  const raw2 = group?.emailTemplates;
12569
12741
  const stored = raw2 && typeof raw2 === "object" && !Array.isArray(raw2) ? raw2 : {};
12570
12742
  const events = chapterEmailEvents(ctx.chapter);
12571
12743
  const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
12572
12744
  subject: str2(stored[key3]?.subject),
12573
12745
  text: str2(stored[key3]?.text),
12746
+ html: str2(stored[key3]?.html),
12574
12747
  enabled: stored[key3]?.enabled
12575
12748
  }]));
12576
12749
  let tiers = [], tiersUnavailable = false;
@@ -12582,7 +12755,14 @@ async function emailSettings(db, ctx, env) {
12582
12755
  }
12583
12756
  }
12584
12757
  const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
12585
- const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
12758
+ const validation = validateEmailReadiness({
12759
+ events,
12760
+ templates: stored,
12761
+ groupValues,
12762
+ tiers: hub ? void 0 : tiers,
12763
+ tiersUnavailable,
12764
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12765
+ });
12586
12766
  return { group, events, data: {
12587
12767
  groupId: ctx.chapter.id,
12588
12768
  name: ctx.chapter.name,
@@ -12610,12 +12790,14 @@ async function updateEmailSettings(db, ctx, env, body) {
12610
12790
  if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
12611
12791
  const t = value;
12612
12792
  if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
12613
- const subject = str2(t.subject).trim(), text10 = str2(t.text);
12614
- const issues = (0, import_crm16.validateEmailCopy)({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12615
- if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
12616
- return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
12793
+ const subject = str2(t.subject).trim(), text10 = str2(t.text), html = str2(t.html);
12794
+ if (t.html !== void 0 && typeof t.html !== "string") return json({ error: `Invalid HTML body: ${key3}` }, 400);
12795
+ const issues = (0, import_crm17.validateEmailCopy)({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12796
+ const htmlIssues = html.trim() ? htmlBodyIssues(html, { allowedVariables: events[key3].variables }) : [];
12797
+ if (t.enabled && (issues.length || htmlIssues.length) || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code)) || html.length > HTML_BODY_LIMIT) {
12798
+ return json({ error: `${events[key3].label} is incomplete.`, errors: [...issues, ...htmlIssues].map((issue) => issue.message) }, 400);
12617
12799
  }
12618
- clean[key3] = { subject, text: text10, enabled: t.enabled };
12800
+ clean[key3] = { subject, text: text10, ...html.trim() ? { html } : {}, enabled: t.enabled };
12619
12801
  }
12620
12802
  const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
12621
12803
  const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
@@ -12628,7 +12810,7 @@ async function updateEmailSettings(db, ctx, env, body) {
12628
12810
  if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
12629
12811
  if (ctx.chapter.mode === "hub") {
12630
12812
  const missing = Object.fromEntries(Object.keys(events).filter((key3) => !group?.emailTemplates?.[key3]).map((key3) => [key3, { subject: "", text: "", enabled: false }]));
12631
- await (0, import_crm16.saveConfig)({ db, crm: ctx.chapter.crm }, { emailTemplates: { ...missing, ...clean }, notificationEmail, replyTo, debugEmail });
12813
+ await (0, import_crm17.saveConfig)({ db, crm: ctx.chapter.crm }, { emailTemplates: { ...missing, ...clean }, notificationEmail, replyTo, debugEmail });
12632
12814
  } else {
12633
12815
  const id7 = { ns: "groups", attr: "id", value: ctx.chapter.id };
12634
12816
  await db.transact([
@@ -12636,11 +12818,12 @@ async function updateEmailSettings(db, ctx, env, body) {
12636
12818
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12637
12819
  ]);
12638
12820
  }
12639
- return json({ ok: true });
12821
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12822
+ return json({ ok: true, warnings });
12640
12823
  }
12641
12824
 
12642
12825
  // src/worker-routes-admin-comms.ts
12643
- var import_crm17 = require("@odla-ai/crm");
12826
+ var import_crm18 = require("@odla-ai/crm");
12644
12827
  async function gate5(req, env, ctx) {
12645
12828
  const rawDb = ctx.makeDb(env);
12646
12829
  const u = await ctx.verifyUser(req, env);
@@ -12673,7 +12856,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
12673
12856
  const got = await gate5(req, env, ctx);
12674
12857
  if (got instanceof Response) return got;
12675
12858
  if (ctx.chapter.mode === "hub") {
12676
- const rows5 = await (0, import_crm17.listEmailLog)({ db: got, crm: ctx.chapter.crm }, { limit: 50 });
12859
+ const rows5 = await (0, import_crm18.listEmailLog)({ db: got, crm: ctx.chapter.crm }, { limit: 50 });
12677
12860
  return json({ sends: rows5.map((row2) => ({ ...row2, template: row2.templateId })) });
12678
12861
  }
12679
12862
  const { emailLog } = await got.query({ emailLog: { $: { where: { groupId: ctx.chapter.id }, order: { sentAt: "desc" }, limit: 50 } } });
@@ -12770,7 +12953,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
12770
12953
  };
12771
12954
 
12772
12955
  // src/worker-routes-network.ts
12773
- var import_crm19 = require("@odla-ai/crm");
12956
+ var import_crm20 = require("@odla-ai/crm");
12774
12957
  var import_db9 = require("@odla-ai/db");
12775
12958
 
12776
12959
  // src/network-edge.ts
@@ -12796,7 +12979,7 @@ function unpairedResponse(unpaired) {
12796
12979
  }
12797
12980
 
12798
12981
  // src/worker-network-push.ts
12799
- var import_crm18 = require("@odla-ai/crm");
12982
+ var import_crm19 = require("@odla-ai/crm");
12800
12983
  var import_db8 = require("@odla-ai/db");
12801
12984
  async function pushNetworkRecord(db, env, ctx, target, record4) {
12802
12985
  const secret = await getVaultSecret(db, target.secretName);
@@ -12808,7 +12991,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
12808
12991
  };
12809
12992
  if (!secret) {
12810
12993
  const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
12811
- await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
12994
+ await (0, import_crm19.recordDeliveryAttempt)(crmDeps3, {
12812
12995
  recordId: record4.id,
12813
12996
  targetId: target.id,
12814
12997
  status: "failed",
@@ -12847,7 +13030,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
12847
13030
  const responseBody = await res.json().catch(() => ({}));
12848
13031
  if (!res.ok) {
12849
13032
  const error = responseBody.error ?? "follower rejected the record";
12850
- await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
13033
+ await (0, import_crm19.recordDeliveryAttempt)(crmDeps3, {
12851
13034
  recordId: record4.id,
12852
13035
  targetId: target.id,
12853
13036
  status: "failed",
@@ -12862,7 +13045,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
12862
13045
  error
12863
13046
  };
12864
13047
  }
12865
- await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
13048
+ await (0, import_crm19.recordDeliveryAttempt)(crmDeps3, {
12866
13049
  recordId: record4.id,
12867
13050
  targetId: target.id,
12868
13051
  status: "delivered",
@@ -12878,7 +13061,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
12878
13061
  };
12879
13062
  } catch (err) {
12880
13063
  const error = err instanceof Error ? err.message : "delivery failed";
12881
- await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
13064
+ await (0, import_crm19.recordDeliveryAttempt)(crmDeps3, {
12882
13065
  recordId: record4.id,
12883
13066
  targetId: target.id,
12884
13067
  status: "failed",
@@ -12931,7 +13114,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
12931
13114
  const verified = await (0, import_db9.verifyFederatedRequest)(req, { secret });
12932
13115
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
12933
13116
  const types = await Promise.all(
12934
- Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm19.typeSummary)({ crm: ctx.chapter.crm, db }, type))
13117
+ Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm20.typeSummary)({ crm: ctx.chapter.crm, db }, type))
12935
13118
  );
12936
13119
  const activeConfiguration = ctx.chapter.signupControl ? await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV) : null;
12937
13120
  const snapshot = {
@@ -13078,7 +13261,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
13078
13261
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
13079
13262
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
13080
13263
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
13081
- const record4 = await (0, import_crm19.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
13264
+ const record4 = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
13082
13265
  if (!record4) return json({ error: "record not found" }, 404);
13083
13266
  const results = await Promise.all(
13084
13267
  targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record4))
@@ -13087,11 +13270,11 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
13087
13270
  };
13088
13271
 
13089
13272
  // src/worker-routes-network-records-follower.ts
13090
- var import_crm23 = require("@odla-ai/crm");
13273
+ var import_crm24 = require("@odla-ai/crm");
13091
13274
  var import_db10 = require("@odla-ai/db");
13092
13275
 
13093
13276
  // src/network-application-action.ts
13094
- var import_crm20 = require("@odla-ai/crm");
13277
+ var import_crm21 = require("@odla-ai/crm");
13095
13278
  var ACTION_META = "chapterNetworkMutation";
13096
13279
  async function networkApplicationAction(opts) {
13097
13280
  const { db, env, ctx, record: record4, input, sourceId, activityId, fingerprint } = opts;
@@ -13181,7 +13364,7 @@ async function networkApplicationAction(opts) {
13181
13364
  const current = (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0];
13182
13365
  if (!current || current.status !== input.stage) return json({ error: "application reconciliation pending", code: "action_pending" }, 503);
13183
13366
  await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) });
13184
- const projected = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
13367
+ const projected = await (0, import_crm21.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
13185
13368
  if (!projected || projected.stage !== input.stage) return json({ error: "CRM reconciliation pending", code: "action_pending" }, 503);
13186
13369
  const audit = (await db.query({ crm_activity: { $: { where: { id: activityId }, limit: 1 } } })).crm_activity?.[0];
13187
13370
  const priorMeta = audit?.meta;
@@ -13191,7 +13374,7 @@ async function networkApplicationAction(opts) {
13191
13374
  } },
13192
13375
  { t: "update", ns: "crm_activity", id: lockId, attrs: { meta: { state: "complete", activityId } } }
13193
13376
  ], { guards: [{ ns: "crm_activity", id: lockId, exists: true, equals: { meta: { state: "pending", activityId } } }] });
13194
- const updated = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
13377
+ const updated = await (0, import_crm21.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
13195
13378
  if (!updated) return json({ error: "record not found" }, 404);
13196
13379
  return { record: updated, duplicate: Boolean(receipt) };
13197
13380
  }
@@ -13227,7 +13410,7 @@ async function operationalReaderRecord(record4, reader, db, env, ctx) {
13227
13410
  }
13228
13411
 
13229
13412
  // src/worker-routes-network-records-search.ts
13230
- var import_crm21 = require("@odla-ai/crm");
13413
+ var import_crm22 = require("@odla-ai/crm");
13231
13414
  var SEARCH_BATCH_SIZE = 1e3;
13232
13415
  var MAX_READER_SEARCH_POPULATION = 1e4;
13233
13416
  var ReaderSearchPopulationError = class extends Error {
@@ -13268,19 +13451,19 @@ async function listVisibleSearchPopulation(db, where, window, count) {
13268
13451
  (_, index2) => index2 * SEARCH_BATCH_SIZE
13269
13452
  );
13270
13453
  const pages = await Promise.all(offsets.map((offset) => db.query({
13271
- [import_crm21.CRM_NS.record]: { $: {
13454
+ [import_crm22.CRM_NS.record]: { $: {
13272
13455
  where,
13273
13456
  order: { [window.sort.field]: window.sort.dir },
13274
13457
  limit: Math.min(SEARCH_BATCH_SIZE, count - offset),
13275
13458
  offset
13276
13459
  } }
13277
13460
  })));
13278
- return pages.flatMap((page) => (page[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord));
13461
+ return pages.flatMap((page) => (page[import_crm22.CRM_NS.record] ?? []).map(import_crm22.rowToRecord));
13279
13462
  }
13280
13463
  async function listReaderRecords(db, type, allowed2, emailField, window) {
13281
13464
  const where = baseWhere(type, window.stage);
13282
13465
  const crmDb = db;
13283
- const aggregate = await crmDb.aggregate(import_crm21.CRM_NS.record, { count: true }, { where });
13466
+ const aggregate = await crmDb.aggregate(import_crm22.CRM_NS.record, { count: true }, { where });
13284
13467
  const candidateCount = aggregate.count ?? 0;
13285
13468
  if (window.search) {
13286
13469
  const population = await listVisibleSearchPopulation(crmDb, where, window, candidateCount);
@@ -13292,7 +13475,7 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
13292
13475
  };
13293
13476
  }
13294
13477
  const result = await crmDb.query({
13295
- [import_crm21.CRM_NS.record]: { $: {
13478
+ [import_crm22.CRM_NS.record]: { $: {
13296
13479
  where,
13297
13480
  order: { [window.sort.field]: window.sort.dir },
13298
13481
  limit: window.limit,
@@ -13300,19 +13483,19 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
13300
13483
  } }
13301
13484
  });
13302
13485
  return {
13303
- records: (result[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord),
13486
+ records: (result[import_crm22.CRM_NS.record] ?? []).map(import_crm22.rowToRecord),
13304
13487
  total: candidateCount
13305
13488
  };
13306
13489
  }
13307
13490
 
13308
13491
  // src/network-mutation-receipt.ts
13309
- var import_crm22 = require("@odla-ai/crm");
13492
+ var import_crm23 = require("@odla-ai/crm");
13310
13493
  var NETWORK_MUTATION_META = "chapterNetworkMutation";
13311
13494
  async function priorMutation(db, id7) {
13312
13495
  const result = await db.query({
13313
- [import_crm22.CRM_NS.activity]: { $: { where: { id: id7 }, limit: 1 } }
13496
+ [import_crm23.CRM_NS.activity]: { $: { where: { id: id7 }, limit: 1 } }
13314
13497
  });
13315
- const row2 = (result[import_crm22.CRM_NS.activity] ?? [])[0];
13498
+ const row2 = (result[import_crm23.CRM_NS.activity] ?? [])[0];
13316
13499
  const meta = row2?.meta;
13317
13500
  const receipt = meta?.[NETWORK_MUTATION_META];
13318
13501
  return typeof receipt?.fingerprint === "string" ? { fingerprint: receipt.fingerprint, pending: receipt.state === "pending", failed: receipt.state === "failed" } : null;
@@ -13336,7 +13519,7 @@ function mutationError(error) {
13336
13519
  if (error?.code === "transact_guard_failed") {
13337
13520
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
13338
13521
  }
13339
- if (error instanceof import_crm23.CrmInputError) {
13522
+ if (error instanceof import_crm24.CrmInputError) {
13340
13523
  return json({ error: error.message, code: "invalid_transition_or_fields", ...error.fields ? { fields: error.fields } : {} }, 422);
13341
13524
  }
13342
13525
  return json({ error: "follower mutation failed", code: "mutation_failed" }, 500);
@@ -13391,7 +13574,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
13391
13574
  }
13392
13575
  if (prior?.failed) return json({ error: "this operation was not applied; reload before choosing a new action", code: "action_failed" }, 409);
13393
13576
  if (prior && !prior.pending) {
13394
- const replayed = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
13577
+ const replayed = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
13395
13578
  if (!replayed || replayed.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
13396
13579
  const result = {
13397
13580
  version: 1,
@@ -13401,7 +13584,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
13401
13584
  };
13402
13585
  return json(result);
13403
13586
  }
13404
- const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
13587
+ const record4 = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
13405
13588
  if (!record4 || record4.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
13406
13589
  if (!prior?.pending && record4.updatedAt !== input.expectedRevision) {
13407
13590
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
@@ -13464,7 +13647,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
13464
13647
  record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
13465
13648
  });
13466
13649
  }
13467
- const updated = hasFields ? await (0, import_crm23.updateRecord)(deps, {
13650
+ const updated = hasFields ? await (0, import_crm24.updateRecord)(deps, {
13468
13651
  id: record4.id,
13469
13652
  input: fields,
13470
13653
  mutationId: activityId,
@@ -13475,7 +13658,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
13475
13658
  authorId: `network:${verified.sender}`,
13476
13659
  meta: auditMeta
13477
13660
  }
13478
- }) : await (0, import_crm23.setStage)(deps, {
13661
+ }) : await (0, import_crm24.setStage)(deps, {
13479
13662
  id: record4.id,
13480
13663
  to: input.stage,
13481
13664
  authorId: `network:${verified.sender}`,
@@ -13502,7 +13685,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
13502
13685
  const recordId = url.searchParams.get("recordId");
13503
13686
  if (recordId !== null) {
13504
13687
  if (!RECORD_ID.test(recordId)) return json({ error: "invalid record id" }, 400);
13505
- const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
13688
+ const record4 = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
13506
13689
  if (!record4 || record4.type !== type || record4.archived) return json({ error: "record not found" }, 404);
13507
13690
  return json({
13508
13691
  version: 1,
@@ -13797,7 +13980,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
13797
13980
  };
13798
13981
 
13799
13982
  // src/worker-routes-network-shared-notes.ts
13800
- var import_crm24 = require("@odla-ai/crm");
13983
+ var import_crm25 = require("@odla-ai/crm");
13801
13984
  var import_db12 = require("@odla-ai/db");
13802
13985
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
13803
13986
  var RECORD_ID3 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
@@ -13828,7 +14011,7 @@ function sharedNote(activity, sourceId) {
13828
14011
  }
13829
14012
  async function listSharedNotes(db, sourceId, recordId) {
13830
14013
  const result = await db.query({
13831
- [import_crm24.CRM_NS.activity]: {
14014
+ [import_crm25.CRM_NS.activity]: {
13832
14015
  $: {
13833
14016
  where: {
13834
14017
  and: [
@@ -13841,7 +14024,7 @@ async function listSharedNotes(db, sourceId, recordId) {
13841
14024
  }
13842
14025
  }
13843
14026
  });
13844
- return (result[import_crm24.CRM_NS.activity] ?? []).flatMap((activity) => {
14027
+ return (result[import_crm25.CRM_NS.activity] ?? []).flatMap((activity) => {
13845
14028
  const note = sharedNote(activity, sourceId);
13846
14029
  return note ? [note] : [];
13847
14030
  });
@@ -13869,7 +14052,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
13869
14052
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
13870
14053
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
13871
14054
  }
13872
- const record4 = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
14055
+ const record4 = await (0, import_crm25.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
13873
14056
  if (!record4 || record4.type !== recordType) return json({ error: "record not found" }, 404);
13874
14057
  if (req.method === "GET") {
13875
14058
  return json({
@@ -13891,7 +14074,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
13891
14074
  }
13892
14075
  const createdAt = Date.now();
13893
14076
  const id7 = `network:${verified.sender}:${mutationId}`;
13894
- const result = await (0, import_crm24.addActivity)(
14077
+ const result = await (0, import_crm25.addActivity)(
13895
14078
  {
13896
14079
  crm: ctx.chapter.crm,
13897
14080
  db,
@@ -15520,7 +15703,7 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
15520
15703
  };
15521
15704
 
15522
15705
  // src/worker-routes-formation.ts
15523
- var import_crm25 = require("@odla-ai/crm");
15706
+ var import_crm26 = require("@odla-ai/crm");
15524
15707
  var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
15525
15708
  var handleFormation = async (req, url, env, ctx) => {
15526
15709
  const formation = ctx.chapter.formation;
@@ -15572,7 +15755,7 @@ var handleFormation = async (req, url, env, ctx) => {
15572
15755
  const db = ctx.makeDb(env);
15573
15756
  const id7 = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
15574
15757
  try {
15575
- const created = await (0, import_crm25.createRecord)(
15758
+ const created = await (0, import_crm26.createRecord)(
15576
15759
  {
15577
15760
  crm: ctx.chapter.crm,
15578
15761
  db,
@@ -17763,7 +17946,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
17763
17946
  }
17764
17947
 
17765
17948
  // src/membership-chapter-delivery.ts
17766
- var import_crm26 = require("@odla-ai/crm");
17949
+ var import_crm27 = require("@odla-ai/crm");
17767
17950
 
17768
17951
  // src/invitation-test-delivery.ts
17769
17952
  function invitationDelivery(env, recipientEmail) {
@@ -17828,7 +18011,7 @@ function createMembershipChapterEffectRoute(options) {
17828
18011
  join.searchParams.set("seat", effect.seatId);
17829
18012
  join.searchParams.set("tier", effect.skuId);
17830
18013
  const template = config.emailTemplates.namedSeatInvitation;
17831
- if (template && !(0, import_crm26.validateEmailCopy)(template).length && !template.text.includes("{{purchaserName}}")) {
18014
+ if (template && !(0, import_crm27.validateEmailCopy)(template).length && !template.text.includes("{{purchaserName}}")) {
17832
18015
  config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: {
17833
18016
  ...template,
17834
18017
  text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text