@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.js
CHANGED
|
@@ -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
|
|
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(
|
|
267
|
+
await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
|
|
251
268
|
return { recordId: existing.id };
|
|
252
269
|
}
|
|
253
|
-
const created = await createRecord(
|
|
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
|
|
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:
|
|
506
|
-
debugEmail:
|
|
507
|
-
refundPolicyText:
|
|
508
|
-
commitmentText:
|
|
509
|
-
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
|
|
715
|
-
return Array.isArray(
|
|
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
|
|
1009
|
-
const paid =
|
|
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
|
|
1030
|
-
return Array.isArray(
|
|
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(
|
|
1266
|
-
return
|
|
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
|
|
1288
|
-
if (Number.isFinite(from))
|
|
1289
|
-
if (Number.isFinite(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(
|
|
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 =
|
|
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);
|