@odla-ai/chapter 0.15.0 → 0.16.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.
@@ -10,6 +10,23 @@ function roleFromClaim(payload, auth) {
10
10
  function isAdminRole(role, auth) {
11
11
  return role === auth.adminRole;
12
12
  }
13
+ function canChangeRole(ctx) {
14
+ const { auth } = ctx;
15
+ if (!auth.ladder.includes(ctx.newRole)) {
16
+ return { ok: false, status: 400, error: `role must be one of: ${auth.ladder.join(", ")}` };
17
+ }
18
+ if (ctx.actorId === ctx.targetId) {
19
+ return { ok: false, status: 400, error: "you cannot change your own role" };
20
+ }
21
+ if (ctx.targetIsSuper && !ctx.actorIsSuper) {
22
+ return { ok: false, status: 403, error: "this person is a super-admin; their access is managed in odla Studio" };
23
+ }
24
+ const touchesAdmin = ctx.newRole === auth.adminRole || ctx.targetCurrentRole === auth.adminRole;
25
+ if (auth.superAdmins && touchesAdmin && !ctx.actorIsSuper) {
26
+ return { ok: false, status: 403, error: `only super-admins can create or change an ${auth.adminRole}` };
27
+ }
28
+ return { ok: true };
29
+ }
13
30
  async function getVaultSecret(db, name) {
14
31
  try {
15
32
  const value = await db.secrets.get(name);
@@ -243,14 +260,14 @@ function sharedPersonInput(person) {
243
260
  }
244
261
  async function upsertPerson(deps, opts) {
245
262
  const email = opts.email.toLowerCase();
246
- const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
263
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
247
264
  const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
248
265
  const existing = crm_record?.[0];
249
266
  if (existing && typeof existing.id === "string") {
250
- await updateRecord(crmDeps, { id: existing.id, input: opts.input });
267
+ await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
251
268
  return { recordId: existing.id };
252
269
  }
253
- const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
270
+ const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
254
271
  return { recordId: created.id };
255
272
  }
256
273
  async function projectSharedRecord(deps, person) {
@@ -426,6 +443,11 @@ function groupVars(group, vars) {
426
443
  normsText: group.normsText ?? ""
427
444
  };
428
445
  }
446
+ function renderTemplateBody(group, template, vars) {
447
+ const tpl = group.emailTemplates?.[template];
448
+ if (!tpl) return null;
449
+ return render(tpl.text, groupVars(group, vars));
450
+ }
429
451
  function isAlreadySent(priorRows) {
430
452
  return priorRows.some((row) => !row.error);
431
453
  }
@@ -446,6 +468,7 @@ function planDelivery(input) {
446
468
  }
447
469
 
448
470
  // src/notify.ts
471
+ var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
449
472
  async function sendTemplated(deps, input) {
450
473
  const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
451
474
  const prior = Array.isArray(emailLog) ? emailLog : [];
@@ -497,16 +520,16 @@ async function sendTemplated(deps, input) {
497
520
  return error ? { sent: false, reason: error } : { sent: true };
498
521
  }
499
522
  function emailGroupFrom(row) {
500
- const str = (v) => typeof v === "string" ? v : void 0;
523
+ const str3 = (v) => typeof v === "string" ? v : void 0;
501
524
  const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
502
525
  return {
503
526
  id: String(row.id),
504
527
  name: String(row.name ?? ""),
505
- replyTo: str(row.replyTo) ?? "",
506
- debugEmail: str(row.debugEmail),
507
- refundPolicyText: str(row.refundPolicyText),
508
- commitmentText: str(row.commitmentText),
509
- normsText: str(row.normsText),
528
+ replyTo: str3(row.replyTo) ?? "",
529
+ debugEmail: str3(row.debugEmail),
530
+ refundPolicyText: str3(row.refundPolicyText),
531
+ commitmentText: str3(row.commitmentText),
532
+ normsText: str3(row.normsText),
510
533
  emailTemplates: templates
511
534
  };
512
535
  }
@@ -694,9 +717,17 @@ var handleMember = async (req, url, env, ctx) => {
694
717
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
695
718
 
696
719
  // src/pipeline.ts
720
+ function canTransition(from, to, p) {
721
+ const fi = p.stages.indexOf(from);
722
+ const ti = p.stages.indexOf(to);
723
+ return fi >= 0 && ti >= 0 && ti >= fi;
724
+ }
697
725
  function canBook(status, p) {
698
726
  return p.bookableFrom.includes(status);
699
727
  }
728
+ function canApprove(status, p) {
729
+ return p.approvableFrom.includes(status);
730
+ }
700
731
 
701
732
  // src/worker-routes-schedule.ts
702
733
  function errCode(err) {
@@ -711,8 +742,8 @@ function makeCalendar(env) {
711
742
  }
712
743
  async function firstRow(db, ns, q) {
713
744
  const res = await db.query({ [ns]: { $: q } });
714
- const rows = res[ns];
715
- return Array.isArray(rows) ? rows[0] : void 0;
745
+ const rows2 = res[ns];
746
+ return Array.isArray(rows2) ? rows2[0] : void 0;
716
747
  }
717
748
  async function computeSlots(cal, cfg) {
718
749
  const { from, to } = slotWindow(Date.now(), cfg.windowDays);
@@ -1005,8 +1036,8 @@ function createStripeProvider(config) {
1005
1036
  const sk = requireSecret(secretKey);
1006
1037
  const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
1007
1038
  if (!charges.ok) fail("charges list", charges);
1008
- const rows = charges.body.data ?? [];
1009
- const paid = rows.filter((c) => c.status === "succeeded" && c.refunded !== true);
1039
+ const rows2 = charges.body.data ?? [];
1040
+ const paid = rows2.filter((c) => c.status === "succeeded" && c.refunded !== true);
1010
1041
  const charge = paid[paid.length - 1];
1011
1042
  if (!charge) {
1012
1043
  const err = new Error("no paid charge to refund");
@@ -1026,8 +1057,8 @@ function createStripeProvider(config) {
1026
1057
  var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
1027
1058
  async function firstRow2(db, ns, q) {
1028
1059
  const res = await db.query({ [ns]: { $: q } });
1029
- const rows = res[ns];
1030
- return Array.isArray(rows) ? rows[0] : void 0;
1060
+ const rows2 = res[ns];
1061
+ return Array.isArray(rows2) ? rows2[0] : void 0;
1031
1062
  }
1032
1063
  function lineItems(group) {
1033
1064
  const standard = Number(group.standardPriceCents ?? 0);
@@ -1262,8 +1293,8 @@ async function upcomingEvents(env) {
1262
1293
  const res = await cal.availability.upcoming();
1263
1294
  return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
1264
1295
  }
1265
- function toReconcile(rows) {
1266
- return rows.map((m) => ({
1296
+ function toReconcile(rows2) {
1297
+ return rows2.map((m) => ({
1267
1298
  id: String(m.id),
1268
1299
  applicationId: String(m.applicationId),
1269
1300
  googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
@@ -1284,12 +1315,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1284
1315
  const query = await db.query({
1285
1316
  meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
1286
1317
  });
1287
- let rows = Array.isArray(query.meetings) ? query.meetings : [];
1288
- if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
1289
- if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
1318
+ let rows2 = Array.isArray(query.meetings) ? query.meetings : [];
1319
+ if (Number.isFinite(from)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) >= from);
1320
+ if (Number.isFinite(to)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) <= to);
1290
1321
  let decisions = [];
1291
1322
  try {
1292
- decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
1323
+ decisions = reconcileMeetings(toReconcile(rows2), await upcomingEvents(env), Date.now());
1293
1324
  } catch {
1294
1325
  }
1295
1326
  const patched = /* @__PURE__ */ new Map();
@@ -1316,10 +1347,757 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1316
1347
  };
1317
1348
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
1318
1349
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
1319
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1350
+ const meetings = rows2.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1320
1351
  return json({ meetings, adopted: decisions.length, timezone });
1321
1352
  };
1322
1353
 
1354
+ // src/clerk-roles.ts
1355
+ var CLERK_API = "https://api.clerk.com";
1356
+ var DEFAULT_ROLE = "provisional";
1357
+ var PAGE = 100;
1358
+ function toRecord(u) {
1359
+ if (typeof u.id !== "string") return null;
1360
+ const pm = u.public_metadata ?? {};
1361
+ const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
1362
+ const email = u.email_addresses?.[0]?.email_address;
1363
+ return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
1364
+ }
1365
+ async function clerkGet(path, secretKey, fetchImpl) {
1366
+ const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
1367
+ if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
1368
+ return res.json();
1369
+ }
1370
+ async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
1371
+ const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
1372
+ const user = Array.isArray(data) ? data[0] : void 0;
1373
+ return user ? toRecord(user) : null;
1374
+ }
1375
+ async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
1376
+ const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
1377
+ return data ? toRecord(data) : null;
1378
+ }
1379
+ async function clerkListUsers(secretKey, fetchImpl = fetch) {
1380
+ const out = [];
1381
+ for (let offset = 0; ; offset += PAGE) {
1382
+ const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
1383
+ const page = Array.isArray(data) ? data : [];
1384
+ for (const u of page) {
1385
+ const record = toRecord(u);
1386
+ if (record) out.push(record);
1387
+ }
1388
+ if (page.length < PAGE) break;
1389
+ }
1390
+ return out;
1391
+ }
1392
+ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
1393
+ const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
1394
+ method: "PATCH",
1395
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1396
+ body: JSON.stringify({ public_metadata: { role } })
1397
+ });
1398
+ return res.ok;
1399
+ }
1400
+
1401
+ // src/crm-sync.ts
1402
+ import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1403
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1404
+ function personInputFromApp(chapter, app) {
1405
+ const input = sharedPersonInput({
1406
+ email: str(app.email),
1407
+ firstName: str(app.firstName) || void 0,
1408
+ lastName: str(app.lastName) || void 0,
1409
+ phone: str(app.phone) || void 0,
1410
+ linkedin: str(app.linkedin) || void 0,
1411
+ hubRecordId: str(app.id)
1412
+ });
1413
+ for (const f of chapter.application.crmFields) {
1414
+ if (app[f] !== void 0) input[f] = app[f];
1415
+ }
1416
+ if (app.id !== void 0) input.applicationId = str(app.id);
1417
+ return input;
1418
+ }
1419
+ function billingColumns(app) {
1420
+ const status = str(app.status);
1421
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1422
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1423
+ const cols = { billingStatus };
1424
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1425
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1426
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1427
+ return cols;
1428
+ }
1429
+ async function syncApplicationToCrm(deps, opts) {
1430
+ const emailKey = str(opts.app.email).toLowerCase();
1431
+ if (!emailKey) return null;
1432
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1433
+ const input = personInputFromApp(deps.chapter, opts.app);
1434
+ const { crm_record } = await deps.db.query({
1435
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1436
+ });
1437
+ const existing = crm_record?.[0] ?? null;
1438
+ const stage = opts.stage || void 0;
1439
+ let recordId;
1440
+ if (existing && typeof existing.id === "string") {
1441
+ recordId = existing.id;
1442
+ await updateRecord2(crmDeps3, { id: recordId, input });
1443
+ } else {
1444
+ const created = await createRecord2(crmDeps3, { type: "person", input, ...stage ? { stage } : {} });
1445
+ recordId = created.id;
1446
+ }
1447
+ if (existing && stage && existing.stage !== stage) {
1448
+ await setStage(crmDeps3, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1449
+ }
1450
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1451
+ await linkIdentity(crmDeps3, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1452
+ return recordId;
1453
+ }
1454
+ async function backfillCrm(deps) {
1455
+ const [appsRes, usersRes] = await Promise.all([
1456
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1457
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
1458
+ ]);
1459
+ const seen = /* @__PURE__ */ new Set();
1460
+ let synced = 0;
1461
+ const errors = [];
1462
+ const run = async (app, stage) => {
1463
+ const key = str(app.email).toLowerCase();
1464
+ if (!key || seen.has(key)) return;
1465
+ seen.add(key);
1466
+ try {
1467
+ await syncApplicationToCrm(deps, { app, stage });
1468
+ synced += 1;
1469
+ } catch (err) {
1470
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1471
+ }
1472
+ };
1473
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1474
+ for (const u of usersRes.$users ?? []) {
1475
+ if (u.deleted === true) continue;
1476
+ await run({ email: u.email, firstName: str(u.name) });
1477
+ }
1478
+ return { synced, errors };
1479
+ }
1480
+
1481
+ // src/worker-routes-admin-people.ts
1482
+ async function adminGate(req, env, ctx) {
1483
+ const rawDb = ctx.makeDb(env);
1484
+ const u = await ctx.verifyUser(req, env);
1485
+ if (!u) return json({ error: "unauthorized" }, 401);
1486
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1487
+ return { db: rawDb, actor: { userId: u.userId, email: u.email ?? void 0 } };
1488
+ }
1489
+ var crmDeps = (db, ctx) => ({
1490
+ crm: ctx.chapter.crm,
1491
+ db,
1492
+ now: () => Date.now(),
1493
+ newId: () => crypto.randomUUID(),
1494
+ chapter: ctx.chapter
1495
+ });
1496
+ var handleAdminCrmSync = async (req, url, env, ctx) => {
1497
+ if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1498
+ const gate4 = await adminGate(req, env, ctx);
1499
+ if (gate4 instanceof Response) return gate4;
1500
+ const result = await backfillCrm(crmDeps(gate4.db, ctx));
1501
+ return json({ ok: true, ...result });
1502
+ };
1503
+ var handleAdminPeople = async (req, url, env, ctx) => {
1504
+ if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
1505
+ const gate4 = await adminGate(req, env, ctx);
1506
+ if (gate4 instanceof Response) return gate4;
1507
+ const { db } = gate4;
1508
+ const sk = await getVaultSecret(db, "clerk_secret_key");
1509
+ const [appsRes, usersRes, roleList] = await Promise.all([
1510
+ db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
1511
+ db.query({ $users: { $: { limit: 200 } } }),
1512
+ sk ? clerkListUsers(sk).catch(() => []) : Promise.resolve([])
1513
+ ]);
1514
+ const roleByUserId = new Map(roleList.map((u) => [u.id, u.role]));
1515
+ const people = /* @__PURE__ */ new Map();
1516
+ for (const u of usersRes.$users ?? []) {
1517
+ if (u.deleted === true) continue;
1518
+ const email = typeof u.email === "string" ? u.email : "";
1519
+ if (!email) continue;
1520
+ people.set(email.toLowerCase(), {
1521
+ email,
1522
+ name: typeof u.name === "string" ? u.name : "",
1523
+ userId: typeof u.id === "string" ? u.id : null,
1524
+ role: roleByUserId.get(String(u.id)) ?? "provisional",
1525
+ application: null
1526
+ });
1527
+ }
1528
+ for (const a of appsRes.applications ?? []) {
1529
+ const key = String(a.email ?? "").toLowerCase();
1530
+ if (!key) continue;
1531
+ const name = `${a.firstName ?? ""} ${a.lastName ?? ""}`.trim();
1532
+ const row = people.get(key);
1533
+ if (row) {
1534
+ if (!row.application) row.application = applicationSummary(a);
1535
+ if (!row.name) row.name = name;
1536
+ } else {
1537
+ people.set(key, { email: String(a.email), name, userId: null, role: null, application: applicationSummary(a) });
1538
+ }
1539
+ }
1540
+ const rows2 = [...people.values()].sort(
1541
+ (x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
1542
+ );
1543
+ return json({ people: rows2 });
1544
+ };
1545
+ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1546
+ if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
1547
+ const gate4 = await adminGate(req, env, ctx);
1548
+ if (gate4 instanceof Response) return gate4;
1549
+ const { db } = gate4;
1550
+ const targetId = url.searchParams.get("userId") ?? "";
1551
+ if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
1552
+ const sk = await getVaultSecret(db, "clerk_secret_key");
1553
+ if (!sk) return json({ error: "role management unavailable: clerk_secret_key missing from vault" }, 503);
1554
+ const info = await clerkGetUser(sk, targetId);
1555
+ if (!info) return json({ error: "user lookup unavailable" }, 502);
1556
+ return json({ userId: targetId, role: info.role, email: info.email ?? null, superAdmin: await ctx.isSuperAdminEmail(db, info.email) });
1557
+ };
1558
+ var handleAdminPeopleRole = async (req, url, env, ctx) => {
1559
+ if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
1560
+ const gate4 = await adminGate(req, env, ctx);
1561
+ if (gate4 instanceof Response) return gate4;
1562
+ const { db, actor } = gate4;
1563
+ let body;
1564
+ try {
1565
+ body = await req.json();
1566
+ } catch {
1567
+ return json({ error: "invalid JSON body" }, 400);
1568
+ }
1569
+ const targetId = typeof body.userId === "string" ? body.userId : "";
1570
+ const newRole = typeof body.role === "string" ? body.role : "";
1571
+ if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
1572
+ const sk = await getVaultSecret(db, "clerk_secret_key");
1573
+ if (!sk) return json({ error: "role management unavailable: clerk_secret_key missing from vault" }, 503);
1574
+ const target = await clerkGetUser(sk, targetId);
1575
+ const guard = canChangeRole({
1576
+ actorId: actor.userId,
1577
+ actorIsSuper: await ctx.isSuperAdminEmail(db, actor.email),
1578
+ targetId,
1579
+ targetCurrentRole: target?.role ?? "provisional",
1580
+ targetIsSuper: await ctx.isSuperAdminEmail(db, target?.email),
1581
+ newRole,
1582
+ auth: ctx.chapter.auth
1583
+ });
1584
+ if (!guard.ok) return json({ error: guard.error }, guard.status);
1585
+ if (!await clerkSetRole(sk, targetId, newRole)) return json({ error: "role update failed upstream" }, 502);
1586
+ return json({ ok: true });
1587
+ };
1588
+
1589
+ // src/series.ts
1590
+ function bucketSeries(points, now, weeks = 12) {
1591
+ const WEEK = 7 * 864e5;
1592
+ const end = now;
1593
+ const start = end - weeks * WEEK;
1594
+ const buckets = Array.from({ length: weeks }, (_, i) => ({ weekStart: start + i * WEEK, value: 0 }));
1595
+ for (const p of points) {
1596
+ if (!Number.isFinite(p.t) || p.t < start || p.t > end) continue;
1597
+ const idx = Math.min(weeks - 1, Math.floor((p.t - start) / WEEK));
1598
+ const bucket = buckets[idx];
1599
+ if (bucket) bucket.value += Number.isFinite(p.v) ? p.v : 0;
1600
+ }
1601
+ return buckets;
1602
+ }
1603
+ function subAnnualCents(sub) {
1604
+ const items = sub.items?.data ?? [];
1605
+ let cents = 0;
1606
+ for (const it of items) {
1607
+ const price = it.price ?? {};
1608
+ const per = (price.unit_amount ?? 0) * (it.quantity ?? 1);
1609
+ cents += price.recurring?.interval === "month" ? per * 12 : per;
1610
+ }
1611
+ return cents;
1612
+ }
1613
+
1614
+ // src/worker-routes-admin-dashboard.ts
1615
+ async function gate(req, env, ctx) {
1616
+ const rawDb = ctx.makeDb(env);
1617
+ const u = await ctx.verifyUser(req, env);
1618
+ if (!u) return json({ error: "unauthorized" }, 401);
1619
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1620
+ return rawDb;
1621
+ }
1622
+ var rows = (v) => Array.isArray(v) ? v : [];
1623
+ var handleAdminDashboard = async (req, url, env, ctx) => {
1624
+ if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
1625
+ const got = await gate(req, env, ctx);
1626
+ if (got instanceof Response) return got;
1627
+ const db = got;
1628
+ const now = Date.now();
1629
+ const d7 = now - 7 * 864e5;
1630
+ const d30 = now - 30 * 864e5;
1631
+ const [appsRes, meetingsRes, recsRes, groupRes] = await Promise.all([
1632
+ db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1633
+ db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } }),
1634
+ db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
1635
+ db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
1636
+ ]);
1637
+ const apps = rows(appsRes.applications);
1638
+ const applications = {
1639
+ total: apps.length,
1640
+ last7: apps.filter((a) => a.createdAt >= d7).length,
1641
+ last30: apps.filter((a) => a.createdAt >= d30).length
1642
+ };
1643
+ const pipeline = {};
1644
+ const pipelineDelta = {};
1645
+ for (const s of ctx.chapter.pipeline.stages) {
1646
+ pipeline[s] = 0;
1647
+ pipelineDelta[s] = 0;
1648
+ }
1649
+ for (const r of rows(recsRes.crm_record)) {
1650
+ const s = r.stage;
1651
+ if (s in pipeline) {
1652
+ pipeline[s] = (pipeline[s] ?? 0) + 1;
1653
+ const sc = typeof r.stageChangedAt === "number" ? r.stageChangedAt : Date.parse(String(r.stageChangedAt));
1654
+ if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
1655
+ }
1656
+ }
1657
+ const meetings = rows(meetingsRes.meetings);
1658
+ const appById = new Map(apps.map((a) => [a.id, a]));
1659
+ const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
1660
+ const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
1661
+ const agenda = upcoming.slice(0, 8).map((m) => {
1662
+ const a = appById.get(m.applicationId);
1663
+ return {
1664
+ id: m.id,
1665
+ startAt: m.startAt,
1666
+ meetUrl: m.meetUrl ?? null,
1667
+ htmlLink: m.htmlLink ?? null,
1668
+ drift: m.drift ?? "none",
1669
+ name: a ? `${a.firstName} ${a.lastName}` : "(unknown)",
1670
+ email: a?.email ?? null
1671
+ };
1672
+ });
1673
+ const group = groupRes.groups?.[0];
1674
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1675
+ let revenue = { billingReady: false };
1676
+ let revenueSeries = null;
1677
+ let membersSeries = null;
1678
+ const sk = await getVaultSecret(db, "stripe_secret_key");
1679
+ if (sk) {
1680
+ const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
1681
+ if (subsRes.ok) {
1682
+ const subs = rows(subsRes.body.data);
1683
+ const subMs = (s) => (s.created ?? 0) * 1e3;
1684
+ membersSeries = bucketSeries(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
1685
+ revenueSeries = bucketSeries(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
1686
+ const active = subs.filter((s) => s.status === "active");
1687
+ revenue = {
1688
+ billingReady: true,
1689
+ testMode: String(group?.stripePublishableKey ?? "").startsWith("pk_test"),
1690
+ activeCount: active.length,
1691
+ annualRunRateCents: active.reduce((sum, s) => sum + subAnnualCents(s), 0),
1692
+ newPaid7: subs.filter((s) => subMs(s) >= d7).length,
1693
+ newPaid30: subs.filter((s) => subMs(s) >= d30).length
1694
+ };
1695
+ }
1696
+ }
1697
+ const applicationsSeries = bucketSeries(apps.map((a) => ({ t: a.createdAt || 0, v: 1 })), now);
1698
+ return json({ applications, applicationsSeries, pipeline, pipelineDelta, calls, agenda, timezone, revenue, revenueSeries, membersSeries });
1699
+ };
1700
+ var handleAdminBilling = async (req, url, env, ctx) => {
1701
+ if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
1702
+ const got = await gate(req, env, ctx);
1703
+ if (got instanceof Response) return got;
1704
+ const db = got;
1705
+ const sk = await getVaultSecret(db, "stripe_secret_key");
1706
+ if (!sk) return json({ billingReady: false, rows: [], summary: null });
1707
+ const [appsRes, subsRes, groupRes] = await Promise.all([
1708
+ db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
1709
+ stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" }),
1710
+ db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
1711
+ ]);
1712
+ const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
1713
+ if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
1714
+ const subById = new Map(rows(subsRes.body.data).map((s) => [s.id, s]));
1715
+ const billingRows = [];
1716
+ for (const a of rows(appsRes.applications)) {
1717
+ if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
1718
+ const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
1719
+ const items = sub?.items?.data ?? [];
1720
+ let amountCents = 0;
1721
+ let interval = "year";
1722
+ for (const it of items) {
1723
+ amountCents += (it.price?.unit_amount ?? 0) * (it.quantity ?? 1);
1724
+ interval = it.price?.recurring?.interval ?? interval;
1725
+ }
1726
+ const periodEnd = sub?.current_period_end ?? items[0]?.current_period_end;
1727
+ billingRows.push({
1728
+ id: a.id,
1729
+ name: `${a.firstName} ${a.lastName}`,
1730
+ email: a.email,
1731
+ applicationStatus: a.status,
1732
+ subscriptionStatus: sub?.status ?? null,
1733
+ cancelAtPeriodEnd: sub?.cancel_at_period_end === true,
1734
+ amountCents,
1735
+ interval,
1736
+ renewalAt: periodEnd ? periodEnd * 1e3 : a.renewalAt ?? null
1737
+ });
1738
+ }
1739
+ const renewing = billingRows.filter((r) => r.subscriptionStatus === "active" && !r.cancelAtPeriodEnd);
1740
+ const soonCutoff = Date.now() + 60 * 864e5;
1741
+ const summary = {
1742
+ activeCount: billingRows.filter((r) => r.subscriptionStatus === "active").length,
1743
+ annualizedCents: renewing.reduce((s, r) => s + r.amountCents * (r.interval === "month" ? 12 : 1), 0),
1744
+ renewingSoonCount: renewing.filter((r) => r.renewalAt && r.renewalAt < soonCutoff).length,
1745
+ pastDueCount: billingRows.filter((r) => r.subscriptionStatus === "past_due").length,
1746
+ canceledCount: billingRows.filter((r) => r.subscriptionStatus === "canceled" || r.cancelAtPeriodEnd).length,
1747
+ refundedCount: billingRows.filter((r) => r.applicationStatus === "refunded").length
1748
+ };
1749
+ return json({
1750
+ billingReady: true,
1751
+ testMode,
1752
+ truncated: subsRes.body.has_more === true,
1753
+ rows: billingRows,
1754
+ summary
1755
+ });
1756
+ };
1757
+
1758
+ // src/worker-routes-admin-lifecycle.ts
1759
+ import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
1760
+ async function gate2(req, env, ctx) {
1761
+ const rawDb = ctx.makeDb(env);
1762
+ const u = await ctx.verifyUser(req, env);
1763
+ if (!u) return json({ error: "unauthorized" }, 401);
1764
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1765
+ return rawDb;
1766
+ }
1767
+ var calFor = (env) => initCalendar3({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1768
+ var crmDeps2 = (db, ctx) => ({ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID(), chapter: ctx.chapter });
1769
+ var readJson = async (req) => {
1770
+ try {
1771
+ return await req.json();
1772
+ } catch {
1773
+ return null;
1774
+ }
1775
+ };
1776
+ async function loadApp(db, id) {
1777
+ const { applications } = await db.query({ applications: { $: { where: { id }, limit: 1 } } });
1778
+ return applications?.[0] ?? null;
1779
+ }
1780
+ async function loadGroup(db, id) {
1781
+ const { groups } = await db.query({ groups: { $: { where: { id }, limit: 1 } } });
1782
+ return groups?.[0] ?? null;
1783
+ }
1784
+ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
1785
+ const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
1786
+ if (req.method !== "POST" || !m) return null;
1787
+ const got = await gate2(req, env, ctx);
1788
+ if (got instanceof Response) return got;
1789
+ const db = got;
1790
+ const body = await readJson(req);
1791
+ const startAt = Number(body?.startAt);
1792
+ if (!Number.isFinite(startAt)) return json({ error: "startAt required" }, 400);
1793
+ const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
1794
+ const meeting = meetings?.[0];
1795
+ if (!meeting) return json({ error: "not found" }, 404);
1796
+ if (meeting.status !== "scheduled") return json({ error: "meeting is cancelled" }, 409);
1797
+ if (!meeting.googleEventId) return json({ error: "no calendar event on file" }, 409);
1798
+ const cfg = resolveScheduling((await loadGroup(db, String(meeting.groupId ?? ctx.chapter.id)))?.schedulingJson);
1799
+ const endAt = endForSlot(startAt, cfg.slotMinutes);
1800
+ const cal = calFor(env);
1801
+ try {
1802
+ const { from, to } = slotWindow(Date.now(), cfg.windowDays);
1803
+ const fb = await cal.availability.freeBusy({ timeMin: from, timeMax: to });
1804
+ const slots = computeBookableSlots2(fb.busy, {
1805
+ from: fb.timeMin,
1806
+ to: fb.timeMax,
1807
+ timezone: cfg.timezone,
1808
+ slotMinutes: cfg.slotMinutes,
1809
+ businessHours: { days: [...cfg.days], startHour: cfg.startHour, endHour: cfg.endHour },
1810
+ minNoticeMs: cfg.minNoticeHours * 36e5
1811
+ });
1812
+ if (!isSlotAvailable(slots, startAt)) return json({ error: "slot no longer available", code: "calendar_slot_unavailable" }, 409);
1813
+ await cal.actions.reschedule(String(meeting.googleEventId), { startAt, endAt });
1814
+ } catch {
1815
+ return json({ error: "reschedule failed upstream" }, 502);
1816
+ }
1817
+ await db.transact([{ t: "update", ns: "meetings", id: String(meeting.id), attrs: meetingRescheduleUpdate(startAt, endAt) }]);
1818
+ await db.transact([{ t: "update", ns: "applications", id: String(meeting.applicationId), attrs: { meetingAt: startAt } }]);
1819
+ return json({ ok: true, startAt, endAt });
1820
+ };
1821
+ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
1822
+ const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
1823
+ if (req.method !== "POST" || !m) return null;
1824
+ const got = await gate2(req, env, ctx);
1825
+ if (got instanceof Response) return got;
1826
+ const db = got;
1827
+ const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
1828
+ const meeting = meetings?.[0];
1829
+ if (!meeting) return json({ error: "not found" }, 404);
1830
+ if (meeting.status !== "scheduled") return json({ error: "already cancelled" }, 409);
1831
+ if (meeting.googleEventId) {
1832
+ try {
1833
+ await calFor(env).actions.cancel(String(meeting.googleEventId));
1834
+ } catch {
1835
+ return json({ error: "cancel failed upstream" }, 502);
1836
+ }
1837
+ }
1838
+ await db.transact([{ t: "update", ns: "meetings", id: String(meeting.id), attrs: { status: "cancelled", drift: "none" } }]);
1839
+ await db.transact([{ t: "update", ns: "applications", id: String(meeting.applicationId), attrs: { meetingAt: 0, meetingLink: "" } }]);
1840
+ return json({ ok: true });
1841
+ };
1842
+ var handleAdminApprove = async (req, url, env, ctx) => {
1843
+ const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
1844
+ if (req.method !== "POST" || !m) return null;
1845
+ const got = await gate2(req, env, ctx);
1846
+ if (got instanceof Response) return got;
1847
+ const db = got;
1848
+ const id = m[1];
1849
+ const app = await loadApp(db, id);
1850
+ if (!app) return json({ error: "not found" }, 404);
1851
+ if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
1852
+ const target = "approved";
1853
+ await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
1854
+ await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
1855
+ const { promoteTo, send } = ctx.chapter.operations.onApprove;
1856
+ let rolePromoted = false;
1857
+ const sk = await getVaultSecret(db, "clerk_secret_key");
1858
+ if (promoteTo !== false && sk) {
1859
+ let userId = app.clerkUserId || null;
1860
+ if (!userId) {
1861
+ const found = await clerkGetUserByEmail(sk, String(app.email));
1862
+ userId = found?.id ?? null;
1863
+ if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
1864
+ }
1865
+ if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
1866
+ }
1867
+ let emailLogged = false;
1868
+ const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
1869
+ if (send !== false && group) {
1870
+ const res = await sendTemplated(
1871
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1872
+ { group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
1873
+ );
1874
+ emailLogged = res.sent;
1875
+ }
1876
+ return json({ ok: true, status: target, rolePromoted, emailLogged });
1877
+ };
1878
+ var handleAdminRefund = async (req, url, env, ctx) => {
1879
+ const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
1880
+ if (req.method !== "POST" || !m) return null;
1881
+ const got = await gate2(req, env, ctx);
1882
+ if (got instanceof Response) return got;
1883
+ const db = got;
1884
+ const app = await loadApp(db, m[1]);
1885
+ if (!app) return json({ error: "not found" }, 404);
1886
+ if (app.status === "refunded") return json({ error: "already refunded" }, 409);
1887
+ const allowedFrom = ctx.chapter.operations.refund.allowedFrom;
1888
+ if (allowedFrom && !allowedFrom.includes(String(app.status))) {
1889
+ return json({ error: `cannot refund from status "${String(app.status)}"` }, 409);
1890
+ }
1891
+ const subscriptionId = app.stripeSubscriptionId;
1892
+ const customerId = app.stripeCustomerId;
1893
+ if (!customerId) return json({ error: "no customer on file" }, 409);
1894
+ const sk = await getVaultSecret(db, "stripe_secret_key");
1895
+ if (!sk) return json({ error: "payments not configured" }, 503);
1896
+ const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: customerId, limit: 100 });
1897
+ if (!charges.ok) return json({ error: "refund failed upstream" }, 502);
1898
+ const succeeded = (charges.body.data ?? []).filter((c) => c.status === "succeeded" && c.refunded !== true);
1899
+ const firstCharge = succeeded[succeeded.length - 1];
1900
+ if (!firstCharge) return json({ error: "no paid charge to refund" }, 409);
1901
+ const refund = await stripeCall(sk, "POST", "/v1/refunds", { charge: String(firstCharge.id) });
1902
+ if (!refund.ok) return json({ error: "refund failed upstream" }, 502);
1903
+ let subscriptionCanceled = false;
1904
+ if (ctx.chapter.operations.refund.cancelSubscription && subscriptionId) {
1905
+ const cancel = await stripeCall(sk, "DELETE", `/v1/subscriptions/${subscriptionId}`);
1906
+ subscriptionCanceled = cancel.ok;
1907
+ }
1908
+ return json({ ok: true, refundedCents: refund.body.amount ?? null, subscriptionCanceled });
1909
+ };
1910
+ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
1911
+ const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
1912
+ if (req.method !== "PATCH" || !m) return null;
1913
+ const got = await gate2(req, env, ctx);
1914
+ if (got instanceof Response) return got;
1915
+ const db = got;
1916
+ const id = m[1];
1917
+ const body = await readJson(req);
1918
+ if (!body) return json({ error: "invalid JSON body" }, 400);
1919
+ const app = await loadApp(db, id);
1920
+ if (!app) return json({ error: "not found" }, 404);
1921
+ const attrs = {};
1922
+ if (body.status !== void 0) {
1923
+ const to = String(body.status);
1924
+ if (!ctx.chapter.pipeline.stages.includes(to)) return json({ error: `status must be one of: ${ctx.chapter.pipeline.stages.join(", ")}` }, 400);
1925
+ if (!canTransition(String(app.status), to, ctx.chapter.pipeline)) return json({ error: `cannot move from "${String(app.status)}" to "${to}"` }, 409);
1926
+ attrs.status = to;
1927
+ }
1928
+ if (body.meetingAt !== void 0) {
1929
+ if (typeof body.meetingAt !== "number" || !Number.isFinite(body.meetingAt)) return json({ error: "meetingAt must be epoch milliseconds" }, 400);
1930
+ attrs.meetingAt = body.meetingAt;
1931
+ }
1932
+ if (Object.keys(attrs).length === 0) return json({ error: "nothing to update" }, 400);
1933
+ await db.transact([{ t: "update", ns: "applications", id, attrs }]);
1934
+ if (attrs.status !== void 0) {
1935
+ await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
1936
+ }
1937
+ return json({ ok: true });
1938
+ };
1939
+
1940
+ // src/worker-routes-admin-comms.ts
1941
+ async function gate3(req, env, ctx) {
1942
+ const rawDb = ctx.makeDb(env);
1943
+ const u = await ctx.verifyUser(req, env);
1944
+ if (!u) return json({ error: "unauthorized" }, 401);
1945
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1946
+ return rawDb;
1947
+ }
1948
+ async function loadGroup2(db, id) {
1949
+ const { groups } = await db.query({ groups: { $: { where: { id }, limit: 1 } } });
1950
+ return groups?.[0] ?? null;
1951
+ }
1952
+ var str2 = (v, d = "") => typeof v === "string" ? v : d;
1953
+ var notifyDeps = (db, env) => ({
1954
+ db,
1955
+ envName: env.ODLA_ENV,
1956
+ sender: env.SEND_EMAIL,
1957
+ from: env.EMAIL_FROM,
1958
+ now: () => Date.now(),
1959
+ newId: () => crypto.randomUUID()
1960
+ });
1961
+ var handleAdminGroupEmail = async (req, url, env, ctx) => {
1962
+ if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
1963
+ const got = await gate3(req, env, ctx);
1964
+ if (got instanceof Response) return got;
1965
+ const db = got;
1966
+ const group = await loadGroup2(db, ctx.chapter.id);
1967
+ if (!group) return json({ error: "not found" }, 404);
1968
+ if (req.method === "GET") {
1969
+ const stored = group.emailTemplates ?? {};
1970
+ const emailTemplates = {};
1971
+ for (const key of EMAIL_TEMPLATE_NAMES) {
1972
+ const t = stored[key];
1973
+ if (t) emailTemplates[key] = { subject: str2(t.subject), text: str2(t.text), enabled: t.enabled !== false };
1974
+ }
1975
+ return json({
1976
+ groupId: group.id,
1977
+ name: group.name,
1978
+ replyTo: str2(group.replyTo),
1979
+ notificationEmail: str2(group.notificationEmail),
1980
+ debugEmail: str2(group.debugEmail),
1981
+ emailTemplates,
1982
+ commitmentText: str2(group.commitmentText),
1983
+ normsText: str2(group.normsText),
1984
+ refundPolicyText: str2(group.refundPolicyText),
1985
+ // Read-only delivery wiring — surfaced so "why did this not send?" is
1986
+ // answerable without logs.
1987
+ envName: env.ODLA_ENV,
1988
+ transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
1989
+ fromEmail: env.EMAIL_FROM ?? null
1990
+ });
1991
+ }
1992
+ let body;
1993
+ try {
1994
+ body = await req.json();
1995
+ } catch {
1996
+ return json({ error: "invalid JSON body" }, 400);
1997
+ }
1998
+ const templates = body.emailTemplates;
1999
+ if (!templates || typeof templates !== "object") return json({ error: "emailTemplates object required" }, 400);
2000
+ const clean = {};
2001
+ for (const key of EMAIL_TEMPLATE_NAMES) {
2002
+ const t = templates[key];
2003
+ const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
2004
+ const text = typeof t?.text === "string" ? t.text : "";
2005
+ if (!subject || !text.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
2006
+ if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
2007
+ if (text.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
2008
+ clean[key] = { subject, text, enabled: t?.enabled !== false };
2009
+ }
2010
+ const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2011
+ const notificationEmail = str2(body.notificationEmail).trim();
2012
+ const replyTo = str2(body.replyTo).trim();
2013
+ const debugEmail = str2(body.debugEmail).trim();
2014
+ if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
2015
+ if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
2016
+ if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
2017
+ const commitmentText = str2(body.commitmentText);
2018
+ const normsText = str2(body.normsText);
2019
+ if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
2020
+ await db.transact([{ t: "update", ns: "groups", id: ctx.chapter.id, attrs: { emailTemplates: clean, commitmentText, normsText, notificationEmail, replyTo, debugEmail } }]);
2021
+ return json({ ok: true });
2022
+ };
2023
+ var handleAdminEmailLog = async (req, url, env, ctx) => {
2024
+ if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
2025
+ const got = await gate3(req, env, ctx);
2026
+ if (got instanceof Response) return got;
2027
+ const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
2028
+ const sends = (emailLog ?? []).map((r) => ({
2029
+ id: r.id,
2030
+ template: r.template,
2031
+ to: r.to,
2032
+ subject: r.subject,
2033
+ transport: r.transport,
2034
+ redirected: r.redirected === true,
2035
+ error: r.error ?? null,
2036
+ sentAt: r.sentAt
2037
+ }));
2038
+ return json({ sends });
2039
+ };
2040
+ var handleAdminEmailTest = async (req, url, env, ctx) => {
2041
+ if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
2042
+ const got = await gate3(req, env, ctx);
2043
+ if (got instanceof Response) return got;
2044
+ const db = got;
2045
+ let body;
2046
+ try {
2047
+ body = await req.json();
2048
+ } catch {
2049
+ return json({ error: "invalid JSON body" }, 400);
2050
+ }
2051
+ const template = str2(body.template);
2052
+ if (!EMAIL_TEMPLATE_NAMES.includes(template)) {
2053
+ return json({ error: `template must be one of: ${EMAIL_TEMPLATE_NAMES.join(", ")}` }, 400);
2054
+ }
2055
+ const group = await loadGroup2(db, ctx.chapter.id);
2056
+ if (!group) return json({ error: "not found" }, 404);
2057
+ const res = await sendTemplated(notifyDeps(db, env), {
2058
+ group: emailGroupFrom(group),
2059
+ template,
2060
+ to: str2(group.notificationEmail),
2061
+ vars: { firstName: "Sample", lastName: "Person", email: "sample@example.com", phone: "(555) 010-0100", state: "CA", adminUrl: `${url.origin}/admin/`, membersUrl: `${url.origin}/members/` },
2062
+ dedupeKey: `test:${template}:${Date.now()}`,
2063
+ force: true
2064
+ });
2065
+ return json({ ok: res.sent, reason: res.reason ?? null, to: str2(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
2066
+ };
2067
+ var handleAdminComms = async (req, url, env, ctx) => {
2068
+ const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
2069
+ if (req.method !== "GET" || !m) return null;
2070
+ const got = await gate3(req, env, ctx);
2071
+ if (got instanceof Response) return got;
2072
+ const db = got;
2073
+ const appId = m[1];
2074
+ const [emailRes, meetingRes, appRes] = await Promise.all([
2075
+ db.query({ emailLog: { $: { where: { applicationId: appId }, order: { sentAt: "desc" }, limit: 100 } } }),
2076
+ db.query({ meetings: { $: { where: { applicationId: appId } } } }),
2077
+ db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })
2078
+ ]);
2079
+ const app = appRes.applications?.[0] ?? null;
2080
+ const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
2081
+ const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: `${url.origin}/admin/`, membersUrl: `${url.origin}/members/` } : null;
2082
+ const emails = (emailRes.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
2083
+ let mailBody = typeof r.body === "string" ? r.body : null;
2084
+ if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
2085
+ const channel = r.error ? "email (failed)" : r.redirected ? "email (dev-redirected)" : r.transport === "log-only" ? "email (not delivered)" : "email";
2086
+ 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 };
2087
+ });
2088
+ const calendar = (meetingRes.meetings ?? []).map((m2) => ({
2089
+ kind: "calendar",
2090
+ channel: "Google Calendar",
2091
+ label: m2.status === "cancelled" ? "Invitation (call later cancelled)" : "Meeting invitation",
2092
+ subject: "Introduction call invitation",
2093
+ to: null,
2094
+ at: m2.createdAt ?? m2.startAt,
2095
+ error: null
2096
+ }));
2097
+ const items = [...emails, ...calendar].sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
2098
+ return json({ items });
2099
+ };
2100
+
1323
2101
  // src/worker.ts
1324
2102
  var BUILTIN_ROUTES = [
1325
2103
  handleHealth,
@@ -1331,7 +2109,26 @@ var BUILTIN_ROUTES = [
1331
2109
  handleSchedule,
1332
2110
  handlePayments,
1333
2111
  handleAdminMeetings,
1334
- handleAdminScheduling
2112
+ handleAdminScheduling,
2113
+ // Roster + identity
2114
+ handleAdminPeople,
2115
+ handleAdminPeopleAccess,
2116
+ handleAdminPeopleRole,
2117
+ handleAdminCrmSync,
2118
+ // Aggregation
2119
+ handleAdminDashboard,
2120
+ handleAdminBilling,
2121
+ // Lifecycle actions
2122
+ handleAdminMeetingReschedule,
2123
+ handleAdminMeetingCancel,
2124
+ handleAdminApprove,
2125
+ handleAdminRefund,
2126
+ handleAdminApplicationPatch,
2127
+ // Email + comms
2128
+ handleAdminGroupEmail,
2129
+ handleAdminEmailLog,
2130
+ handleAdminEmailTest,
2131
+ handleAdminComms
1335
2132
  ];
1336
2133
  function chapterWorker(options) {
1337
2134
  const ctx = createWorkerContext(options);