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