@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/README.md
CHANGED
|
@@ -235,22 +235,37 @@ These bite silently — a smoke test won't catch them:
|
|
|
235
235
|
- **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
|
|
236
236
|
the peer conflict that tells you a pair is unsupported. If you need it, find out
|
|
237
237
|
why first.
|
|
238
|
-
- **The admin surface
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
238
|
+
- **The admin operational surface ships (0.16.0).** `/api/admin/*` is now a full
|
|
239
|
+
membership-operations API, admin-gated (`verifyUser` + `isAdmin`):
|
|
240
|
+
- **Roster/identity:** `GET /people` (union of `$users` + applications by email,
|
|
241
|
+
roles from `clerkListUsers`), `GET /people/access`, `POST /people/role`,
|
|
242
|
+
`POST /crm/sync` (backfill/reproject).
|
|
243
|
+
- **Pipeline/meetings:** `GET /dashboard` (flow counts, stage counts + weekly
|
|
244
|
+
delta, agenda, live revenue), `GET /meetings` (reconciled agenda),
|
|
245
|
+
`GET/PUT /scheduling`, `POST /meetings/:id/{reschedule,cancel}`,
|
|
246
|
+
`PATCH /applications/:id`.
|
|
247
|
+
- **Money:** `GET /billing` (applications ⋈ live Stripe subs; `truncated` flags a
|
|
248
|
+
>100 page instead of losing rows; `billingReady:false` when no Stripe key is
|
|
249
|
+
vaulted), `POST /applications/:id/{approve,refund}`.
|
|
250
|
+
- **Email:** `GET/PUT /group/email`, `GET /email/log`, `POST /email/test`,
|
|
251
|
+
`GET /people/:id/comms` (sent mail + reconstructed calendar invitations).
|
|
252
|
+
|
|
253
|
+
Two behaviours are **config, not code** — the mechanics are chapter's, the
|
|
254
|
+
decisions are yours (same model as `sends`):
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
defineChapter({ operations: {
|
|
258
|
+
onApprove: { promoteTo: "member", send: "onboardingInvite" }, // promoteTo defaults to the rung below admin; either can be false
|
|
259
|
+
refund: { allowedFrom: ["paid_pending_vetting"], cancelSubscription: true }, // allowedFrom validated against the pipeline
|
|
260
|
+
}});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The escalation rules (super-admin tier, self-demotion lockout) are **not** seams
|
|
264
|
+
— `canChangeRole` package-enforces them so a site can't weaken them. `approve`
|
|
265
|
+
is the caller `onboardingInvite` was missing.
|
|
266
|
+
- **The server-side Clerk primitives are exported too** — for host routes beyond
|
|
267
|
+
the built-in `/people/role`. Beside
|
|
268
|
+
`createClerkUser`/`createClerkInvitation`/`canChangeRole`, chapter exports
|
|
254
269
|
the odla→Clerk write half: `clerkGetUserByEmail`, `clerkGetUser`, `clerkListUsers`
|
|
255
270
|
(auto-paginated — never a silent 100-user cap), and `clerkSetRole`. Two
|
|
256
271
|
load-bearing semantics: an absent `public_metadata.role` reads as the lowest rung
|
package/dist/index.cjs
CHANGED
|
@@ -24,8 +24,10 @@ __export(index_exports, {
|
|
|
24
24
|
applicantProfile: () => applicantProfile,
|
|
25
25
|
applicationBookingUpdate: () => applicationBookingUpdate,
|
|
26
26
|
applicationSummary: () => applicationSummary,
|
|
27
|
+
backfillCrm: () => backfillCrm,
|
|
27
28
|
bookingDecision: () => bookingDecision,
|
|
28
29
|
brandTokens: () => brandTokens,
|
|
30
|
+
bucketSeries: () => bucketSeries,
|
|
29
31
|
buildGroupSeed: () => buildGroupSeed,
|
|
30
32
|
canApprove: () => canApprove,
|
|
31
33
|
canBook: () => canBook,
|
|
@@ -65,6 +67,7 @@ __export(index_exports, {
|
|
|
65
67
|
memberSession: () => memberSession,
|
|
66
68
|
normalizeWebhookEvent: () => normalizeWebhookEvent,
|
|
67
69
|
paymentsReady: () => paymentsReady,
|
|
70
|
+
personInputFromApp: () => personInputFromApp,
|
|
68
71
|
planDelivery: () => planDelivery,
|
|
69
72
|
projectApplicant: () => projectApplicant,
|
|
70
73
|
projectSharedRecord: () => projectSharedRecord,
|
|
@@ -83,9 +86,12 @@ __export(index_exports, {
|
|
|
83
86
|
sharedPersonInput: () => sharedPersonInput,
|
|
84
87
|
slotWindow: () => slotWindow,
|
|
85
88
|
stageIndex: () => stageIndex,
|
|
89
|
+
stripeCall: () => stripeCall,
|
|
86
90
|
stripeForm: () => stripeForm,
|
|
91
|
+
subAnnualCents: () => subAnnualCents,
|
|
87
92
|
submitApplication: () => submitApplication,
|
|
88
93
|
subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
|
|
94
|
+
syncApplicationToCrm: () => syncApplicationToCrm,
|
|
89
95
|
validateScheduling: () => validateScheduling,
|
|
90
96
|
verifyStripeSignature: () => verifyStripeSignature,
|
|
91
97
|
webhookMutationId: () => webhookMutationId
|
|
@@ -631,6 +637,27 @@ function defineChapter(config) {
|
|
|
631
637
|
);
|
|
632
638
|
}
|
|
633
639
|
const sends = { adminNotification };
|
|
640
|
+
const onApproveCfg = config.operations?.onApprove ?? {};
|
|
641
|
+
const belowAdmin = auth.ladder[auth.ladder.length - 2] ?? auth.adminRole;
|
|
642
|
+
const promoteTo = onApproveCfg.promoteTo === void 0 ? belowAdmin : onApproveCfg.promoteTo;
|
|
643
|
+
if (promoteTo !== false && !auth.ladder.includes(promoteTo)) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`defineChapter.operations.onApprove.promoteTo: "${String(promoteTo)}" is not in the auth ladder (${auth.ladder.join(", ")})`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const refundCfg = config.operations?.refund ?? {};
|
|
649
|
+
if (refundCfg.allowedFrom !== void 0) {
|
|
650
|
+
const unknown = refundCfg.allowedFrom.filter((s) => !pipeline.stages.includes(s));
|
|
651
|
+
if (unknown.length > 0) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`defineChapter.operations.refund.allowedFrom: ${unknown.map((s) => `"${s}"`).join(", ")} not in the pipeline (${pipeline.stages.join(", ")})`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const operations = {
|
|
658
|
+
onApprove: { promoteTo, send: onApproveCfg.send === void 0 ? "onboardingInvite" : onApproveCfg.send },
|
|
659
|
+
refund: { allowedFrom: refundCfg.allowedFrom ?? null, cancelSubscription: refundCfg.cancelSubscription ?? true }
|
|
660
|
+
};
|
|
634
661
|
const chapter = {
|
|
635
662
|
config,
|
|
636
663
|
id: id2,
|
|
@@ -645,6 +672,7 @@ function defineChapter(config) {
|
|
|
645
672
|
services,
|
|
646
673
|
account,
|
|
647
674
|
sends,
|
|
675
|
+
operations,
|
|
648
676
|
groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
|
|
649
677
|
};
|
|
650
678
|
if (config.url !== void 0) chapter.url = config.url;
|
|
@@ -771,16 +799,16 @@ async function sendTemplated(deps, input) {
|
|
|
771
799
|
return error ? { sent: false, reason: error } : { sent: true };
|
|
772
800
|
}
|
|
773
801
|
function emailGroupFrom(row) {
|
|
774
|
-
const
|
|
802
|
+
const str2 = (v) => typeof v === "string" ? v : void 0;
|
|
775
803
|
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
776
804
|
return {
|
|
777
805
|
id: String(row.id),
|
|
778
806
|
name: String(row.name ?? ""),
|
|
779
|
-
replyTo:
|
|
780
|
-
debugEmail:
|
|
781
|
-
refundPolicyText:
|
|
782
|
-
commitmentText:
|
|
783
|
-
normsText:
|
|
807
|
+
replyTo: str2(row.replyTo) ?? "",
|
|
808
|
+
debugEmail: str2(row.debugEmail),
|
|
809
|
+
refundPolicyText: str2(row.refundPolicyText),
|
|
810
|
+
commitmentText: str2(row.commitmentText),
|
|
811
|
+
normsText: str2(row.normsText),
|
|
784
812
|
emailTemplates: templates
|
|
785
813
|
};
|
|
786
814
|
}
|
|
@@ -928,6 +956,126 @@ async function projectApplicant(deps, applicant) {
|
|
|
928
956
|
}
|
|
929
957
|
}
|
|
930
958
|
|
|
959
|
+
// src/crm-sync.ts
|
|
960
|
+
var import_crm4 = require("@odla-ai/crm");
|
|
961
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
962
|
+
function personInputFromApp(chapter, app) {
|
|
963
|
+
const input = sharedPersonInput({
|
|
964
|
+
email: str(app.email),
|
|
965
|
+
firstName: str(app.firstName) || void 0,
|
|
966
|
+
lastName: str(app.lastName) || void 0,
|
|
967
|
+
phone: str(app.phone) || void 0,
|
|
968
|
+
linkedin: str(app.linkedin) || void 0,
|
|
969
|
+
hubRecordId: str(app.id)
|
|
970
|
+
});
|
|
971
|
+
for (const f of chapter.application.crmFields) {
|
|
972
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
973
|
+
}
|
|
974
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
975
|
+
return input;
|
|
976
|
+
}
|
|
977
|
+
function billingColumns(app) {
|
|
978
|
+
const status = str(app.status);
|
|
979
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
980
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
981
|
+
const cols = { billingStatus };
|
|
982
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
983
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
984
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
985
|
+
return cols;
|
|
986
|
+
}
|
|
987
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
988
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
989
|
+
if (!emailKey) return null;
|
|
990
|
+
const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
991
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
992
|
+
const { crm_record } = await deps.db.query({
|
|
993
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
994
|
+
});
|
|
995
|
+
const existing = crm_record?.[0] ?? null;
|
|
996
|
+
const stage = opts.stage || void 0;
|
|
997
|
+
let recordId;
|
|
998
|
+
if (existing && typeof existing.id === "string") {
|
|
999
|
+
recordId = existing.id;
|
|
1000
|
+
await (0, import_crm4.updateRecord)(crmDeps, { id: recordId, input });
|
|
1001
|
+
} else {
|
|
1002
|
+
const created = await (0, import_crm4.createRecord)(crmDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1003
|
+
recordId = created.id;
|
|
1004
|
+
}
|
|
1005
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1006
|
+
await (0, import_crm4.setStage)(crmDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1007
|
+
}
|
|
1008
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1009
|
+
await (0, import_crm4.linkIdentity)(crmDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1010
|
+
return recordId;
|
|
1011
|
+
}
|
|
1012
|
+
async function backfillCrm(deps) {
|
|
1013
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1014
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1015
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1016
|
+
]);
|
|
1017
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1018
|
+
let synced = 0;
|
|
1019
|
+
const errors = [];
|
|
1020
|
+
const run = async (app, stage) => {
|
|
1021
|
+
const key = str(app.email).toLowerCase();
|
|
1022
|
+
if (!key || seen.has(key)) return;
|
|
1023
|
+
seen.add(key);
|
|
1024
|
+
try {
|
|
1025
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1026
|
+
synced += 1;
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1032
|
+
for (const u of usersRes.$users ?? []) {
|
|
1033
|
+
if (u.deleted === true) continue;
|
|
1034
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1035
|
+
}
|
|
1036
|
+
return { synced, errors };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/series.ts
|
|
1040
|
+
function bucketSeries(points, now, weeks = 12) {
|
|
1041
|
+
const WEEK = 7 * 864e5;
|
|
1042
|
+
const end = now;
|
|
1043
|
+
const start = end - weeks * WEEK;
|
|
1044
|
+
const buckets = Array.from({ length: weeks }, (_, i) => ({ weekStart: start + i * WEEK, value: 0 }));
|
|
1045
|
+
for (const p of points) {
|
|
1046
|
+
if (!Number.isFinite(p.t) || p.t < start || p.t > end) continue;
|
|
1047
|
+
const idx = Math.min(weeks - 1, Math.floor((p.t - start) / WEEK));
|
|
1048
|
+
const bucket = buckets[idx];
|
|
1049
|
+
if (bucket) bucket.value += Number.isFinite(p.v) ? p.v : 0;
|
|
1050
|
+
}
|
|
1051
|
+
return buckets;
|
|
1052
|
+
}
|
|
1053
|
+
function subAnnualCents(sub) {
|
|
1054
|
+
const items = sub.items?.data ?? [];
|
|
1055
|
+
let cents = 0;
|
|
1056
|
+
for (const it of items) {
|
|
1057
|
+
const price = it.price ?? {};
|
|
1058
|
+
const per = (price.unit_amount ?? 0) * (it.quantity ?? 1);
|
|
1059
|
+
cents += price.recurring?.interval === "month" ? per * 12 : per;
|
|
1060
|
+
}
|
|
1061
|
+
return cents;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/payments-stripe.ts
|
|
1065
|
+
async function stripeCall(sk, method, path, params, idempotencyKey) {
|
|
1066
|
+
const qs = method === "GET" && params ? `?${stripeForm(params)}` : "";
|
|
1067
|
+
const headers = { authorization: `Bearer ${sk}` };
|
|
1068
|
+
if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
|
|
1069
|
+
const init = { method, headers };
|
|
1070
|
+
if (method === "POST" && params) {
|
|
1071
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
1072
|
+
init.body = stripeForm(params);
|
|
1073
|
+
}
|
|
1074
|
+
const res = await fetch(`https://api.stripe.com${path}${qs}`, init);
|
|
1075
|
+
const body = await res.json().catch(() => ({}));
|
|
1076
|
+
return { ok: res.ok, status: res.status, body };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
931
1079
|
// src/clerk.ts
|
|
932
1080
|
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
933
1081
|
function clerkInviteRequest(input) {
|