@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.
- package/README.md +40 -29
- package/dist/{chunk-OYXNI6VR.js → chunk-GMGTJIY3.js} +28 -15
- package/dist/chunk-GMGTJIY3.js.map +1 -0
- package/dist/{chunk-NTZSXAFT.js → chunk-WGQO4WDJ.js} +3 -2
- package/dist/chunk-WGQO4WDJ.js.map +1 -0
- package/dist/{chunk-VCC4S6JU.js → chunk-ZPAJL7KG.js} +28 -21
- package/dist/chunk-ZPAJL7KG.js.map +1 -0
- package/dist/{copy-context-pDogTR4x.d.ts → copy-context-DI21CYQ3.d.ts} +11 -5
- package/dist/index.cjs +70 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -25
- package/dist/index.d.ts +72 -25
- package/dist/index.js +70 -21
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +2 -2
- package/dist/ui/admin/index.js +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +3 -3
- package/dist/ui/member/index.d.ts +2 -2
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +308 -108
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +11 -5
- package/dist/worker/index.d.ts +11 -5
- package/dist/worker/index.js +308 -108
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/runbooks/adopt-existing.md +38 -9
- package/runbooks/greenfield.md +31 -31
- package/dist/chunk-NTZSXAFT.js.map +0 -1
- package/dist/chunk-OYXNI6VR.js.map +0 -1
- package/dist/chunk-VCC4S6JU.js.map +0 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
296
|
-
* `
|
|
297
|
-
*
|
|
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
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
296
|
-
* `
|
|
297
|
-
*
|
|
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
|
package/dist/worker/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
766
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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, {
|
|
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(
|
|
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(
|
|
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,
|