@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 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 is intentionally minimal, and staying that way.** chapter
239
- ships `/api/admin/scheduling` (booking rules, with field-keyed validation
240
- messages) and `/api/admin/meetings` (the reconciled agenda, with the applicant
241
- joined, `?all=1`, `from`/`to`, drift fields, Meet/Calendar links and the group
242
- timezone), plus `peopleSection`. **Dashboard, billing, email config/log/test,
243
- per-person comms, people list, role changes, approve and refund are out of
244
- scope** they are yours as host routes via the seam. Don't plan around them
245
- shipping.
246
- - **`onboardingInvite` is a template with no built-in caller.** It is seeded into
247
- the group's email settings (so you can edit its copy) but chapter never fires
248
- it, because the moment it would fire approval is a host route (approval is
249
- out of scope, above). Send it from your own approve handler via `sendTemplated`;
250
- the seed existing does not mean the built-ins send it.
251
- - **Server-side Clerk is exported, even though the admin *routes* aren't.** A role
252
- change is your host route — but you don't hand-roll the Clerk REST calls for it.
253
- Beside `createClerkUser`/`createClerkInvitation`/`canChangeRole`, chapter exports
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 str = (v) => typeof v === "string" ? v : void 0;
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: str(row.replyTo) ?? "",
780
- debugEmail: str(row.debugEmail),
781
- refundPolicyText: str(row.refundPolicyText),
782
- commitmentText: str(row.commitmentText),
783
- normsText: str(row.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) {