@odla-ai/chapter 0.23.0 → 0.25.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.
@@ -15,7 +15,14 @@ interface ChapterCopy {
15
15
  join: {
16
16
  form: TextFields<"submit" | "submitting" | "submitFailed" | "unexpectedFailure">;
17
17
  booking: TextFields<"unavailable" | "loadFailed" | "slotTaken" | "failed" | "loading" | "book" | "booking">;
18
- payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete">;
18
+ /**
19
+ * `consent` is the affirmative statement a member agrees to before the card
20
+ * form appears, rendered beside the checkbox and beneath the policy text.
21
+ * It is what the member is shown at the moment of consent, so it says they
22
+ * agree rather than merely restating the policy. Set it to an empty string
23
+ * to render the checkbox with the policy as its only label.
24
+ */
25
+ payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
19
26
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
20
27
  };
21
28
  members: {
@@ -292,10 +299,9 @@ interface ChapterApplication {
292
299
  * writing a row with no consent record. Default `true`. Set `false`
293
300
  * deliberately only when the site renders no consent control. */
294
301
  requireDisclaimerAck?: boolean;
295
- /** Allowlist of fields that reach the Clerk account's client-readable
296
- * `public_metadata.profile`. Default `[]`, so application details remain
297
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
298
- * fields the browser may read. */
302
+ /** Allowlist of application fields mirrored into the Clerk account's
303
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
304
+ * account/admin signals; the application row and CRM remain canonical. */
299
305
  profileFields?: readonly string[];
300
306
  /** Extra application fields carried into the one-way CRM projection, on top of
301
307
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -15,7 +15,14 @@ interface ChapterCopy {
15
15
  join: {
16
16
  form: TextFields<"submit" | "submitting" | "submitFailed" | "unexpectedFailure">;
17
17
  booking: TextFields<"unavailable" | "loadFailed" | "slotTaken" | "failed" | "loading" | "book" | "booking">;
18
- payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete">;
18
+ /**
19
+ * `consent` is the affirmative statement a member agrees to before the card
20
+ * form appears, rendered beside the checkbox and beneath the policy text.
21
+ * It is what the member is shown at the moment of consent, so it says they
22
+ * agree rather than merely restating the policy. Set it to an empty string
23
+ * to render the checkbox with the policy as its only label.
24
+ */
25
+ payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
19
26
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
20
27
  };
21
28
  members: {
@@ -292,10 +299,9 @@ interface ChapterApplication {
292
299
  * writing a row with no consent record. Default `true`. Set `false`
293
300
  * deliberately only when the site renders no consent control. */
294
301
  requireDisclaimerAck?: boolean;
295
- /** Allowlist of fields that reach the Clerk account's client-readable
296
- * `public_metadata.profile`. Default `[]`, so application details remain
297
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
298
- * fields the browser may read. */
302
+ /** Allowlist of application fields mirrored into the Clerk account's
303
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
304
+ * account/admin signals; the application row and CRM remain canonical. */
299
305
  profileFields?: readonly string[];
300
306
  /** Extra application fields carried into the one-way CRM projection, on top of
301
307
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -519,23 +519,30 @@ function clerkUserRequest(input) {
519
519
  skip_password_requirement: true,
520
520
  ...input.firstName ? { first_name: input.firstName } : {},
521
521
  ...input.lastName ? { last_name: input.lastName } : {},
522
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
522
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
523
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
523
524
  }
524
525
  };
525
526
  }
526
- async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
527
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
528
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
529
+ method: "PATCH",
530
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
531
+ body: JSON.stringify({
532
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
533
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
534
+ })
535
+ });
536
+ return res.ok;
537
+ }
538
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
527
539
  const auth = { authorization: `Bearer ${secretKey}` };
528
540
  const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
529
541
  if (!found.ok) return false;
530
542
  const users = await found.json().catch(() => null);
531
543
  const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
532
544
  if (!id) return false;
533
- const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
534
- method: "PATCH",
535
- headers: { ...auth, "content-type": "application/json" },
536
- body: JSON.stringify({ public_metadata: publicMetadata })
537
- });
538
- return patched.ok;
545
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
539
546
  }
540
547
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
541
548
  const { path, body } = clerkUserRequest(input);
@@ -546,8 +553,15 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
546
553
  });
547
554
  if (res.ok) return { ok: true, status: res.status };
548
555
  const healed = heal(res.status);
549
- if (!healed.existed || !input.publicMetadata) return healed;
550
- const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
556
+ const repair = input.repairMetadata ?? {
557
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
558
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
559
+ };
560
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
561
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
562
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
563
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
564
+ }, fetchImpl).catch(() => false);
551
565
  return { ...healed, refreshed };
552
566
  }
553
567
 
@@ -655,6 +669,41 @@ function emailGroupFrom(row) {
655
669
  }
656
670
 
657
671
  // src/worker-provisioning.ts
672
+ var applicantPrivateMetadata = (chapter, applicationId, fields, removeMissingProfile = false) => {
673
+ const profile = applicantProfile(chapter, fields);
674
+ return {
675
+ applicationId,
676
+ ...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
677
+ };
678
+ };
679
+ var publicProfileRemoval = { applicationId: null, profile: null };
680
+ async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
681
+ await db.transact([{
682
+ t: "update",
683
+ ns: "applications",
684
+ id: applicationId,
685
+ attrs: {
686
+ clerkPrivateMetadataSyncedAt: Date.now(),
687
+ ...clerkUserId ? { clerkUserId } : {}
688
+ }
689
+ }]);
690
+ }
691
+ async function syncApplicantPrivateMetadata(db, chapter, applicationId, fields, clerkUserId) {
692
+ if (chapter.account === "none") return false;
693
+ try {
694
+ const secret = await getVaultSecret(db, "clerk_secret_key");
695
+ if (!secret) return false;
696
+ const ok = await updateClerkUserMetadata(secret, clerkUserId, {
697
+ publicMetadata: publicProfileRemoval,
698
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
699
+ });
700
+ if (!ok) return false;
701
+ await markPrivateMetadataSynced(db, applicationId, clerkUserId);
702
+ return true;
703
+ } catch {
704
+ return false;
705
+ }
706
+ }
658
707
  async function provisionApplicant(db, chapter, applicationId, fields) {
659
708
  const email = typeof fields.email === "string" ? fields.email : "";
660
709
  if (!email) return;
@@ -682,17 +731,30 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
682
731
  try {
683
732
  const secret = await getVaultSecret(db, "clerk_secret_key");
684
733
  if (!secret) return;
685
- const profile = applicantProfile(chapter, fields);
686
- const publicMetadata = { applicationId, ...profile ? { profile } : {} };
734
+ const privateMetadata = applicantPrivateMetadata(chapter, applicationId, fields);
687
735
  if (chapter.account === "create") {
688
- await createClerkUser(secret, {
736
+ const result = await createClerkUser(secret, {
689
737
  email,
690
738
  firstName: stringValue(fields.firstName),
691
739
  lastName: stringValue(fields.lastName),
692
- publicMetadata
740
+ privateMetadata,
741
+ repairMetadata: {
742
+ publicMetadata: publicProfileRemoval,
743
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
744
+ }
693
745
  });
746
+ if (result.ok && (!result.existed || result.refreshed)) {
747
+ await markPrivateMetadataSynced(db, applicationId);
748
+ }
694
749
  } else {
695
- await createClerkInvitation(secret, { email, publicMetadata });
750
+ const result = await createClerkInvitation(secret, { email });
751
+ if (result.existed) {
752
+ const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
753
+ publicMetadata: publicProfileRemoval,
754
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
755
+ });
756
+ if (refreshed) await markPrivateMetadataSynced(db, applicationId);
757
+ }
696
758
  }
697
759
  } catch {
698
760
  }
@@ -741,7 +803,10 @@ async function memberSessionApplication(db, chapterId, email) {
741
803
  const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
742
804
  const group = Array.isArray(groups) ? groups[0] : void 0;
743
805
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
744
- return memberApplication(app, meeting, timezone);
806
+ return {
807
+ application: memberApplication(app, meeting, timezone),
808
+ source: app
809
+ };
745
810
  }
746
811
  var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
747
812
  var handleConfig = async (_req, url, env, ctx) => {
@@ -762,8 +827,26 @@ var handleMe = async (req, url, env, ctx) => {
762
827
  const superAdmin = await ctx.isSuperAdminEmail(db, u.email);
763
828
  const base = { authorized: isAdminRole(role, ctx.auth), role, superAdmin, email: u.email ?? null };
764
829
  if (ctx.chapter.mode !== "chapter" || !u.email) return json(base);
765
- const application = await memberSessionApplication(db, ctx.chapter.id, u.email);
766
- return json({ ...base, application });
830
+ const member = await memberSessionApplication(db, ctx.chapter.id, u.email);
831
+ let privateMetadataSynced = false;
832
+ if (member && ctx.chapter.account !== "none" && typeof member.source.clerkPrivateMetadataSyncedAt !== "number") {
833
+ privateMetadataSynced = await syncApplicantPrivateMetadata(
834
+ db,
835
+ ctx.chapter,
836
+ String(member.source.id),
837
+ member.source,
838
+ u.userId
839
+ );
840
+ }
841
+ if (member && !privateMetadataSynced && member.source.clerkUserId !== u.userId) {
842
+ await db.transact([{
843
+ t: "update",
844
+ ns: "applications",
845
+ id: String(member.source.id),
846
+ attrs: { clerkUserId: u.userId }
847
+ }]).catch(() => void 0);
848
+ }
849
+ return json({ ...base, application: member?.application ?? null });
767
850
  };
768
851
  var handleCrm = async (req, url, env, ctx) => {
769
852
  const crmBase = ctx.crmBase;
@@ -921,6 +1004,93 @@ var handleMember = async (req, url, env, ctx) => {
921
1004
  return null;
922
1005
  };
923
1006
 
1007
+ // src/crm-sync.ts
1008
+ import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1009
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1010
+ var crmDeps = (db, ctx) => ({
1011
+ crm: ctx.chapter.crm,
1012
+ db,
1013
+ now: () => Date.now(),
1014
+ newId: () => crypto.randomUUID(),
1015
+ chapter: ctx.chapter
1016
+ });
1017
+ function personInputFromApp(chapter, app) {
1018
+ const input = sharedPersonInput({
1019
+ email: str(app.email),
1020
+ firstName: str(app.firstName) || void 0,
1021
+ lastName: str(app.lastName) || void 0,
1022
+ phone: str(app.phone) || void 0,
1023
+ linkedin: str(app.linkedin) || void 0,
1024
+ hubRecordId: str(app.id)
1025
+ });
1026
+ for (const f of chapter.application.crmFields) {
1027
+ if (app[f] !== void 0) input[f] = app[f];
1028
+ }
1029
+ if (app.id !== void 0) input.applicationId = str(app.id);
1030
+ return input;
1031
+ }
1032
+ function billingColumns(app) {
1033
+ const status = str(app.status);
1034
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1035
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1036
+ const cols = { billingStatus };
1037
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1038
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1039
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1040
+ return cols;
1041
+ }
1042
+ async function syncApplicationToCrm(deps, opts) {
1043
+ const emailKey = str(opts.app.email).toLowerCase();
1044
+ if (!emailKey) return null;
1045
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1046
+ const input = personInputFromApp(deps.chapter, opts.app);
1047
+ const { crm_record } = await deps.db.query({
1048
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1049
+ });
1050
+ const existing = crm_record?.[0] ?? null;
1051
+ const stage = opts.stage || void 0;
1052
+ let recordId;
1053
+ if (existing && typeof existing.id === "string") {
1054
+ recordId = existing.id;
1055
+ await updateRecord2(recordDeps, { id: recordId, input });
1056
+ } else {
1057
+ const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1058
+ recordId = created.id;
1059
+ }
1060
+ if (existing && stage && existing.stage !== stage) {
1061
+ await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1062
+ }
1063
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1064
+ await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1065
+ return recordId;
1066
+ }
1067
+ async function backfillCrm(deps) {
1068
+ const [appsRes, usersRes] = await Promise.all([
1069
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1070
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
1071
+ ]);
1072
+ const seen = /* @__PURE__ */ new Set();
1073
+ let synced = 0;
1074
+ const errors = [];
1075
+ const run = async (app, stage) => {
1076
+ const key = str(app.email).toLowerCase();
1077
+ if (!key || seen.has(key)) return;
1078
+ seen.add(key);
1079
+ try {
1080
+ await syncApplicationToCrm(deps, { app, stage });
1081
+ synced += 1;
1082
+ } catch (err) {
1083
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1084
+ }
1085
+ };
1086
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1087
+ for (const u of usersRes.$users ?? []) {
1088
+ if (u.deleted === true) continue;
1089
+ await run({ email: u.email, firstName: str(u.name) });
1090
+ }
1091
+ return { synced, errors };
1092
+ }
1093
+
924
1094
  // src/worker-routes-schedule.ts
925
1095
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
926
1096
  function errCode(err) {
@@ -1024,8 +1194,11 @@ async function bookSlot(req, env, ctx) {
1024
1194
  if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
1025
1195
  return json({ error: "booking failed", code }, 502);
1026
1196
  }
1027
- const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
1197
+ const bookingAttrs = applicationBookingUpdate(status, startAt, htmlLink);
1198
+ const appOp = { t: "update", ns: "applications", id: applicationId, attrs: bookingAttrs };
1028
1199
  await db.transact([meetingOp, appOp]);
1200
+ const booked = { ...app, ...bookingAttrs };
1201
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
1029
1202
  if (typeof app.email === "string" && app.email) {
1030
1203
  await sendTemplated(
1031
1204
  { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
@@ -1375,6 +1548,8 @@ async function ingestWebhook(req, env, ctx) {
1375
1548
  const patch = webhookPatch(event, String(app.status ?? ""));
1376
1549
  if (Object.keys(patch).length) {
1377
1550
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1551
+ const next = { ...app, ...patch };
1552
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
1378
1553
  }
1379
1554
  if (event.kind === "first_payment") {
1380
1555
  await notifyPaymentConfirmed(db, env, eventId, app);
@@ -1526,9 +1701,21 @@ var PAGE = 100;
1526
1701
  function toRecord(u) {
1527
1702
  if (typeof u.id !== "string") return null;
1528
1703
  const pm = u.public_metadata ?? {};
1704
+ const privateMetadata = u.private_metadata ?? {};
1705
+ const rawProfile = privateMetadata.profile;
1706
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
1707
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
1529
1708
  const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
1530
1709
  const email = u.email_addresses?.[0]?.email_address;
1531
- return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
1710
+ return {
1711
+ id: u.id,
1712
+ email: typeof email === "string" ? email : void 0,
1713
+ role,
1714
+ publicMetadata: pm,
1715
+ privateMetadata,
1716
+ profile,
1717
+ ...applicationId ? { applicationId } : {}
1718
+ };
1532
1719
  }
1533
1720
  async function clerkGet(path, secretKey, fetchImpl) {
1534
1721
  const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
@@ -1566,86 +1753,6 @@ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
1566
1753
  return res.ok;
1567
1754
  }
1568
1755
 
1569
- // src/crm-sync.ts
1570
- import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1571
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1572
- function personInputFromApp(chapter, app) {
1573
- const input = sharedPersonInput({
1574
- email: str(app.email),
1575
- firstName: str(app.firstName) || void 0,
1576
- lastName: str(app.lastName) || void 0,
1577
- phone: str(app.phone) || void 0,
1578
- linkedin: str(app.linkedin) || void 0,
1579
- hubRecordId: str(app.id)
1580
- });
1581
- for (const f of chapter.application.crmFields) {
1582
- if (app[f] !== void 0) input[f] = app[f];
1583
- }
1584
- if (app.id !== void 0) input.applicationId = str(app.id);
1585
- return input;
1586
- }
1587
- function billingColumns(app) {
1588
- const status = str(app.status);
1589
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1590
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1591
- const cols = { billingStatus };
1592
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1593
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1594
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1595
- return cols;
1596
- }
1597
- async function syncApplicationToCrm(deps, opts) {
1598
- const emailKey = str(opts.app.email).toLowerCase();
1599
- if (!emailKey) return null;
1600
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1601
- const input = personInputFromApp(deps.chapter, opts.app);
1602
- const { crm_record } = await deps.db.query({
1603
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1604
- });
1605
- const existing = crm_record?.[0] ?? null;
1606
- const stage = opts.stage || void 0;
1607
- let recordId;
1608
- if (existing && typeof existing.id === "string") {
1609
- recordId = existing.id;
1610
- await updateRecord2(crmDeps3, { id: recordId, input });
1611
- } else {
1612
- const created = await createRecord2(crmDeps3, { type: "person", input, ...stage ? { stage } : {} });
1613
- recordId = created.id;
1614
- }
1615
- if (existing && stage && existing.stage !== stage) {
1616
- await setStage(crmDeps3, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1617
- }
1618
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1619
- await linkIdentity(crmDeps3, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1620
- return recordId;
1621
- }
1622
- async function backfillCrm(deps) {
1623
- const [appsRes, usersRes] = await Promise.all([
1624
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1625
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1626
- ]);
1627
- const seen = /* @__PURE__ */ new Set();
1628
- let synced = 0;
1629
- const errors = [];
1630
- const run = async (app, stage) => {
1631
- const key = str(app.email).toLowerCase();
1632
- if (!key || seen.has(key)) return;
1633
- seen.add(key);
1634
- try {
1635
- await syncApplicationToCrm(deps, { app, stage });
1636
- synced += 1;
1637
- } catch (err) {
1638
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1639
- }
1640
- };
1641
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1642
- for (const u of usersRes.$users ?? []) {
1643
- if (u.deleted === true) continue;
1644
- await run({ email: u.email, firstName: str(u.name) });
1645
- }
1646
- return { synced, errors };
1647
- }
1648
-
1649
1756
  // src/worker-routes-admin-people.ts
1650
1757
  async function adminGate(req, env, ctx) {
1651
1758
  const rawDb = ctx.makeDb(env);
@@ -1654,7 +1761,7 @@ async function adminGate(req, env, ctx) {
1654
1761
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1655
1762
  return { db: rawDb, actor: { userId: u.userId, email: u.email ?? void 0 } };
1656
1763
  }
1657
- var crmDeps = (db, ctx) => ({
1764
+ var crmDeps2 = (db, ctx) => ({
1658
1765
  crm: ctx.chapter.crm,
1659
1766
  db,
1660
1767
  now: () => Date.now(),
@@ -1665,7 +1772,7 @@ var handleAdminCrmSync = async (req, url, env, ctx) => {
1665
1772
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1666
1773
  const gate5 = await adminGate(req, env, ctx);
1667
1774
  if (gate5 instanceof Response) return gate5;
1668
- const result = await backfillCrm(crmDeps(gate5.db, ctx));
1775
+ const result = await backfillCrm(crmDeps2(gate5.db, ctx));
1669
1776
  return json({ ok: true, ...result });
1670
1777
  };
1671
1778
  var handleAdminPeople = async (req, url, env, ctx) => {
@@ -1679,17 +1786,20 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1679
1786
  db.query({ $users: { $: { limit: 200 } } }),
1680
1787
  sk ? clerkListUsers(sk).catch(() => []) : Promise.resolve([])
1681
1788
  ]);
1682
- const roleByUserId = new Map(roleList.map((u) => [u.id, u.role]));
1789
+ const clerkByUserId = new Map(roleList.map((u) => [u.id, u]));
1683
1790
  const people = /* @__PURE__ */ new Map();
1684
1791
  for (const u of usersRes.$users ?? []) {
1685
1792
  if (u.deleted === true) continue;
1686
1793
  const email = typeof u.email === "string" ? u.email : "";
1687
1794
  if (!email) continue;
1795
+ const clerk = clerkByUserId.get(String(u.id));
1688
1796
  people.set(email.toLowerCase(), {
1689
1797
  email,
1690
1798
  name: typeof u.name === "string" ? u.name : "",
1691
1799
  userId: typeof u.id === "string" ? u.id : null,
1692
- role: roleByUserId.get(String(u.id)) ?? "provisional",
1800
+ role: clerk?.role ?? "provisional",
1801
+ profile: clerk ? clerk.profile : null,
1802
+ clerkApplicationId: clerk?.applicationId ?? null,
1693
1803
  application: null
1694
1804
  });
1695
1805
  }
@@ -1702,7 +1812,15 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1702
1812
  if (!row.application) row.application = applicationSummary(a);
1703
1813
  if (!row.name) row.name = name;
1704
1814
  } else {
1705
- people.set(key, { email: String(a.email), name, userId: null, role: null, application: applicationSummary(a) });
1815
+ people.set(key, {
1816
+ email: String(a.email),
1817
+ name,
1818
+ userId: null,
1819
+ role: null,
1820
+ profile: null,
1821
+ clerkApplicationId: null,
1822
+ application: applicationSummary(a)
1823
+ });
1706
1824
  }
1707
1825
  }
1708
1826
  const rows2 = [...people.values()].sort(
@@ -1754,6 +1872,88 @@ var handleAdminPeopleRole = async (req, url, env, ctx) => {
1754
1872
  return json({ ok: true });
1755
1873
  };
1756
1874
 
1875
+ // src/worker-routes-admin-clerk.ts
1876
+ var own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
1877
+ function canonical(value) {
1878
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1879
+ if (value && typeof value === "object") {
1880
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
1881
+ }
1882
+ return JSON.stringify(value) ?? "undefined";
1883
+ }
1884
+ function positiveInt(raw, fallback, max) {
1885
+ const parsed = Number(raw);
1886
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? Math.min(parsed, max) : fallback;
1887
+ }
1888
+ var handleAdminClerkPrivateProfileSync = async (req, url, env, ctx) => {
1889
+ if (req.method !== "POST" || url.pathname !== "/api/admin/clerk/private-profiles/sync") return null;
1890
+ const rawDb = ctx.makeDb(env);
1891
+ const actor = await ctx.verifyUser(req, env);
1892
+ if (!actor) return json({ error: "unauthorized" }, 401);
1893
+ if (!await ctx.isAdmin(rawDb, actor)) return json({ error: "forbidden" }, 403);
1894
+ if (ctx.chapter.mode !== "chapter" || ctx.chapter.account === "none") {
1895
+ return json({ error: "private profile sync requires a side-effecting chapter account model" }, 409);
1896
+ }
1897
+ const db = rawDb;
1898
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1899
+ if (!secret) return json({ error: "clerk_secret_key missing from vault" }, 503);
1900
+ let all;
1901
+ try {
1902
+ all = await clerkListUsers(secret);
1903
+ } catch {
1904
+ return json({ error: "Clerk user listing failed" }, 502);
1905
+ }
1906
+ const offset = positiveInt(url.searchParams.get("offset"), 0, Number.MAX_SAFE_INTEGER);
1907
+ const limit = positiveInt(url.searchParams.get("limit"), 50, 100) || 50;
1908
+ const users = all.slice(offset, offset + limit);
1909
+ const result = { updated: 0, alreadyCurrent: 0, noApplication: 0, failed: 0 };
1910
+ for (const user of users) {
1911
+ if (!user.email) {
1912
+ result.noApplication += 1;
1913
+ continue;
1914
+ }
1915
+ const apps = (await db.query({
1916
+ applications: { $: { where: { email: user.email }, order: { createdAt: "desc" }, limit: 1 } }
1917
+ })).applications;
1918
+ const app = Array.isArray(apps) ? apps[0] : void 0;
1919
+ if (!app || typeof app.id !== "string") {
1920
+ result.noApplication += 1;
1921
+ continue;
1922
+ }
1923
+ const desired = applicantPrivateMetadata(ctx.chapter, app.id, app);
1924
+ const desiredProfile = desired.profile ?? {};
1925
+ const legacyPublic = own(user.publicMetadata, "applicationId") || own(user.publicMetadata, "profile");
1926
+ const current = user.applicationId === app.id && canonical(user.profile) === canonical(desiredProfile);
1927
+ try {
1928
+ let updated = false;
1929
+ if (!current || legacyPublic) {
1930
+ const ok = await updateClerkUserMetadata(secret, user.id, {
1931
+ publicMetadata: publicProfileRemoval,
1932
+ privateMetadata: applicantPrivateMetadata(ctx.chapter, app.id, app, true)
1933
+ });
1934
+ if (!ok) {
1935
+ result.failed += 1;
1936
+ continue;
1937
+ }
1938
+ updated = true;
1939
+ }
1940
+ await markPrivateMetadataSynced(db, app.id, user.id);
1941
+ if (updated) result.updated += 1;
1942
+ else result.alreadyCurrent += 1;
1943
+ } catch {
1944
+ result.failed += 1;
1945
+ }
1946
+ }
1947
+ const nextOffset = offset + users.length < all.length ? offset + users.length : null;
1948
+ return json({
1949
+ totalUsers: all.length,
1950
+ offset,
1951
+ processed: users.length,
1952
+ nextOffset,
1953
+ ...result
1954
+ });
1955
+ };
1956
+
1757
1957
  // src/series.ts
1758
1958
  function periodSeries(points, now, count, periodMs) {
1759
1959
  const start = now - count * periodMs;
@@ -1945,7 +2145,6 @@ async function gate2(req, env, ctx) {
1945
2145
  return rawDb;
1946
2146
  }
1947
2147
  var calFor = (env) => initCalendar3({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1948
- var crmDeps2 = (db, ctx) => ({ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID(), chapter: ctx.chapter });
1949
2148
  var readJson = async (req) => {
1950
2149
  try {
1951
2150
  return await req.json();
@@ -2031,7 +2230,7 @@ var handleAdminApprove = async (req, url, env, ctx) => {
2031
2230
  if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
2032
2231
  const target = "approved";
2033
2232
  await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
2034
- await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
2233
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
2035
2234
  const { promoteTo, send } = ctx.chapter.operations.onApprove;
2036
2235
  let rolePromoted = false;
2037
2236
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2112,7 +2311,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
2112
2311
  if (Object.keys(attrs).length === 0) return json({ error: "nothing to update" }, 400);
2113
2312
  await db.transact([{ t: "update", ns: "applications", id, attrs }]);
2114
2313
  if (attrs.status !== void 0) {
2115
- await syncApplicationToCrm(crmDeps2(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
2314
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
2116
2315
  }
2117
2316
  return json({ ok: true });
2118
2317
  };
@@ -2371,6 +2570,7 @@ var BUILTIN_ROUTES = [
2371
2570
  handleAdminPeopleAccess,
2372
2571
  handleAdminPeopleRole,
2373
2572
  handleAdminCrmSync,
2573
+ handleAdminClerkPrivateProfileSync,
2374
2574
  // Aggregation
2375
2575
  handleAdminDashboard,
2376
2576
  handleAdminBilling,