@odla-ai/chapter 0.34.0 → 0.36.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 +83 -2
- package/dist/{chunk-UJIVBGL5.js → chunk-I45VYBUV.js} +441 -239
- package/dist/chunk-I45VYBUV.js.map +1 -0
- package/dist/{chunk-JAWTIROV.js → chunk-IAWBE5PX.js} +55 -1
- package/dist/chunk-IAWBE5PX.js.map +1 -0
- package/dist/{chunk-TXE5ZDI4.js → chunk-LWM32REV.js} +166 -59
- package/dist/chunk-LWM32REV.js.map +1 -0
- package/dist/{copy-context-c1tqPXOR.d.ts → copy-context-0k0-yWHL.d.ts} +6 -2
- package/dist/index.cjs +967 -296
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +253 -3
- package/dist/index.d.ts +253 -3
- package/dist/index.js +967 -296
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +10 -3
- package/dist/ui/admin/index.js +6 -2
- package/dist/ui/index.d.ts +3 -3
- package/dist/ui/index.js +11 -3
- package/dist/ui/member/index.d.ts +86 -7
- package/dist/ui/member/index.js +6 -2
- package/dist/worker/index.cjs +1957 -722
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +200 -3
- package/dist/worker/index.d.ts +200 -3
- package/dist/worker/index.js +1968 -733
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/chunk-JAWTIROV.js.map +0 -1
- package/dist/chunk-TXE5ZDI4.js.map +0 -1
- package/dist/chunk-UJIVBGL5.js.map +0 -1
package/dist/worker/index.cjs
CHANGED
|
@@ -22,7 +22,10 @@ var worker_exports = {};
|
|
|
22
22
|
__export(worker_exports, {
|
|
23
23
|
chapterWorker: () => chapterWorker,
|
|
24
24
|
createWorkerContext: () => createWorkerContext,
|
|
25
|
-
|
|
25
|
+
deliverAdmissionGrants: () => deliverAdmissionGrants,
|
|
26
|
+
deliverSignupControl: () => deliverSignupControl,
|
|
27
|
+
renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
|
|
28
|
+
withActiveSignupControlTheme: () => withActiveSignupControlTheme
|
|
26
29
|
});
|
|
27
30
|
module.exports = __toCommonJS(worker_exports);
|
|
28
31
|
|
|
@@ -137,7 +140,7 @@ function createWorkerContext(options) {
|
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
// src/worker-routes.ts
|
|
140
|
-
var
|
|
143
|
+
var import_crm4 = require("@odla-ai/crm");
|
|
141
144
|
|
|
142
145
|
// src/discussion-reference-security.ts
|
|
143
146
|
async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
|
|
@@ -195,6 +198,205 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
195
198
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
196
199
|
}
|
|
197
200
|
|
|
201
|
+
// src/admission-grants-contract.ts
|
|
202
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
203
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
204
|
+
|
|
205
|
+
// src/signup-control-canonical.ts
|
|
206
|
+
var encoder = new TextEncoder();
|
|
207
|
+
function canonicalValue(value) {
|
|
208
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
209
|
+
return JSON.stringify(value);
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
212
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
213
|
+
const record2 = value;
|
|
214
|
+
const entries = Object.keys(record2).sort().flatMap((key) => {
|
|
215
|
+
const item = record2[key];
|
|
216
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
217
|
+
});
|
|
218
|
+
return `{${entries.join(",")}}`;
|
|
219
|
+
}
|
|
220
|
+
function canonicalSignupControl(value) {
|
|
221
|
+
return canonicalValue(value);
|
|
222
|
+
}
|
|
223
|
+
async function signupControlDigest(document) {
|
|
224
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
225
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/admission-grants.ts
|
|
229
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
230
|
+
var MAX_GRANTS = 2e3;
|
|
231
|
+
var MAX_NOTE = 2e3;
|
|
232
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
233
|
+
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
234
|
+
function normalizeAdmissionEmail(email) {
|
|
235
|
+
return email.trim().toLowerCase();
|
|
236
|
+
}
|
|
237
|
+
function validateAdmissionGrantDocument(input) {
|
|
238
|
+
const errors = [];
|
|
239
|
+
const root = input && typeof input === "object" ? input : {};
|
|
240
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
241
|
+
const revision = Number(root.revision);
|
|
242
|
+
const generatedAt = Number(root.generatedAt);
|
|
243
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
244
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
245
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
246
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
247
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
248
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
249
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
250
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
251
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
252
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
253
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
254
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
255
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
256
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
257
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
258
|
+
const grants = [];
|
|
259
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
260
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
261
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
262
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
263
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
264
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
265
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
266
|
+
const decision = grant.decision;
|
|
267
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
268
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
269
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
270
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
271
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
272
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
273
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
274
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
275
|
+
}
|
|
276
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
277
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
278
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
279
|
+
}
|
|
280
|
+
seenIds.add(id);
|
|
281
|
+
seenEmails.add(normalized);
|
|
282
|
+
grants.push({
|
|
283
|
+
id,
|
|
284
|
+
email,
|
|
285
|
+
decision,
|
|
286
|
+
...note ? { note } : {},
|
|
287
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (errors.length) return { ok: false, errors };
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
value: {
|
|
294
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
295
|
+
revision,
|
|
296
|
+
generatedAt,
|
|
297
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
298
|
+
target: { chapterId, environment },
|
|
299
|
+
grants
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
304
|
+
function asGrant(row) {
|
|
305
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
306
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
307
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
308
|
+
return row;
|
|
309
|
+
}
|
|
310
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
311
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
312
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
313
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
314
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
315
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
316
|
+
)).sort((a, b) => {
|
|
317
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
318
|
+
return scope || b.createdAt - a.createdAt;
|
|
319
|
+
});
|
|
320
|
+
return active[0] ?? null;
|
|
321
|
+
}
|
|
322
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
323
|
+
const where = { sourceId, targetId, environment };
|
|
324
|
+
if (db.aggregate) {
|
|
325
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
326
|
+
}
|
|
327
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
328
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
329
|
+
}
|
|
330
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
331
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
332
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
333
|
+
where: { sourceId, targetId, environment },
|
|
334
|
+
order: { revision: "desc" },
|
|
335
|
+
limit: 1
|
|
336
|
+
} } });
|
|
337
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
338
|
+
return row ? row : null;
|
|
339
|
+
}
|
|
340
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
341
|
+
const { document, digest } = envelope;
|
|
342
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
343
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
344
|
+
limit: MAX_GRANTS
|
|
345
|
+
} } })).admissionGrants);
|
|
346
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
347
|
+
const ops = [];
|
|
348
|
+
for (const row of current) {
|
|
349
|
+
const id = String(row.id ?? "");
|
|
350
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
351
|
+
active: false,
|
|
352
|
+
revokedAt: receivedAt,
|
|
353
|
+
revokedBy: document.source.actor,
|
|
354
|
+
updatedAt: receivedAt,
|
|
355
|
+
updatedBy: document.source.actor
|
|
356
|
+
} });
|
|
357
|
+
}
|
|
358
|
+
for (const grant of document.grants) {
|
|
359
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
360
|
+
const existing = current.find((row) => row.id === id);
|
|
361
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
362
|
+
id,
|
|
363
|
+
sourceGrantId: grant.id,
|
|
364
|
+
sourceScope: "parent",
|
|
365
|
+
sourceId: document.source.appId,
|
|
366
|
+
targetId: document.target.chapterId,
|
|
367
|
+
email: grant.email,
|
|
368
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
369
|
+
decision: grant.decision,
|
|
370
|
+
...grant.note ? { note: grant.note } : {},
|
|
371
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
372
|
+
active: true,
|
|
373
|
+
sourceRevision: document.revision,
|
|
374
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
375
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
376
|
+
updatedAt: receivedAt,
|
|
377
|
+
updatedBy: document.source.actor
|
|
378
|
+
} });
|
|
379
|
+
const retract = [
|
|
380
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
381
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
382
|
+
];
|
|
383
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
384
|
+
}
|
|
385
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
386
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
387
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
388
|
+
id: revisionId,
|
|
389
|
+
sourceId: document.source.appId,
|
|
390
|
+
targetId: document.target.chapterId,
|
|
391
|
+
environment: document.target.environment,
|
|
392
|
+
revision: document.revision,
|
|
393
|
+
digest,
|
|
394
|
+
active: true,
|
|
395
|
+
receivedAt
|
|
396
|
+
} });
|
|
397
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
398
|
+
}
|
|
399
|
+
|
|
198
400
|
// src/member.ts
|
|
199
401
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
200
402
|
var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
|
|
@@ -246,9 +448,9 @@ function resolveApplication(a) {
|
|
|
246
448
|
validateEmail: a?.validateEmail ?? true
|
|
247
449
|
};
|
|
248
450
|
}
|
|
249
|
-
var
|
|
451
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
250
452
|
function isValidEmail(value) {
|
|
251
|
-
return typeof value === "string" &&
|
|
453
|
+
return typeof value === "string" && EMAIL_RE2.test(value);
|
|
252
454
|
}
|
|
253
455
|
function clampArray(value, max) {
|
|
254
456
|
if (!Array.isArray(value)) return value;
|
|
@@ -298,6 +500,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
298
500
|
}
|
|
299
501
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
300
502
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
503
|
+
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
504
|
+
row.emailNormalized = normalizeAdmissionEmail(fields.email);
|
|
505
|
+
}
|
|
506
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
507
|
+
row.admissionGrantId = opts.admissionGrant.id;
|
|
508
|
+
row.admissionSource = opts.admissionGrant.sourceScope;
|
|
509
|
+
row.interviewWaivedAt = opts.now;
|
|
510
|
+
}
|
|
511
|
+
if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
|
|
301
512
|
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
302
513
|
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
303
514
|
return { ok: false, error: "tierId is not an offered tier" };
|
|
@@ -305,8 +516,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
305
516
|
row.tierId = fields.tierId;
|
|
306
517
|
}
|
|
307
518
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
519
|
+
const ops = [
|
|
520
|
+
{ t: "update", ns: "applications", id, attrs: row }
|
|
521
|
+
];
|
|
522
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
523
|
+
ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
|
|
524
|
+
consumedAt: opts.now,
|
|
525
|
+
consumedApplicationId: id
|
|
526
|
+
} });
|
|
527
|
+
}
|
|
308
528
|
const { duplicate } = await db.transact(
|
|
309
|
-
|
|
529
|
+
ops,
|
|
310
530
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
311
531
|
);
|
|
312
532
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
@@ -340,8 +560,8 @@ function tierPayable(tier, group, hasSecretKey) {
|
|
|
340
560
|
function byOrder(a, b) {
|
|
341
561
|
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
342
562
|
}
|
|
343
|
-
function resolveTiers(group,
|
|
344
|
-
const stored =
|
|
563
|
+
function resolveTiers(group, rows4 = []) {
|
|
564
|
+
const stored = rows4.filter((tier) => tier.active);
|
|
345
565
|
if (stored.length) return [...stored].sort(byOrder);
|
|
346
566
|
return [{
|
|
347
567
|
id: "standard",
|
|
@@ -387,15 +607,15 @@ function rowToTier(row, groupId) {
|
|
|
387
607
|
}
|
|
388
608
|
async function loadTiers(db, group) {
|
|
389
609
|
const groupId = String(group.id ?? "");
|
|
390
|
-
let
|
|
610
|
+
let rows4;
|
|
391
611
|
try {
|
|
392
|
-
|
|
612
|
+
rows4 = (await db.query({
|
|
393
613
|
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
394
614
|
})).tiers;
|
|
395
615
|
} catch {
|
|
396
|
-
|
|
616
|
+
rows4 = void 0;
|
|
397
617
|
}
|
|
398
|
-
const list = Array.isArray(
|
|
618
|
+
const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
|
|
399
619
|
return resolveTiers(group, list);
|
|
400
620
|
}
|
|
401
621
|
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
@@ -509,341 +729,252 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
509
729
|
};
|
|
510
730
|
}
|
|
511
731
|
|
|
512
|
-
// src/
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
732
|
+
// src/gift-offers.ts
|
|
733
|
+
var GIFT_OFFER_CATALOG_VERSION = 1;
|
|
734
|
+
var MAX_GIFT_OFFERS = 20;
|
|
735
|
+
var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
|
|
736
|
+
var PRICE = /^price_[A-Za-z0-9]{12,}$/;
|
|
737
|
+
var CURRENCY = /^[a-z]{3}$/;
|
|
738
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
739
|
+
var text = (value, path, errors, max) => {
|
|
740
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
741
|
+
errors.push(`${path} is required`);
|
|
742
|
+
return "";
|
|
743
|
+
}
|
|
744
|
+
if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
|
|
745
|
+
return value.trim();
|
|
746
|
+
};
|
|
747
|
+
function validateGiftOfferCatalog(input, tierIds) {
|
|
748
|
+
const errors = [];
|
|
749
|
+
const root = record(input);
|
|
750
|
+
if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
|
|
751
|
+
for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
|
|
752
|
+
if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
|
|
753
|
+
if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
|
|
754
|
+
const rawOffers = Array.isArray(root.offers) ? root.offers : [];
|
|
755
|
+
if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
|
|
756
|
+
const ids = /* @__PURE__ */ new Set();
|
|
757
|
+
const offers = [];
|
|
758
|
+
for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
|
|
759
|
+
const path = `document.giftOffers.offers[${index}]`;
|
|
760
|
+
const item = record(raw);
|
|
761
|
+
if (!item) {
|
|
762
|
+
errors.push(`${path} must be an object`);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const allowed = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
766
|
+
for (const key of Object.keys(item)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
767
|
+
const id = text(item.id, `${path}.id`, errors, 80);
|
|
768
|
+
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
769
|
+
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
770
|
+
ids.add(id);
|
|
771
|
+
const targetRaw = record(item.target);
|
|
772
|
+
let target = { type: "entitlement", entitlementKey: "invalid" };
|
|
773
|
+
if (targetRaw?.type === "tier") {
|
|
774
|
+
const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
|
|
775
|
+
if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
|
|
776
|
+
target = { type: "tier", tierId };
|
|
777
|
+
} else if (targetRaw?.type === "entitlement") {
|
|
778
|
+
const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
|
|
779
|
+
if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
|
|
780
|
+
target = { type: "entitlement", entitlementKey };
|
|
781
|
+
} else errors.push(`${path}.target.type must be tier or entitlement`);
|
|
782
|
+
if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
|
|
783
|
+
const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
|
|
784
|
+
if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
785
|
+
if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
|
|
786
|
+
const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
|
|
787
|
+
if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
|
|
788
|
+
const lifetime = Number(item.redemptionLifetimeHours);
|
|
789
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
|
|
790
|
+
const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
|
|
791
|
+
const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
|
|
792
|
+
if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
|
|
793
|
+
if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
|
|
794
|
+
if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
|
|
795
|
+
offers.push({
|
|
796
|
+
id,
|
|
797
|
+
target,
|
|
798
|
+
billingMode: item.billingMode,
|
|
799
|
+
stripePriceId,
|
|
800
|
+
priceCents: Number(item.priceCents),
|
|
801
|
+
currency,
|
|
802
|
+
redemptionLifetimeHours: lifetime,
|
|
803
|
+
...activeFrom !== void 0 ? { activeFrom } : {},
|
|
804
|
+
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
805
|
+
title: text(item.title, `${path}.title`, errors, 160),
|
|
806
|
+
description: text(item.description, `${path}.description`, errors, 2e3),
|
|
807
|
+
ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
|
|
517
811
|
}
|
|
518
|
-
|
|
519
|
-
|
|
812
|
+
var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
|
|
813
|
+
function publicGiftOffers(catalog, now) {
|
|
814
|
+
return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
|
|
520
815
|
}
|
|
521
|
-
function
|
|
522
|
-
return
|
|
816
|
+
function findGiftOffer(catalog, offerId, now) {
|
|
817
|
+
return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
|
|
523
818
|
}
|
|
524
819
|
|
|
525
|
-
// src/
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
820
|
+
// src/clerk-roles.ts
|
|
821
|
+
var CLERK_API = "https://api.clerk.com";
|
|
822
|
+
var DEFAULT_ROLE = "provisional";
|
|
823
|
+
var PAGE = 100;
|
|
824
|
+
function toRecord(u) {
|
|
825
|
+
if (typeof u.id !== "string") return null;
|
|
826
|
+
const pm = u.public_metadata ?? {};
|
|
827
|
+
const privateMetadata = u.private_metadata ?? {};
|
|
828
|
+
const rawProfile = privateMetadata.profile;
|
|
829
|
+
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
830
|
+
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
831
|
+
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
832
|
+
const email = u.email_addresses?.[0]?.email_address;
|
|
833
|
+
return {
|
|
834
|
+
id: u.id,
|
|
835
|
+
email: typeof email === "string" ? email : void 0,
|
|
836
|
+
role,
|
|
837
|
+
publicMetadata: pm,
|
|
838
|
+
privateMetadata,
|
|
839
|
+
profile,
|
|
840
|
+
...applicationId ? { applicationId } : {}
|
|
841
|
+
};
|
|
529
842
|
}
|
|
530
|
-
async function
|
|
531
|
-
const
|
|
532
|
-
if (
|
|
533
|
-
|
|
843
|
+
async function clerkGet(path, secretKey, fetchImpl) {
|
|
844
|
+
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
845
|
+
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
846
|
+
return res.json();
|
|
847
|
+
}
|
|
848
|
+
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
849
|
+
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
850
|
+
const user = Array.isArray(data) ? data[0] : void 0;
|
|
851
|
+
return user ? toRecord(user) : null;
|
|
852
|
+
}
|
|
853
|
+
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
854
|
+
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
855
|
+
return data ? toRecord(data) : null;
|
|
856
|
+
}
|
|
857
|
+
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
858
|
+
const out = [];
|
|
859
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
860
|
+
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
861
|
+
const page = Array.isArray(data) ? data : [];
|
|
862
|
+
for (const u of page) {
|
|
863
|
+
const record2 = toRecord(u);
|
|
864
|
+
if (record2) out.push(record2);
|
|
865
|
+
}
|
|
866
|
+
if (page.length < PAGE) break;
|
|
534
867
|
}
|
|
535
|
-
|
|
536
|
-
return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
868
|
+
return out;
|
|
537
869
|
}
|
|
538
|
-
async function
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
546
|
-
return asStored(latest);
|
|
870
|
+
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
871
|
+
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
872
|
+
method: "PATCH",
|
|
873
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
874
|
+
body: JSON.stringify({ public_metadata: { role } })
|
|
875
|
+
});
|
|
876
|
+
return res.ok;
|
|
547
877
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
878
|
+
|
|
879
|
+
// src/crm-sync.ts
|
|
880
|
+
var import_crm3 = require("@odla-ai/crm");
|
|
881
|
+
|
|
882
|
+
// src/network.ts
|
|
883
|
+
var import_crm2 = require("@odla-ai/crm");
|
|
884
|
+
|
|
885
|
+
// src/network-contract.ts
|
|
886
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
887
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
888
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/network.ts
|
|
892
|
+
function sharedPersonInput(person) {
|
|
893
|
+
const email = person.email.toLowerCase();
|
|
894
|
+
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
895
|
+
const input = { name: person.name ?? fullName ?? email, email };
|
|
896
|
+
if (input.name === "") input.name = email;
|
|
897
|
+
if (person.firstName) input.firstName = person.firstName;
|
|
898
|
+
if (person.lastName) input.lastName = person.lastName;
|
|
899
|
+
if (person.phone) input.phone = person.phone;
|
|
900
|
+
if (person.linkedin) input.linkedin = person.linkedin;
|
|
901
|
+
return input;
|
|
557
902
|
}
|
|
558
|
-
function
|
|
559
|
-
|
|
560
|
-
|
|
903
|
+
function shortHash(value) {
|
|
904
|
+
let a = 2166136261;
|
|
905
|
+
let b = 2654435769;
|
|
906
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
907
|
+
const n = value.charCodeAt(i);
|
|
908
|
+
a = Math.imul(a ^ n, 16777619);
|
|
909
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
910
|
+
}
|
|
911
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
561
912
|
}
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
913
|
+
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
914
|
+
const typeKey = type.toLowerCase();
|
|
915
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
916
|
+
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
917
|
+
const raw = `${prefix}:${hubRecordId}`;
|
|
918
|
+
if (readable && raw.length <= 64) return raw;
|
|
919
|
+
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
566
920
|
}
|
|
567
|
-
function
|
|
568
|
-
|
|
569
|
-
|
|
921
|
+
function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
|
|
922
|
+
if ("input" in record2 && record2.version === 2) {
|
|
923
|
+
return {
|
|
924
|
+
version: 2,
|
|
925
|
+
sourceId: record2.source.siteId,
|
|
926
|
+
sourceRecordId: record2.source.recordId,
|
|
927
|
+
type: record2.type,
|
|
928
|
+
input: record2.input
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
if ("input" in record2) {
|
|
932
|
+
return {
|
|
933
|
+
version: 1,
|
|
934
|
+
sourceId: fallbackSourceId,
|
|
935
|
+
sourceRecordId: record2.hubRecordId,
|
|
936
|
+
type: record2.type,
|
|
937
|
+
input: record2.input
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
if ("type" in record2 && record2.type === "company") {
|
|
941
|
+
const input = { name: record2.name };
|
|
942
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
943
|
+
if (record2[key]) input[key] = record2[key];
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
version: 1,
|
|
947
|
+
sourceId: fallbackSourceId,
|
|
948
|
+
sourceRecordId: record2.hubRecordId,
|
|
949
|
+
type: "company",
|
|
950
|
+
input
|
|
951
|
+
};
|
|
952
|
+
}
|
|
570
953
|
return {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
954
|
+
version: 1,
|
|
955
|
+
sourceId: fallbackSourceId,
|
|
956
|
+
sourceRecordId: record2.hubRecordId,
|
|
957
|
+
type: "person",
|
|
958
|
+
input: sharedPersonInput(record2)
|
|
576
959
|
};
|
|
577
960
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
value
|
|
582
|
-
});
|
|
583
|
-
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
584
|
-
const { document, digest } = envelope;
|
|
585
|
-
const currentRows = (await db.query({ tiers: { $: {
|
|
586
|
-
where: { groupId: document.target.chapterId },
|
|
587
|
-
limit: 100
|
|
588
|
-
} } })).tiers ?? [];
|
|
589
|
-
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
590
|
-
const ops = [];
|
|
591
|
-
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
592
|
-
for (const row of currentRows) {
|
|
593
|
-
const id2 = String(row.id ?? "");
|
|
594
|
-
if (id2 && !incoming.has(id2)) {
|
|
595
|
-
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
for (const tier of document.tiers) {
|
|
599
|
-
const tierRef = byPublicId("tiers", tier.id);
|
|
600
|
-
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
601
|
-
id: tier.id,
|
|
602
|
-
groupId: document.target.chapterId,
|
|
603
|
-
name: tier.name,
|
|
604
|
-
priceCents: tier.priceCents,
|
|
605
|
-
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
606
|
-
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
607
|
-
sortOrder: tier.sortOrder,
|
|
608
|
-
active: tier.available,
|
|
609
|
-
createdAt: receivedAt
|
|
610
|
-
} });
|
|
611
|
-
const retract = [
|
|
612
|
-
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
613
|
-
...!tier.blurb ? ["blurb"] : []
|
|
614
|
-
];
|
|
615
|
-
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
616
|
-
}
|
|
617
|
-
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
618
|
-
const attrs = groupAttrs(document);
|
|
619
|
-
ops.push({
|
|
620
|
-
t: "update",
|
|
621
|
-
ns: "groups",
|
|
622
|
-
id: groupRef,
|
|
623
|
-
attrs
|
|
624
|
-
});
|
|
625
|
-
if (!("stripePriceId" in attrs)) {
|
|
626
|
-
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
627
|
-
}
|
|
628
|
-
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
629
|
-
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
630
|
-
id,
|
|
631
|
-
sourceId: document.source.appId,
|
|
632
|
-
targetId: document.target.chapterId,
|
|
633
|
-
environment: document.target.environment,
|
|
634
|
-
revision: document.revision,
|
|
635
|
-
digest,
|
|
636
|
-
document,
|
|
637
|
-
active: true,
|
|
638
|
-
receivedAt
|
|
639
|
-
} });
|
|
640
|
-
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// src/session.ts
|
|
644
|
-
function applicationSummary(app) {
|
|
645
|
-
return {
|
|
646
|
-
id: app.id,
|
|
647
|
-
firstName: app.firstName ?? null,
|
|
648
|
-
lastName: app.lastName ?? null,
|
|
649
|
-
email: app.email ?? null,
|
|
650
|
-
status: app.status,
|
|
651
|
-
createdAt: app.createdAt ?? null,
|
|
652
|
-
meetingLink: app.meetingLink ?? null,
|
|
653
|
-
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
654
|
-
renewalAt: app.renewalAt ?? null,
|
|
655
|
-
canceled: app.canceled === true
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
function memberApplication(app, meeting, defaultTimezone) {
|
|
659
|
-
const summary = applicationSummary(app);
|
|
660
|
-
let meetingAt = app.meetingAt ?? null;
|
|
661
|
-
let meetUrl = null;
|
|
662
|
-
let timezone = defaultTimezone;
|
|
663
|
-
if (meeting) {
|
|
664
|
-
timezone = meeting.timezone ?? timezone;
|
|
665
|
-
if (meeting.status === "scheduled") {
|
|
666
|
-
meetingAt = meeting.startAt ?? null;
|
|
667
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
668
|
-
} else {
|
|
669
|
-
meetingAt = null;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return { ...summary, meetingAt, meetUrl, timezone };
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// src/clerk.ts
|
|
676
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
677
|
-
function clerkInviteRequest(input) {
|
|
678
|
-
return {
|
|
679
|
-
path: "/v1/invitations",
|
|
680
|
-
body: {
|
|
681
|
-
email_address: input.email,
|
|
682
|
-
notify: true,
|
|
683
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
684
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
685
|
-
}
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
689
|
-
const { path, body } = clerkInviteRequest(input);
|
|
690
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
691
|
-
method: "POST",
|
|
692
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
693
|
-
body: JSON.stringify(body)
|
|
694
|
-
});
|
|
695
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
696
|
-
}
|
|
697
|
-
function clerkUserRequest(input) {
|
|
698
|
-
return {
|
|
699
|
-
path: "/v1/users",
|
|
700
|
-
body: {
|
|
701
|
-
email_address: [input.email],
|
|
702
|
-
skip_password_requirement: true,
|
|
703
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
704
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
705
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
706
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
711
|
-
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
712
|
-
method: "PATCH",
|
|
713
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
714
|
-
body: JSON.stringify({
|
|
715
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
716
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
717
|
-
})
|
|
718
|
-
});
|
|
719
|
-
return res.ok;
|
|
720
|
-
}
|
|
721
|
-
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
722
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
723
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
724
|
-
if (!found.ok) return false;
|
|
725
|
-
const users = await found.json().catch(() => null);
|
|
726
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
727
|
-
if (!id) return false;
|
|
728
|
-
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
729
|
-
}
|
|
730
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
731
|
-
const { path, body } = clerkUserRequest(input);
|
|
732
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
733
|
-
method: "POST",
|
|
734
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
735
|
-
body: JSON.stringify(body)
|
|
736
|
-
});
|
|
737
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
738
|
-
const healed = heal(res.status);
|
|
739
|
-
const repair = input.repairMetadata ?? {
|
|
740
|
-
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
741
|
-
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
742
|
-
};
|
|
743
|
-
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
744
|
-
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
745
|
-
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
746
|
-
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
747
|
-
}, fetchImpl).catch(() => false);
|
|
748
|
-
return { ...healed, refreshed };
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// src/network.ts
|
|
752
|
-
var import_crm2 = require("@odla-ai/crm");
|
|
753
|
-
|
|
754
|
-
// src/network-contract.ts
|
|
755
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
756
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
757
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
758
|
-
};
|
|
759
|
-
|
|
760
|
-
// src/network.ts
|
|
761
|
-
function sharedPersonInput(person) {
|
|
762
|
-
const email = person.email.toLowerCase();
|
|
763
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
764
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
765
|
-
if (input.name === "") input.name = email;
|
|
766
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
767
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
768
|
-
if (person.phone) input.phone = person.phone;
|
|
769
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
770
|
-
return input;
|
|
771
|
-
}
|
|
772
|
-
function shortHash(value) {
|
|
773
|
-
let a = 2166136261;
|
|
774
|
-
let b = 2654435769;
|
|
775
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
776
|
-
const n = value.charCodeAt(i);
|
|
777
|
-
a = Math.imul(a ^ n, 16777619);
|
|
778
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
779
|
-
}
|
|
780
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
781
|
-
}
|
|
782
|
-
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
783
|
-
const typeKey = type.toLowerCase();
|
|
784
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
785
|
-
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
786
|
-
const raw = `${prefix}:${hubRecordId}`;
|
|
787
|
-
if (readable && raw.length <= 64) return raw;
|
|
788
|
-
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
789
|
-
}
|
|
790
|
-
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
791
|
-
if ("input" in record && record.version === 2) {
|
|
792
|
-
return {
|
|
793
|
-
version: 2,
|
|
794
|
-
sourceId: record.source.siteId,
|
|
795
|
-
sourceRecordId: record.source.recordId,
|
|
796
|
-
type: record.type,
|
|
797
|
-
input: record.input
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
if ("input" in record) {
|
|
801
|
-
return {
|
|
802
|
-
version: 1,
|
|
803
|
-
sourceId: fallbackSourceId,
|
|
804
|
-
sourceRecordId: record.hubRecordId,
|
|
805
|
-
type: record.type,
|
|
806
|
-
input: record.input
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
if ("type" in record && record.type === "company") {
|
|
810
|
-
const input = { name: record.name };
|
|
811
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
812
|
-
if (record[key]) input[key] = record[key];
|
|
813
|
-
}
|
|
814
|
-
return {
|
|
815
|
-
version: 1,
|
|
816
|
-
sourceId: fallbackSourceId,
|
|
817
|
-
sourceRecordId: record.hubRecordId,
|
|
818
|
-
type: "company",
|
|
819
|
-
input
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
return {
|
|
823
|
-
version: 1,
|
|
824
|
-
sourceId: fallbackSourceId,
|
|
825
|
-
sourceRecordId: record.hubRecordId,
|
|
826
|
-
type: "person",
|
|
827
|
-
input: sharedPersonInput(record)
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
831
|
-
if (target.fields && !target.fields[record.type]) {
|
|
832
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
961
|
+
function sharedRecordFromCrm(crm, record2, target, sourceId) {
|
|
962
|
+
if (target.fields && !target.fields[record2.type]) {
|
|
963
|
+
throw new Error(`${target.name} does not accept "${record2.type}" records`);
|
|
833
964
|
}
|
|
834
|
-
const def = crm.type(
|
|
835
|
-
const fields = target.fields?.[
|
|
965
|
+
const def = crm.type(record2.type);
|
|
966
|
+
const fields = target.fields?.[record2.type] ?? DEFAULT_SHARE_FIELDS[record2.type];
|
|
836
967
|
if (!fields) {
|
|
837
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
968
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record2.type}" records`);
|
|
838
969
|
}
|
|
839
970
|
const nameField = def.nameField ?? "name";
|
|
840
971
|
const input = {};
|
|
841
972
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
842
|
-
const value =
|
|
973
|
+
const value = record2.fields?.[field];
|
|
843
974
|
if (value !== void 0) input[field] = value;
|
|
844
975
|
}
|
|
845
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
846
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
976
|
+
if (input[nameField] === void 0) input[nameField] = record2.name;
|
|
977
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record2.id }, type: record2.type, input } : { version: 1, type: record2.type, hubRecordId: record2.id, input };
|
|
847
978
|
}
|
|
848
979
|
async function upsertPerson(deps, opts) {
|
|
849
980
|
const email = opts.email.toLowerCase();
|
|
@@ -857,11 +988,11 @@ async function upsertPerson(deps, opts) {
|
|
|
857
988
|
const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
858
989
|
return { recordId: created.id };
|
|
859
990
|
}
|
|
860
|
-
async function findSharedRecord(deps,
|
|
991
|
+
async function findSharedRecord(deps, record2, tag) {
|
|
861
992
|
const structured = await (0, import_crm2.getRecordByOrigin)(
|
|
862
993
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
863
|
-
|
|
864
|
-
|
|
994
|
+
record2.sourceId,
|
|
995
|
+
record2.sourceRecordId
|
|
865
996
|
);
|
|
866
997
|
if (structured) return structured;
|
|
867
998
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -870,53 +1001,53 @@ async function findSharedRecord(deps, record, tag) {
|
|
|
870
1001
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
871
1002
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
872
1003
|
}
|
|
873
|
-
const def = deps.crm.type(
|
|
1004
|
+
const def = deps.crm.type(record2.type);
|
|
874
1005
|
const emailField = def.emailField;
|
|
875
|
-
if (emailField && typeof
|
|
876
|
-
const primaryEmail =
|
|
1006
|
+
if (emailField && typeof record2.input[emailField] === "string") {
|
|
1007
|
+
const primaryEmail = record2.input[emailField].toLowerCase();
|
|
877
1008
|
const found = await deps.db.query({
|
|
878
|
-
crm_record: { $: { where: { type:
|
|
1009
|
+
crm_record: { $: { where: { type: record2.type, primaryEmail }, limit: 1 } }
|
|
879
1010
|
});
|
|
880
1011
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
881
1012
|
}
|
|
882
|
-
const domain =
|
|
1013
|
+
const domain = record2.input.domain;
|
|
883
1014
|
const domainSlot = def.fields.domain?.slot;
|
|
884
1015
|
if (typeof domain === "string" && domainSlot) {
|
|
885
1016
|
const found = await deps.db.query({
|
|
886
|
-
crm_record: { $: { where: { type:
|
|
1017
|
+
crm_record: { $: { where: { type: record2.type, [domainSlot]: domain }, limit: 1 } }
|
|
887
1018
|
});
|
|
888
1019
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
889
1020
|
}
|
|
890
1021
|
const nameField = def.nameField ?? "name";
|
|
891
|
-
const name =
|
|
892
|
-
if (
|
|
1022
|
+
const name = record2.input[nameField];
|
|
1023
|
+
if (record2.type === "company" && typeof name === "string" && name.trim()) {
|
|
893
1024
|
const found = await deps.db.query({
|
|
894
|
-
crm_record: { $: { where: { type:
|
|
1025
|
+
crm_record: { $: { where: { type: record2.type, name: name.trim() }, limit: 1 } }
|
|
895
1026
|
});
|
|
896
1027
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
897
1028
|
}
|
|
898
1029
|
return void 0;
|
|
899
1030
|
}
|
|
900
1031
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
901
|
-
const
|
|
902
|
-
if (!
|
|
1032
|
+
const record2 = normalizeSharedRecord(shared, options.sourceId);
|
|
1033
|
+
if (!record2.type.trim() || !record2.sourceId.trim() || !record2.sourceRecordId.trim()) {
|
|
903
1034
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
904
1035
|
}
|
|
905
|
-
if (options.sourceId && options.sourceId !==
|
|
1036
|
+
if (options.sourceId && options.sourceId !== record2.sourceId) {
|
|
906
1037
|
throw new Error("signed sender does not match payload source");
|
|
907
1038
|
}
|
|
908
|
-
const tag = networkSourceTag(
|
|
1039
|
+
const tag = networkSourceTag(record2.type, record2.sourceRecordId, record2.version === 2 ? record2.sourceId : void 0);
|
|
909
1040
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
910
|
-
const existing = await findSharedRecord(deps,
|
|
1041
|
+
const existing = await findSharedRecord(deps, record2, tag);
|
|
911
1042
|
let recordId;
|
|
912
1043
|
if (existing && typeof existing.id === "string") {
|
|
913
|
-
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input:
|
|
1044
|
+
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record2.input });
|
|
914
1045
|
recordId = existing.id;
|
|
915
1046
|
} else {
|
|
916
|
-
recordId = `network_${shortHash(`${
|
|
1047
|
+
recordId = `network_${shortHash(`${record2.sourceId}\0${record2.type}\0${record2.sourceRecordId}`)}`;
|
|
917
1048
|
await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
|
|
918
|
-
type:
|
|
919
|
-
input:
|
|
1049
|
+
type: record2.type,
|
|
1050
|
+
input: record2.input,
|
|
920
1051
|
mutationId: `share-create:${tag}`
|
|
921
1052
|
});
|
|
922
1053
|
}
|
|
@@ -926,10 +1057,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
926
1057
|
);
|
|
927
1058
|
await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
|
|
928
1059
|
recordId,
|
|
929
|
-
sourceId:
|
|
930
|
-
sourceRecordId:
|
|
1060
|
+
sourceId: record2.sourceId,
|
|
1061
|
+
sourceRecordId: record2.sourceRecordId,
|
|
931
1062
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
932
|
-
payloadVersion:
|
|
1063
|
+
payloadVersion: record2.version
|
|
933
1064
|
});
|
|
934
1065
|
return { recordId };
|
|
935
1066
|
}
|
|
@@ -952,14 +1083,100 @@ async function projectApplicant(deps, applicant) {
|
|
|
952
1083
|
}
|
|
953
1084
|
}
|
|
954
1085
|
|
|
955
|
-
// src/
|
|
956
|
-
|
|
957
|
-
|
|
1086
|
+
// src/crm-sync.ts
|
|
1087
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1088
|
+
var crmDeps = (db, ctx) => ({
|
|
1089
|
+
crm: ctx.chapter.crm,
|
|
1090
|
+
db,
|
|
1091
|
+
now: () => Date.now(),
|
|
1092
|
+
newId: () => crypto.randomUUID(),
|
|
1093
|
+
chapter: ctx.chapter
|
|
1094
|
+
});
|
|
1095
|
+
function personInputFromApp(chapter, app) {
|
|
1096
|
+
const input = sharedPersonInput({
|
|
1097
|
+
email: str(app.email),
|
|
1098
|
+
firstName: str(app.firstName) || void 0,
|
|
1099
|
+
lastName: str(app.lastName) || void 0,
|
|
1100
|
+
phone: str(app.phone) || void 0,
|
|
1101
|
+
linkedin: str(app.linkedin) || void 0,
|
|
1102
|
+
hubRecordId: str(app.id)
|
|
1103
|
+
});
|
|
1104
|
+
for (const f of chapter.application.crmFields) {
|
|
1105
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
1106
|
+
}
|
|
1107
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1108
|
+
return input;
|
|
958
1109
|
}
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1110
|
+
function billingColumns(app) {
|
|
1111
|
+
const status = str(app.status);
|
|
1112
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1113
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1114
|
+
const cols = { billingStatus };
|
|
1115
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1116
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1117
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1118
|
+
return cols;
|
|
1119
|
+
}
|
|
1120
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
1121
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
1122
|
+
if (!emailKey) return null;
|
|
1123
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1124
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1125
|
+
const { crm_record } = await deps.db.query({
|
|
1126
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1127
|
+
});
|
|
1128
|
+
const existing = crm_record?.[0] ?? null;
|
|
1129
|
+
const stage = opts.stage || void 0;
|
|
1130
|
+
let recordId;
|
|
1131
|
+
if (existing && typeof existing.id === "string") {
|
|
1132
|
+
recordId = existing.id;
|
|
1133
|
+
await (0, import_crm3.updateRecord)(recordDeps, { id: recordId, input });
|
|
1134
|
+
} else {
|
|
1135
|
+
const created = await (0, import_crm3.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1136
|
+
recordId = created.id;
|
|
1137
|
+
}
|
|
1138
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1139
|
+
await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1140
|
+
}
|
|
1141
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1142
|
+
await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1143
|
+
return recordId;
|
|
1144
|
+
}
|
|
1145
|
+
async function backfillCrm(deps) {
|
|
1146
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1147
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1148
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1149
|
+
]);
|
|
1150
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1151
|
+
let synced = 0;
|
|
1152
|
+
const errors = [];
|
|
1153
|
+
const run = async (app, stage) => {
|
|
1154
|
+
const key = str(app.email).toLowerCase();
|
|
1155
|
+
if (!key || seen.has(key)) return;
|
|
1156
|
+
seen.add(key);
|
|
1157
|
+
try {
|
|
1158
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1159
|
+
synced += 1;
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1165
|
+
for (const u of usersRes.$users ?? []) {
|
|
1166
|
+
if (u.deleted === true) continue;
|
|
1167
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1168
|
+
}
|
|
1169
|
+
return { synced, errors };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/email.ts
|
|
1173
|
+
function render(template, vars) {
|
|
1174
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
1175
|
+
}
|
|
1176
|
+
function groupVars(group, vars) {
|
|
1177
|
+
return {
|
|
1178
|
+
...vars,
|
|
1179
|
+
refundPolicyText: group.refundPolicyText ?? "",
|
|
963
1180
|
commitmentText: group.commitmentText ?? "",
|
|
964
1181
|
normsText: group.normsText ?? ""
|
|
965
1182
|
};
|
|
@@ -982,10 +1199,10 @@ function planDelivery(input) {
|
|
|
982
1199
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
983
1200
|
const to = redirect ? input.group.debugEmail : input.to;
|
|
984
1201
|
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
985
|
-
const
|
|
1202
|
+
const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
986
1203
|
|
|
987
1204
|
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
988
|
-
return { deliver: true, transport, to, subject, text:
|
|
1205
|
+
return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
|
|
989
1206
|
}
|
|
990
1207
|
|
|
991
1208
|
// src/notify.ts
|
|
@@ -1020,39 +1237,372 @@ async function sendTemplated(deps, input) {
|
|
|
1020
1237
|
} catch (e) {
|
|
1021
1238
|
error = e instanceof Error ? e.message : String(e);
|
|
1022
1239
|
}
|
|
1023
|
-
}
|
|
1024
|
-
const id = deps.newId();
|
|
1025
|
-
const row = {
|
|
1026
|
-
id,
|
|
1027
|
-
groupId: input.group.id,
|
|
1028
|
-
to: decision.to,
|
|
1029
|
-
template: input.template,
|
|
1030
|
-
subject: decision.subject,
|
|
1031
|
-
body: decision.text,
|
|
1032
|
-
transport: decision.transport,
|
|
1033
|
-
redirected: decision.redirected,
|
|
1034
|
-
dedupeKey: input.dedupeKey,
|
|
1035
|
-
sentAt: deps.now(),
|
|
1036
|
-
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1037
|
-
...messageId ? { messageId } : {},
|
|
1038
|
-
...error ? { error } : {}
|
|
1240
|
+
}
|
|
1241
|
+
const id = deps.newId();
|
|
1242
|
+
const row = {
|
|
1243
|
+
id,
|
|
1244
|
+
groupId: input.group.id,
|
|
1245
|
+
to: decision.to,
|
|
1246
|
+
template: input.template,
|
|
1247
|
+
subject: decision.subject,
|
|
1248
|
+
body: decision.text,
|
|
1249
|
+
transport: decision.transport,
|
|
1250
|
+
redirected: decision.redirected,
|
|
1251
|
+
dedupeKey: input.dedupeKey,
|
|
1252
|
+
sentAt: deps.now(),
|
|
1253
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1254
|
+
...messageId ? { messageId } : {},
|
|
1255
|
+
...error ? { error } : {}
|
|
1256
|
+
};
|
|
1257
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1258
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
1259
|
+
}
|
|
1260
|
+
function emailGroupFrom(row) {
|
|
1261
|
+
const str3 = (v) => typeof v === "string" ? v : void 0;
|
|
1262
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
1263
|
+
return {
|
|
1264
|
+
id: String(row.id),
|
|
1265
|
+
name: String(row.name ?? ""),
|
|
1266
|
+
replyTo: str3(row.replyTo) ?? "",
|
|
1267
|
+
debugEmail: str3(row.debugEmail),
|
|
1268
|
+
refundPolicyText: str3(row.refundPolicyText),
|
|
1269
|
+
commitmentText: str3(row.commitmentText),
|
|
1270
|
+
normsText: str3(row.normsText),
|
|
1271
|
+
emailTemplates: templates
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/pipeline.ts
|
|
1276
|
+
function canTransition(from, to, p) {
|
|
1277
|
+
const fi = p.stages.indexOf(from);
|
|
1278
|
+
const ti = p.stages.indexOf(to);
|
|
1279
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
1280
|
+
}
|
|
1281
|
+
function canBook(status, p) {
|
|
1282
|
+
return p.bookableFrom.includes(status);
|
|
1283
|
+
}
|
|
1284
|
+
function canApprove(status, p) {
|
|
1285
|
+
return p.approvableFrom.includes(status);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// src/approve-application.ts
|
|
1289
|
+
async function approveApplication(input) {
|
|
1290
|
+
const { db, env, ctx, app } = input;
|
|
1291
|
+
const id = String(app.id ?? "");
|
|
1292
|
+
const status = String(app.status ?? "");
|
|
1293
|
+
if (!id) return { ok: false, status: 404, error: "not found" };
|
|
1294
|
+
if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
|
|
1295
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1296
|
+
}
|
|
1297
|
+
if (input.reason === "interview_waiver") {
|
|
1298
|
+
if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
|
|
1299
|
+
if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
|
|
1300
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const write = await db.transact(
|
|
1304
|
+
[{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
|
|
1305
|
+
{ mutationId: `approve:${id}` }
|
|
1306
|
+
);
|
|
1307
|
+
const approved = { ...app, status: "approved" };
|
|
1308
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
|
|
1309
|
+
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
1310
|
+
let rolePromoted = false;
|
|
1311
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1312
|
+
if (promoteTo !== false && secret) {
|
|
1313
|
+
let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
|
|
1314
|
+
if (!userId && typeof app.email === "string") {
|
|
1315
|
+
const found = await clerkGetUserByEmail(secret, app.email);
|
|
1316
|
+
userId = found?.id ?? null;
|
|
1317
|
+
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
1318
|
+
}
|
|
1319
|
+
if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
|
|
1320
|
+
}
|
|
1321
|
+
let emailLogged = false;
|
|
1322
|
+
const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
|
|
1323
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1324
|
+
if (send !== false && group && typeof app.email === "string") {
|
|
1325
|
+
const result = await sendTemplated(
|
|
1326
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1327
|
+
{
|
|
1328
|
+
group: emailGroupFrom(group),
|
|
1329
|
+
template: send,
|
|
1330
|
+
to: app.email,
|
|
1331
|
+
vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
|
|
1332
|
+
applicationId: id,
|
|
1333
|
+
dedupeKey: `approve:${id}`
|
|
1334
|
+
}
|
|
1335
|
+
);
|
|
1336
|
+
emailLogged = result.sent;
|
|
1337
|
+
}
|
|
1338
|
+
return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// src/join-continuation.ts
|
|
1342
|
+
function resolveJoinContinuation(input) {
|
|
1343
|
+
if (input.status === "approved") return "accepted";
|
|
1344
|
+
if (input.interviewWaived) {
|
|
1345
|
+
if (!input.payable) return "approve";
|
|
1346
|
+
if (input.status === input.initialStatus) {
|
|
1347
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1348
|
+
}
|
|
1349
|
+
return "approve";
|
|
1350
|
+
}
|
|
1351
|
+
if (!input.payable) return "booking";
|
|
1352
|
+
if (input.status === input.initialStatus) {
|
|
1353
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1354
|
+
}
|
|
1355
|
+
if (canBook(input.status, input.pipeline)) return "booking";
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// src/join-continuation-server.ts
|
|
1360
|
+
async function continueJoin(db, ctx, env, origin, app, group) {
|
|
1361
|
+
if (typeof app.giftOfferId === "string") {
|
|
1362
|
+
if (typeof app.giftClaimId === "string") return "gift";
|
|
1363
|
+
return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
|
|
1364
|
+
}
|
|
1365
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1366
|
+
const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
|
|
1367
|
+
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1368
|
+
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1369
|
+
const state = resolveJoinContinuation({
|
|
1370
|
+
status: String(app.status ?? ""),
|
|
1371
|
+
initialStatus: ctx.chapter.pipeline.initial,
|
|
1372
|
+
pipeline: ctx.chapter.pipeline,
|
|
1373
|
+
payable,
|
|
1374
|
+
subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
|
|
1375
|
+
interviewWaived: typeof app.interviewWaivedAt === "number"
|
|
1376
|
+
});
|
|
1377
|
+
if (state !== "approve") return state;
|
|
1378
|
+
const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
|
|
1379
|
+
return approved.ok ? "accepted" : null;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// src/signup-control-store.ts
|
|
1383
|
+
function asStored(row) {
|
|
1384
|
+
if (!row || typeof row.document !== "object" || row.document === null) return null;
|
|
1385
|
+
return row;
|
|
1386
|
+
}
|
|
1387
|
+
async function hasSignupControl(db, targetId, environment, active) {
|
|
1388
|
+
const where = { targetId, environment, ...active ? { active: true } : {} };
|
|
1389
|
+
if (db.aggregate) {
|
|
1390
|
+
return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
|
|
1391
|
+
}
|
|
1392
|
+
const rows4 = (await db.query({ signupControls: {} })).signupControls;
|
|
1393
|
+
return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
1394
|
+
}
|
|
1395
|
+
async function loadLatestSignupControl(db, targetId, environment) {
|
|
1396
|
+
if (!await hasSignupControl(db, targetId, environment)) return null;
|
|
1397
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1398
|
+
where: { targetId, environment },
|
|
1399
|
+
order: { revision: "desc" },
|
|
1400
|
+
limit: 1
|
|
1401
|
+
} } })).signupControls;
|
|
1402
|
+
const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1403
|
+
return asStored(latest);
|
|
1404
|
+
}
|
|
1405
|
+
async function loadActiveSignupControl(db, targetId, environment) {
|
|
1406
|
+
if (!await hasSignupControl(db, targetId, environment, true)) return null;
|
|
1407
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1408
|
+
where: { targetId, environment, active: true },
|
|
1409
|
+
order: { revision: "desc" },
|
|
1410
|
+
limit: 1
|
|
1411
|
+
} } })).signupControls;
|
|
1412
|
+
const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1413
|
+
return asStored(active);
|
|
1414
|
+
}
|
|
1415
|
+
function controlledApplication(base, document) {
|
|
1416
|
+
const delegated = document?.application;
|
|
1417
|
+
return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
|
|
1418
|
+
}
|
|
1419
|
+
function validateControlledApplication(chapter, document) {
|
|
1420
|
+
if (!document.application) return [];
|
|
1421
|
+
const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
|
|
1422
|
+
return [...document.application.required, ...document.application.optional].filter((field) => !declared.has(field)).map((field) => `document.application field "${field}" is not declared by this chapter schema`);
|
|
1423
|
+
}
|
|
1424
|
+
function groupAttrs(document) {
|
|
1425
|
+
const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
1426
|
+
const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
|
|
1427
|
+
return {
|
|
1428
|
+
...first4 ? { standardPriceCents: first4.priceCents } : {},
|
|
1429
|
+
foundingDiscountCents: 0,
|
|
1430
|
+
...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
|
|
1431
|
+
...document.policy ?? {},
|
|
1432
|
+
...document.scheduling ? { schedulingJson: document.scheduling } : {}
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
var byPublicId = (ns, value) => ({
|
|
1436
|
+
ns,
|
|
1437
|
+
attr: "id",
|
|
1438
|
+
value
|
|
1439
|
+
});
|
|
1440
|
+
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
1441
|
+
const { document, digest } = envelope;
|
|
1442
|
+
const currentRows = (await db.query({ tiers: { $: {
|
|
1443
|
+
where: { groupId: document.target.chapterId },
|
|
1444
|
+
limit: 100
|
|
1445
|
+
} } })).tiers ?? [];
|
|
1446
|
+
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
1447
|
+
const ops = [];
|
|
1448
|
+
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
1449
|
+
for (const row of currentRows) {
|
|
1450
|
+
const id2 = String(row.id ?? "");
|
|
1451
|
+
if (id2 && !incoming.has(id2)) {
|
|
1452
|
+
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
for (const tier of document.tiers) {
|
|
1456
|
+
const tierRef = byPublicId("tiers", tier.id);
|
|
1457
|
+
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
1458
|
+
id: tier.id,
|
|
1459
|
+
groupId: document.target.chapterId,
|
|
1460
|
+
name: tier.name,
|
|
1461
|
+
priceCents: tier.priceCents,
|
|
1462
|
+
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
1463
|
+
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
1464
|
+
sortOrder: tier.sortOrder,
|
|
1465
|
+
active: tier.available,
|
|
1466
|
+
createdAt: receivedAt
|
|
1467
|
+
} });
|
|
1468
|
+
const retract = [
|
|
1469
|
+
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
1470
|
+
...!tier.blurb ? ["blurb"] : []
|
|
1471
|
+
];
|
|
1472
|
+
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
1473
|
+
}
|
|
1474
|
+
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
1475
|
+
const attrs = groupAttrs(document);
|
|
1476
|
+
ops.push({
|
|
1477
|
+
t: "update",
|
|
1478
|
+
ns: "groups",
|
|
1479
|
+
id: groupRef,
|
|
1480
|
+
attrs
|
|
1481
|
+
});
|
|
1482
|
+
if (!("stripePriceId" in attrs)) {
|
|
1483
|
+
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
1484
|
+
}
|
|
1485
|
+
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
1486
|
+
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
1487
|
+
id,
|
|
1488
|
+
sourceId: document.source.appId,
|
|
1489
|
+
targetId: document.target.chapterId,
|
|
1490
|
+
environment: document.target.environment,
|
|
1491
|
+
revision: document.revision,
|
|
1492
|
+
digest,
|
|
1493
|
+
document,
|
|
1494
|
+
active: true,
|
|
1495
|
+
receivedAt
|
|
1496
|
+
} });
|
|
1497
|
+
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/session.ts
|
|
1501
|
+
function applicationSummary(app) {
|
|
1502
|
+
return {
|
|
1503
|
+
id: app.id,
|
|
1504
|
+
firstName: app.firstName ?? null,
|
|
1505
|
+
lastName: app.lastName ?? null,
|
|
1506
|
+
email: app.email ?? null,
|
|
1507
|
+
status: app.status,
|
|
1508
|
+
createdAt: app.createdAt ?? null,
|
|
1509
|
+
meetingLink: app.meetingLink ?? null,
|
|
1510
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1511
|
+
renewalAt: app.renewalAt ?? null,
|
|
1512
|
+
canceled: app.canceled === true
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
1516
|
+
const summary = applicationSummary(app);
|
|
1517
|
+
let meetingAt = app.meetingAt ?? null;
|
|
1518
|
+
let meetUrl = null;
|
|
1519
|
+
let timezone = defaultTimezone;
|
|
1520
|
+
if (meeting) {
|
|
1521
|
+
timezone = meeting.timezone ?? timezone;
|
|
1522
|
+
if (meeting.status === "scheduled") {
|
|
1523
|
+
meetingAt = meeting.startAt ?? null;
|
|
1524
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
1525
|
+
} else {
|
|
1526
|
+
meetingAt = null;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/clerk.ts
|
|
1533
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
1534
|
+
function clerkInviteRequest(input) {
|
|
1535
|
+
return {
|
|
1536
|
+
path: "/v1/invitations",
|
|
1537
|
+
body: {
|
|
1538
|
+
email_address: input.email,
|
|
1539
|
+
notify: true,
|
|
1540
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
1541
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
1542
|
+
}
|
|
1039
1543
|
};
|
|
1040
|
-
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1041
|
-
return error ? { sent: false, reason: error } : { sent: true };
|
|
1042
1544
|
}
|
|
1043
|
-
function
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1545
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
1546
|
+
const { path, body } = clerkInviteRequest(input);
|
|
1547
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1548
|
+
method: "POST",
|
|
1549
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1550
|
+
body: JSON.stringify(body)
|
|
1551
|
+
});
|
|
1552
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
1553
|
+
}
|
|
1554
|
+
function clerkUserRequest(input) {
|
|
1046
1555
|
return {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1556
|
+
path: "/v1/users",
|
|
1557
|
+
body: {
|
|
1558
|
+
email_address: [input.email],
|
|
1559
|
+
skip_password_requirement: true,
|
|
1560
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
1561
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
1562
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1563
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
1568
|
+
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
1569
|
+
method: "PATCH",
|
|
1570
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1571
|
+
body: JSON.stringify({
|
|
1572
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1573
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1574
|
+
})
|
|
1575
|
+
});
|
|
1576
|
+
return res.ok;
|
|
1577
|
+
}
|
|
1578
|
+
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
1579
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
1580
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
1581
|
+
if (!found.ok) return false;
|
|
1582
|
+
const users = await found.json().catch(() => null);
|
|
1583
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
1584
|
+
if (!id) return false;
|
|
1585
|
+
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
1586
|
+
}
|
|
1587
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
1588
|
+
const { path, body } = clerkUserRequest(input);
|
|
1589
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1590
|
+
method: "POST",
|
|
1591
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1592
|
+
body: JSON.stringify(body)
|
|
1593
|
+
});
|
|
1594
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
1595
|
+
const healed = heal(res.status);
|
|
1596
|
+
const repair = input.repairMetadata ?? {
|
|
1597
|
+
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
1598
|
+
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
1055
1599
|
};
|
|
1600
|
+
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
1601
|
+
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
1602
|
+
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
1603
|
+
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
1604
|
+
}, fetchImpl).catch(() => false);
|
|
1605
|
+
return { ...healed, refreshed };
|
|
1056
1606
|
}
|
|
1057
1607
|
|
|
1058
1608
|
// src/worker-provisioning.ts
|
|
@@ -1182,8 +1732,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1182
1732
|
|
|
1183
1733
|
// src/worker-routes.ts
|
|
1184
1734
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1185
|
-
|
|
1186
|
-
|
|
1735
|
+
let apps = (await db.query({ applications: { $: {
|
|
1736
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
1737
|
+
limit: 20
|
|
1738
|
+
} } })).applications;
|
|
1739
|
+
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
1740
|
+
const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
1187
1741
|
if (!app) return null;
|
|
1188
1742
|
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
1189
1743
|
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
@@ -1239,7 +1793,7 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
1239
1793
|
const crmBase = ctx.crmBase;
|
|
1240
1794
|
if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
|
|
1241
1795
|
const db = ctx.makeDb(env);
|
|
1242
|
-
const routes = (0,
|
|
1796
|
+
const routes = (0, import_crm4.createCrmRoutes)({
|
|
1243
1797
|
crm: ctx.chapter.crm,
|
|
1244
1798
|
db,
|
|
1245
1799
|
authorize: async (r) => {
|
|
@@ -1285,7 +1839,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1285
1839
|
membershipPage: activeControl.document.membershipPage,
|
|
1286
1840
|
application: activeControl.document.application,
|
|
1287
1841
|
theme: activeControl.document.theme
|
|
1288
|
-
} } : {}
|
|
1842
|
+
} } : {},
|
|
1843
|
+
giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
|
|
1289
1844
|
});
|
|
1290
1845
|
}
|
|
1291
1846
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
@@ -1324,18 +1879,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1324
1879
|
})).groups;
|
|
1325
1880
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1326
1881
|
if (!group) return json({ error: "not found" }, 404);
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1331
|
-
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1332
|
-
if (!payable) return json({ state: "booking", applicationId });
|
|
1333
|
-
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1334
|
-
return json({ state: "paymentPending", applicationId });
|
|
1335
|
-
}
|
|
1336
|
-
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
1337
|
-
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
1338
|
-
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
1882
|
+
const state = await continueJoin(db, ctx, env, url.origin, app, group);
|
|
1883
|
+
if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
|
|
1884
|
+
return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
|
|
1339
1885
|
}
|
|
1340
1886
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
1341
1887
|
const raw = await req.text();
|
|
@@ -1351,12 +1897,19 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1351
1897
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
1352
1898
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
1353
1899
|
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
1900
|
+
const now = Date.now();
|
|
1901
|
+
const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
|
|
1902
|
+
const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
|
|
1903
|
+
if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
|
|
1904
|
+
const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
|
|
1354
1905
|
const result = await submitApplication(db, effectiveChapter, parsed, {
|
|
1355
1906
|
submissionId,
|
|
1356
1907
|
groupId: chapter.id,
|
|
1357
|
-
now
|
|
1908
|
+
now,
|
|
1358
1909
|
newId: () => crypto.randomUUID(),
|
|
1359
|
-
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1910
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
1911
|
+
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
1912
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
1360
1913
|
});
|
|
1361
1914
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1362
1915
|
if (!result.duplicate) {
|
|
@@ -1365,10 +1918,16 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1365
1918
|
}
|
|
1366
1919
|
await provisionApplicant(db, effectiveChapter, result.id, parsed);
|
|
1367
1920
|
}
|
|
1921
|
+
const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
|
|
1922
|
+
const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
|
|
1923
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
1924
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1925
|
+
const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
|
|
1368
1926
|
return json({
|
|
1369
1927
|
id: result.id,
|
|
1370
1928
|
duplicate: result.duplicate,
|
|
1371
1929
|
status: result.status,
|
|
1930
|
+
...state ? { state } : {},
|
|
1372
1931
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
1373
1932
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
1374
1933
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -1424,8 +1983,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1424
1983
|
payload,
|
|
1425
1984
|
sourceId ? { sourceId } : {}
|
|
1426
1985
|
);
|
|
1427
|
-
const
|
|
1428
|
-
return json({ recordId, type:
|
|
1986
|
+
const record2 = normalizeSharedRecord(payload, sourceId);
|
|
1987
|
+
return json({ recordId, type: record2.type });
|
|
1429
1988
|
} catch (error) {
|
|
1430
1989
|
return json({
|
|
1431
1990
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1436,29 +1995,6 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1436
1995
|
// src/worker-routes-signup-control.ts
|
|
1437
1996
|
var import_db3 = require("@odla-ai/db");
|
|
1438
1997
|
|
|
1439
|
-
// src/signup-control-canonical.ts
|
|
1440
|
-
var encoder = new TextEncoder();
|
|
1441
|
-
function canonicalValue(value) {
|
|
1442
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
1443
|
-
return JSON.stringify(value);
|
|
1444
|
-
}
|
|
1445
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
1446
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
1447
|
-
const record = value;
|
|
1448
|
-
const entries = Object.keys(record).sort().flatMap((key) => {
|
|
1449
|
-
const item = record[key];
|
|
1450
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
1451
|
-
});
|
|
1452
|
-
return `{${entries.join(",")}}`;
|
|
1453
|
-
}
|
|
1454
|
-
function canonicalSignupControl(value) {
|
|
1455
|
-
return canonicalValue(value);
|
|
1456
|
-
}
|
|
1457
|
-
async function signupControlDigest(document) {
|
|
1458
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
1459
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
1998
|
// src/signup-control-contract.ts
|
|
1463
1999
|
var SIGNUP_CONTROL_PATH = "/api/signup-control";
|
|
1464
2000
|
|
|
@@ -1502,10 +2038,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
1502
2038
|
errors.push(`${path} must be a string`);
|
|
1503
2039
|
return void 0;
|
|
1504
2040
|
}
|
|
1505
|
-
const
|
|
1506
|
-
if (required && !
|
|
1507
|
-
if (
|
|
1508
|
-
return
|
|
2041
|
+
const text3 = value.trim();
|
|
2042
|
+
if (required && !text3) errors.push(`${path} is required`);
|
|
2043
|
+
if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2044
|
+
return text3;
|
|
1509
2045
|
}
|
|
1510
2046
|
function validateMembershipPageDocument(input, options = {}) {
|
|
1511
2047
|
const errors = [];
|
|
@@ -1554,8 +2090,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1554
2090
|
else {
|
|
1555
2091
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
1556
2092
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
1557
|
-
const
|
|
1558
|
-
return
|
|
2093
|
+
const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
2094
|
+
return text3 ? [text3] : [];
|
|
1559
2095
|
});
|
|
1560
2096
|
}
|
|
1561
2097
|
}
|
|
@@ -1629,7 +2165,7 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1629
2165
|
var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1630
2166
|
var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
1631
2167
|
var TIER = /^[a-z][a-z0-9-]{0,79}$/;
|
|
1632
|
-
var
|
|
2168
|
+
var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
|
|
1633
2169
|
var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
1634
2170
|
"schemaVersion",
|
|
1635
2171
|
"revision",
|
|
@@ -1641,14 +2177,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
1641
2177
|
"application",
|
|
1642
2178
|
"policy",
|
|
1643
2179
|
"scheduling",
|
|
1644
|
-
"theme"
|
|
2180
|
+
"theme",
|
|
2181
|
+
"giftOffers"
|
|
1645
2182
|
]);
|
|
1646
2183
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
1647
2184
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1648
2185
|
function unknownKeys(value, allowed, path, errors) {
|
|
1649
2186
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1650
2187
|
}
|
|
1651
|
-
function
|
|
2188
|
+
function text2(value, path, errors, max = 65536) {
|
|
1652
2189
|
if (typeof value !== "string" || !value.trim()) {
|
|
1653
2190
|
errors.push(`${path} is required`);
|
|
1654
2191
|
return "";
|
|
@@ -1671,23 +2208,23 @@ function tiersOf(value, errors) {
|
|
|
1671
2208
|
return [];
|
|
1672
2209
|
}
|
|
1673
2210
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
1674
|
-
const id =
|
|
2211
|
+
const id = text2(item.id, `${path}.id`, errors, 80);
|
|
1675
2212
|
if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
1676
2213
|
if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
1677
2214
|
seen.add(id);
|
|
1678
|
-
const name =
|
|
2215
|
+
const name = text2(item.name, `${path}.name`, errors, 160);
|
|
1679
2216
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
1680
2217
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
1681
2218
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
1682
2219
|
errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
|
|
1683
2220
|
}
|
|
1684
|
-
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !
|
|
2221
|
+
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
|
|
1685
2222
|
errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
1686
2223
|
}
|
|
1687
2224
|
if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
|
|
1688
2225
|
errors.push(`${path}.stripePriceId is required for an available paid tier`);
|
|
1689
2226
|
}
|
|
1690
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
2227
|
+
const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
1691
2228
|
return [{
|
|
1692
2229
|
id,
|
|
1693
2230
|
name,
|
|
@@ -1736,7 +2273,7 @@ function policyOf(value, errors) {
|
|
|
1736
2273
|
const result = {};
|
|
1737
2274
|
for (const key of allowed) {
|
|
1738
2275
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
1739
|
-
result[key] =
|
|
2276
|
+
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
1740
2277
|
}
|
|
1741
2278
|
return result;
|
|
1742
2279
|
}
|
|
@@ -1750,12 +2287,18 @@ function themeOf(value, errors) {
|
|
|
1750
2287
|
}
|
|
1751
2288
|
unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
|
|
1752
2289
|
if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
|
|
2290
|
+
let serializedLength = 0;
|
|
1753
2291
|
for (const [key, token] of Object.entries(tokens)) {
|
|
1754
|
-
if (!/^--[a-z0-9-]{
|
|
1755
|
-
|
|
1756
|
-
|
|
2292
|
+
if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
|
|
2293
|
+
errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
|
|
2294
|
+
}
|
|
2295
|
+
const rendered = String(token).trim();
|
|
2296
|
+
serializedLength += key.length + rendered.length + 4;
|
|
2297
|
+
if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
|
|
2298
|
+
errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
|
|
1757
2299
|
}
|
|
1758
2300
|
}
|
|
2301
|
+
if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
|
|
1759
2302
|
return { tokens };
|
|
1760
2303
|
}
|
|
1761
2304
|
function validateSignupControlDocument(input) {
|
|
@@ -1768,9 +2311,9 @@ function validateSignupControlDocument(input) {
|
|
|
1768
2311
|
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1769
2312
|
const source = recordOf(root.source);
|
|
1770
2313
|
const target = recordOf(root.target);
|
|
1771
|
-
const appId =
|
|
1772
|
-
const chapterId =
|
|
1773
|
-
const environment =
|
|
2314
|
+
const appId = text2(source?.appId, "document.source.appId", errors, 63);
|
|
2315
|
+
const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
2316
|
+
const environment = text2(target?.environment, "document.target.environment", errors, 63);
|
|
1774
2317
|
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1775
2318
|
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1776
2319
|
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
@@ -1791,6 +2334,8 @@ function validateSignupControlDocument(input) {
|
|
|
1791
2334
|
else scheduling = root.scheduling;
|
|
1792
2335
|
}
|
|
1793
2336
|
const theme = themeOf(root.theme, errors);
|
|
2337
|
+
const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
|
|
2338
|
+
if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
|
|
1794
2339
|
if (errors.length || !page.ok) return { ok: false, errors };
|
|
1795
2340
|
return { ok: true, value: {
|
|
1796
2341
|
schemaVersion: 1,
|
|
@@ -1803,7 +2348,8 @@ function validateSignupControlDocument(input) {
|
|
|
1803
2348
|
...application ? { application } : {},
|
|
1804
2349
|
...policy ? { policy } : {},
|
|
1805
2350
|
...scheduling ? { scheduling } : {},
|
|
1806
|
-
...theme ? { theme } : {}
|
|
2351
|
+
...theme ? { theme } : {},
|
|
2352
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
1807
2353
|
} };
|
|
1808
2354
|
}
|
|
1809
2355
|
|
|
@@ -1864,92 +2410,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1864
2410
|
});
|
|
1865
2411
|
};
|
|
1866
2412
|
|
|
1867
|
-
// src/
|
|
1868
|
-
var
|
|
1869
|
-
var
|
|
1870
|
-
var
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
});
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
hubRecordId: str(app.id)
|
|
1885
|
-
});
|
|
1886
|
-
for (const f of chapter.application.crmFields) {
|
|
1887
|
-
if (app[f] !== void 0) input[f] = app[f];
|
|
2413
|
+
// src/worker-routes-admission-grants.ts
|
|
2414
|
+
var import_db4 = require("@odla-ai/db");
|
|
2415
|
+
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2416
|
+
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2417
|
+
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
2418
|
+
const authority = ctx.chapter.signupControl;
|
|
2419
|
+
if (!authority) return json({ error: "not found" }, 404);
|
|
2420
|
+
const db = ctx.makeDb(env);
|
|
2421
|
+
const secret = await getVaultSecret(db, authority.secretName);
|
|
2422
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2423
|
+
const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
|
|
2424
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2425
|
+
let input;
|
|
2426
|
+
try {
|
|
2427
|
+
input = JSON.parse(verified.body);
|
|
2428
|
+
} catch {
|
|
2429
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
1888
2430
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
if (
|
|
1899
|
-
if (
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
});
|
|
1910
|
-
const existing = crm_record?.[0] ?? null;
|
|
1911
|
-
const stage = opts.stage || void 0;
|
|
1912
|
-
let recordId;
|
|
1913
|
-
if (existing && typeof existing.id === "string") {
|
|
1914
|
-
recordId = existing.id;
|
|
1915
|
-
await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
|
|
1916
|
-
} else {
|
|
1917
|
-
const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1918
|
-
recordId = created.id;
|
|
2431
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2432
|
+
const checked = validateAdmissionGrantDocument(input.document);
|
|
2433
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2434
|
+
const document = checked.value;
|
|
2435
|
+
if (document.source.appId !== authority.sourceId) return reject2("wrong_source", 403);
|
|
2436
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2437
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2438
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2439
|
+
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2440
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2441
|
+
if (latest && document.revision === latest.revision) {
|
|
2442
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2443
|
+
return json({
|
|
2444
|
+
ok: true,
|
|
2445
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2446
|
+
revision: latest.revision,
|
|
2447
|
+
digest: latest.digest,
|
|
2448
|
+
status: "replayed",
|
|
2449
|
+
acknowledgedAt: latest.receivedAt
|
|
2450
|
+
});
|
|
1919
2451
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
2452
|
+
const receivedAt = Date.now();
|
|
2453
|
+
try {
|
|
2454
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2455
|
+
} catch {
|
|
2456
|
+
return reject2("materialization_failed", 503);
|
|
1922
2457
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
const
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2458
|
+
return json({
|
|
2459
|
+
ok: true,
|
|
2460
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2461
|
+
revision: document.revision,
|
|
2462
|
+
digest,
|
|
2463
|
+
status: "accepted",
|
|
2464
|
+
acknowledgedAt: receivedAt
|
|
2465
|
+
});
|
|
2466
|
+
};
|
|
2467
|
+
|
|
2468
|
+
// src/worker-routes-admin-admission.ts
|
|
2469
|
+
async function gate(req, env, ctx) {
|
|
2470
|
+
const rawDb = ctx.makeDb(env);
|
|
2471
|
+
const user = await ctx.verifyUser(req, env);
|
|
2472
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2473
|
+
if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
|
|
2474
|
+
return { db: rawDb, user };
|
|
2475
|
+
}
|
|
2476
|
+
var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
2477
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
|
|
2478
|
+
const auth = await gate(req, env, ctx);
|
|
2479
|
+
if (auth instanceof Response) return auth;
|
|
2480
|
+
const result = await auth.db.query({ admissionGrants: { $: {
|
|
2481
|
+
where: { targetId: ctx.chapter.id },
|
|
2482
|
+
order: { createdAt: "desc" },
|
|
2483
|
+
limit: 500
|
|
2484
|
+
} } });
|
|
2485
|
+
const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
|
|
2486
|
+
return json({ grants });
|
|
2487
|
+
}
|
|
2488
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
|
|
2489
|
+
const auth = await gate(req, env, ctx);
|
|
2490
|
+
if (auth instanceof Response) return auth;
|
|
2491
|
+
let body;
|
|
1939
2492
|
try {
|
|
1940
|
-
await
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2493
|
+
body = JSON.parse(await req.text());
|
|
2494
|
+
} catch {
|
|
2495
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2496
|
+
}
|
|
2497
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
2498
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
2499
|
+
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
2500
|
+
const decision = body.decision ?? "waive_interview";
|
|
2501
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
2502
|
+
return json({ error: "decision must be waive_interview or require_interview" }, 400);
|
|
2503
|
+
}
|
|
2504
|
+
const note = typeof body.note === "string" ? body.note.trim() : "";
|
|
2505
|
+
if (note.length > 2e3) return json({ error: "note is too long" }, 400);
|
|
2506
|
+
const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
|
|
2507
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
|
|
2508
|
+
return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
|
|
2509
|
+
}
|
|
2510
|
+
const sourceGrantId = crypto.randomUUID();
|
|
2511
|
+
const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
|
|
2512
|
+
const createdAt = Date.now();
|
|
2513
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
2514
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
2515
|
+
id,
|
|
2516
|
+
sourceGrantId,
|
|
2517
|
+
sourceScope: "local",
|
|
2518
|
+
sourceId: ctx.chapter.id,
|
|
2519
|
+
targetId: ctx.chapter.id,
|
|
2520
|
+
email,
|
|
2521
|
+
emailNormalized,
|
|
2522
|
+
decision,
|
|
2523
|
+
...note ? { note } : {},
|
|
2524
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
2525
|
+
active: true,
|
|
2526
|
+
createdAt,
|
|
2527
|
+
createdBy: actor,
|
|
2528
|
+
updatedAt: createdAt,
|
|
2529
|
+
updatedBy: actor
|
|
2530
|
+
} }], { mutationId: `admission-local-create:${sourceGrantId}` });
|
|
2531
|
+
return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
|
|
2532
|
+
}
|
|
2533
|
+
const prefix = "/api/admin/admission-grants/";
|
|
2534
|
+
let revokeId = "";
|
|
2535
|
+
if (url.pathname.startsWith(prefix)) {
|
|
2536
|
+
try {
|
|
2537
|
+
revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
2538
|
+
} catch {
|
|
2539
|
+
revokeId = "";
|
|
1944
2540
|
}
|
|
1945
|
-
};
|
|
1946
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1947
|
-
for (const u of usersRes.$users ?? []) {
|
|
1948
|
-
if (u.deleted === true) continue;
|
|
1949
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1950
2541
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
2542
|
+
if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
|
|
2543
|
+
const auth = await gate(req, env, ctx);
|
|
2544
|
+
if (auth instanceof Response) return auth;
|
|
2545
|
+
const id = revokeId;
|
|
2546
|
+
const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
|
|
2547
|
+
const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
|
|
2548
|
+
if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
|
|
2549
|
+
if (row.active !== true) return json({ ok: true, replayed: true });
|
|
2550
|
+
const revokedAt = Date.now();
|
|
2551
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
2552
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
2553
|
+
active: false,
|
|
2554
|
+
revokedAt,
|
|
2555
|
+
revokedBy: actor,
|
|
2556
|
+
updatedAt: revokedAt,
|
|
2557
|
+
updatedBy: actor
|
|
2558
|
+
} }], { mutationId: `admission-local-revoke:${id}` });
|
|
2559
|
+
return json({ ok: true, replayed: false });
|
|
2560
|
+
}
|
|
2561
|
+
return null;
|
|
2562
|
+
};
|
|
1953
2563
|
|
|
1954
2564
|
// src/worker-routes-schedule.ts
|
|
1955
2565
|
var import_calendar = require("@odla-ai/calendar");
|
|
@@ -1965,8 +2575,8 @@ function makeCalendar(env) {
|
|
|
1965
2575
|
}
|
|
1966
2576
|
async function firstRow(db, ns, q) {
|
|
1967
2577
|
const res = await db.query({ [ns]: { $: q } });
|
|
1968
|
-
const
|
|
1969
|
-
return Array.isArray(
|
|
2578
|
+
const rows4 = res[ns];
|
|
2579
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1970
2580
|
}
|
|
1971
2581
|
async function computeSlots(cal, cfg) {
|
|
1972
2582
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2103,8 +2713,26 @@ function findApplicationRef(obj) {
|
|
|
2103
2713
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2104
2714
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2105
2715
|
}
|
|
2716
|
+
function findGiftRef(obj) {
|
|
2717
|
+
const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
|
|
2718
|
+
const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
|
|
2719
|
+
const parent = obj.parent;
|
|
2720
|
+
const subscription = obj.subscription_details ?? parent?.subscription_details;
|
|
2721
|
+
const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
|
|
2722
|
+
const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
|
|
2723
|
+
return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
|
|
2724
|
+
}
|
|
2106
2725
|
function normalizeWebhookEvent(event) {
|
|
2107
2726
|
const obj = event.data?.object ?? {};
|
|
2727
|
+
const gift = findGiftRef(obj);
|
|
2728
|
+
if (gift.giftIntentId) {
|
|
2729
|
+
if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
|
|
2730
|
+
return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
|
|
2731
|
+
}
|
|
2732
|
+
if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
|
|
2733
|
+
if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
|
|
2734
|
+
return { kind: "ignored", type: event.type };
|
|
2735
|
+
}
|
|
2108
2736
|
const ref = findApplicationRef(obj);
|
|
2109
2737
|
switch (event.type) {
|
|
2110
2738
|
case "invoice.paid": {
|
|
@@ -2205,9 +2833,9 @@ function createStripeProvider(config) {
|
|
|
2205
2833
|
`sub:${input.applicationId}`
|
|
2206
2834
|
);
|
|
2207
2835
|
if (!sub.ok) fail("subscription create", sub);
|
|
2208
|
-
const
|
|
2209
|
-
if (!
|
|
2210
|
-
return { customerId, subscriptionId: String(sub.body.id), clientSecret };
|
|
2836
|
+
const clientSecret2 = clientSecretOf(sub.body);
|
|
2837
|
+
if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
|
|
2838
|
+
return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
|
|
2211
2839
|
},
|
|
2212
2840
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2213
2841
|
if (!webhookSecret || !await (0, import_stripe2.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2220,8 +2848,8 @@ function createStripeProvider(config) {
|
|
|
2220
2848
|
const sk = requireSecret(secretKey);
|
|
2221
2849
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2222
2850
|
if (!charges.ok) fail("charges list", charges);
|
|
2223
|
-
const
|
|
2224
|
-
const paid =
|
|
2851
|
+
const rows4 = charges.body.data ?? [];
|
|
2852
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2225
2853
|
const charge = paid[paid.length - 1];
|
|
2226
2854
|
if (!charge) {
|
|
2227
2855
|
const err = new Error("no paid charge to refund");
|
|
@@ -2237,12 +2865,337 @@ function createStripeProvider(config) {
|
|
|
2237
2865
|
};
|
|
2238
2866
|
}
|
|
2239
2867
|
|
|
2868
|
+
// src/gift-codes.ts
|
|
2869
|
+
var GIFT_CODE_VERSION = 1;
|
|
2870
|
+
var GIFT_CODE_BYTES = 32;
|
|
2871
|
+
var base64url = (bytes) => {
|
|
2872
|
+
let binary = "";
|
|
2873
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2874
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
2875
|
+
};
|
|
2876
|
+
function createGiftCode() {
|
|
2877
|
+
const bytes = new Uint8Array(GIFT_CODE_BYTES);
|
|
2878
|
+
crypto.getRandomValues(bytes);
|
|
2879
|
+
return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
|
|
2880
|
+
}
|
|
2881
|
+
async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
|
|
2882
|
+
const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
|
|
2883
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
|
|
2884
|
+
}
|
|
2885
|
+
async function giftRequesterBucket(chapterId, requester) {
|
|
2886
|
+
const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
|
|
2887
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// src/gift-store.ts
|
|
2891
|
+
var first = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
2892
|
+
async function prepareGiftCode(chapterId) {
|
|
2893
|
+
const code = createGiftCode();
|
|
2894
|
+
return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
|
|
2895
|
+
}
|
|
2896
|
+
async function issueGiftClaim(db, intent, provider, now) {
|
|
2897
|
+
const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
2898
|
+
if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
|
|
2899
|
+
const offer = intent.offerSnapshot;
|
|
2900
|
+
const orderId = `gift-order:${intent.id}`;
|
|
2901
|
+
const claimId = `gift-claim:${intent.id}`;
|
|
2902
|
+
const shared = {
|
|
2903
|
+
chapterId: intent.chapterId,
|
|
2904
|
+
offerId: intent.offerId,
|
|
2905
|
+
...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
|
|
2906
|
+
...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
|
|
2907
|
+
purchaserEmailNormalized: intent.purchaserEmailNormalized
|
|
2908
|
+
};
|
|
2909
|
+
const ops = [
|
|
2910
|
+
{ t: "update", ns: "giftOrders", id: orderId, attrs: {
|
|
2911
|
+
id: orderId,
|
|
2912
|
+
intentId: intent.id,
|
|
2913
|
+
...shared,
|
|
2914
|
+
environment: intent.environment,
|
|
2915
|
+
offerSnapshot: offer,
|
|
2916
|
+
purchaserType: intent.purchaserType,
|
|
2917
|
+
providerEventId: provider.eventId,
|
|
2918
|
+
providerObjectId: provider.objectId,
|
|
2919
|
+
amountCents: offer.priceCents,
|
|
2920
|
+
currency: offer.currency,
|
|
2921
|
+
billingMode: offer.billingMode,
|
|
2922
|
+
createdAt: now
|
|
2923
|
+
} },
|
|
2924
|
+
{ t: "update", ns: "giftClaims", id: claimId, attrs: {
|
|
2925
|
+
id: claimId,
|
|
2926
|
+
orderId,
|
|
2927
|
+
intentId: intent.id,
|
|
2928
|
+
...shared,
|
|
2929
|
+
target: offer.target,
|
|
2930
|
+
codeHash: intent.codeHash,
|
|
2931
|
+
codeVersion: intent.codeVersion,
|
|
2932
|
+
rotationCount: 0,
|
|
2933
|
+
codeIssuedAt: intent.codeIssuedAt,
|
|
2934
|
+
codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
|
|
2935
|
+
attemptCount: 0,
|
|
2936
|
+
attemptWindowStartedAt: now,
|
|
2937
|
+
status: "pending",
|
|
2938
|
+
createdAt: now
|
|
2939
|
+
} },
|
|
2940
|
+
{ t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
|
|
2941
|
+
];
|
|
2942
|
+
if (intent.purchaserApplicationId) ops.push({
|
|
2943
|
+
t: "update",
|
|
2944
|
+
ns: "applications",
|
|
2945
|
+
id: intent.purchaserApplicationId,
|
|
2946
|
+
attrs: { giftPaidAt: now, giftClaimId: claimId }
|
|
2947
|
+
});
|
|
2948
|
+
try {
|
|
2949
|
+
const result = await db.transact(ops, {
|
|
2950
|
+
mutationId: `gift-paid:${provider.eventId}`,
|
|
2951
|
+
guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
|
|
2952
|
+
});
|
|
2953
|
+
return { claimId, orderId, duplicate: result.duplicate };
|
|
2954
|
+
} catch {
|
|
2955
|
+
const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
2956
|
+
if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
|
|
2957
|
+
throw new Error("gift issuance conflict");
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
function buyerGiftClaims(rows4, now) {
|
|
2961
|
+
return rows4.map((row) => {
|
|
2962
|
+
const stored = String(row.status ?? "pending");
|
|
2963
|
+
const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
|
|
2964
|
+
return {
|
|
2965
|
+
id: String(row.id),
|
|
2966
|
+
offerId: String(row.offerId),
|
|
2967
|
+
status,
|
|
2968
|
+
createdAt: Number(row.createdAt),
|
|
2969
|
+
codeExpiresAt: Number(row.codeExpiresAt),
|
|
2970
|
+
...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
|
|
2971
|
+
...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
|
|
2972
|
+
};
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
async function rotateGiftClaim(db, claim, now) {
|
|
2976
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2977
|
+
const prepared = await prepareGiftCode(String(claim.chapterId));
|
|
2978
|
+
const currentRotation = Number(claim.rotationCount ?? 0);
|
|
2979
|
+
try {
|
|
2980
|
+
const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
2981
|
+
codeHash: prepared.codeHash,
|
|
2982
|
+
codeVersion: prepared.codeVersion,
|
|
2983
|
+
rotationCount: currentRotation + 1,
|
|
2984
|
+
codeIssuedAt: now,
|
|
2985
|
+
rotatedAt: now,
|
|
2986
|
+
attemptCount: 0,
|
|
2987
|
+
attemptWindowStartedAt: now
|
|
2988
|
+
} }], {
|
|
2989
|
+
mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
|
|
2990
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
|
|
2991
|
+
});
|
|
2992
|
+
return write.duplicate ? null : { code: prepared.code };
|
|
2993
|
+
} catch {
|
|
2994
|
+
return null;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
2998
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2999
|
+
const chapterId = String(claim.chapterId);
|
|
3000
|
+
const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
|
|
3001
|
+
const target = claim.target;
|
|
3002
|
+
const ops = [];
|
|
3003
|
+
let result;
|
|
3004
|
+
if (target.type === "tier") {
|
|
3005
|
+
const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
3006
|
+
const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3007
|
+
const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
|
|
3008
|
+
const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
|
|
3009
|
+
ops.push({ t: "update", ns: "applications", id, attrs: {
|
|
3010
|
+
id,
|
|
3011
|
+
groupId: chapterId,
|
|
3012
|
+
email: beneficiary.email,
|
|
3013
|
+
emailNormalized,
|
|
3014
|
+
tierId: target.tierId,
|
|
3015
|
+
status,
|
|
3016
|
+
giftClaimId: String(claim.id),
|
|
3017
|
+
giftOrderId: String(claim.orderId),
|
|
3018
|
+
clerkUserId: beneficiary.userId,
|
|
3019
|
+
...existing ? {} : { createdAt: now }
|
|
3020
|
+
} });
|
|
3021
|
+
result = { kind: "application", id };
|
|
3022
|
+
} else {
|
|
3023
|
+
const id = `gift-entitlement:${String(claim.id)}`;
|
|
3024
|
+
ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
|
|
3025
|
+
id,
|
|
3026
|
+
claimId: String(claim.id),
|
|
3027
|
+
orderId: String(claim.orderId),
|
|
3028
|
+
chapterId,
|
|
3029
|
+
entitlementKey: target.entitlementKey,
|
|
3030
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3031
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3032
|
+
active: true,
|
|
3033
|
+
createdAt: now
|
|
3034
|
+
} });
|
|
3035
|
+
result = { kind: "entitlement", id };
|
|
3036
|
+
}
|
|
3037
|
+
ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3038
|
+
status: "redeemed",
|
|
3039
|
+
redeemedAt: now,
|
|
3040
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3041
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3042
|
+
...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
|
|
3043
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1
|
|
3044
|
+
} });
|
|
3045
|
+
try {
|
|
3046
|
+
await db.transact(ops, {
|
|
3047
|
+
mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
|
|
3048
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
|
|
3049
|
+
status: "pending",
|
|
3050
|
+
codeVersion: Number(claim.codeVersion),
|
|
3051
|
+
codeHash: String(claim.codeHash)
|
|
3052
|
+
} }]
|
|
3053
|
+
});
|
|
3054
|
+
return result;
|
|
3055
|
+
} catch {
|
|
3056
|
+
return null;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
async function revokeGiftClaim(db, claim, reason, now) {
|
|
3060
|
+
if (claim.status === "revoked") return false;
|
|
3061
|
+
const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3062
|
+
status: "revoked",
|
|
3063
|
+
revokedAt: now,
|
|
3064
|
+
revokedReason: reason
|
|
3065
|
+
} }];
|
|
3066
|
+
if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
|
|
3067
|
+
if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
|
|
3068
|
+
giftRevokedAt: now,
|
|
3069
|
+
...reason === "refunded" ? { status: "refunded" } : { canceled: true }
|
|
3070
|
+
} });
|
|
3071
|
+
try {
|
|
3072
|
+
await db.transact(ops, {
|
|
3073
|
+
mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
|
|
3074
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
|
|
3075
|
+
});
|
|
3076
|
+
return true;
|
|
3077
|
+
} catch {
|
|
3078
|
+
return false;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// src/gift-checkout.ts
|
|
3083
|
+
var clientSecret = (value) => {
|
|
3084
|
+
const direct = value.client_secret;
|
|
3085
|
+
if (typeof direct === "string" && direct) return direct;
|
|
3086
|
+
const invoice = value.latest_invoice;
|
|
3087
|
+
const confirmation = invoice?.confirmation_secret;
|
|
3088
|
+
const intent = invoice?.payment_intent;
|
|
3089
|
+
const nested = confirmation?.client_secret ?? intent?.client_secret;
|
|
3090
|
+
return typeof nested === "string" && nested ? nested : null;
|
|
3091
|
+
};
|
|
3092
|
+
async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
|
|
3093
|
+
const offer = intent.offerSnapshot;
|
|
3094
|
+
const metadata = {
|
|
3095
|
+
giftIntentId: intent.id,
|
|
3096
|
+
chapterId: intent.chapterId,
|
|
3097
|
+
environment: intent.environment,
|
|
3098
|
+
offerId: intent.offerId
|
|
3099
|
+
};
|
|
3100
|
+
let customerId = existingCustomerId;
|
|
3101
|
+
if (!customerId) {
|
|
3102
|
+
const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
|
|
3103
|
+
if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
|
|
3104
|
+
customerId = String(customer.body.id);
|
|
3105
|
+
}
|
|
3106
|
+
if (offer.billingMode === "subscription") {
|
|
3107
|
+
const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
|
|
3108
|
+
customer: customerId,
|
|
3109
|
+
"items[0][price]": offer.stripePriceId,
|
|
3110
|
+
payment_behavior: "default_incomplete",
|
|
3111
|
+
"payment_settings[save_default_payment_method]": "on_subscription",
|
|
3112
|
+
"payment_settings[payment_method_types][0]": "card",
|
|
3113
|
+
"expand[0]": "latest_invoice.confirmation_secret",
|
|
3114
|
+
metadata
|
|
3115
|
+
}, `gift-subscription:${intent.id}`);
|
|
3116
|
+
const secret2 = created2.ok ? clientSecret(created2.body) : null;
|
|
3117
|
+
if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
|
|
3118
|
+
return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
|
|
3119
|
+
}
|
|
3120
|
+
const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
|
|
3121
|
+
if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
|
|
3122
|
+
throw new Error("gift price does not match the signed offer");
|
|
3123
|
+
}
|
|
3124
|
+
const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
|
|
3125
|
+
customer: customerId,
|
|
3126
|
+
amount: offer.priceCents,
|
|
3127
|
+
currency: offer.currency,
|
|
3128
|
+
"automatic_payment_methods[enabled]": true,
|
|
3129
|
+
metadata
|
|
3130
|
+
}, `gift-payment:${intent.id}`);
|
|
3131
|
+
const secret = created.ok ? clientSecret(created.body) : null;
|
|
3132
|
+
if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
|
|
3133
|
+
return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// src/gift-intents.ts
|
|
3137
|
+
var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3138
|
+
async function startGiftIntent(db, input) {
|
|
3139
|
+
let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
|
|
3140
|
+
let giftCode;
|
|
3141
|
+
if (!stored) {
|
|
3142
|
+
const prepared = await prepareGiftCode(input.chapterId);
|
|
3143
|
+
const attrs = {
|
|
3144
|
+
id: input.id,
|
|
3145
|
+
chapterId: input.chapterId,
|
|
3146
|
+
environment: input.environment,
|
|
3147
|
+
purchaserType: input.purchaserType,
|
|
3148
|
+
purchaserEmailNormalized: input.purchaserEmailNormalized,
|
|
3149
|
+
...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
|
|
3150
|
+
...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
|
|
3151
|
+
offerId: input.offer.id,
|
|
3152
|
+
offerSnapshot: input.offer,
|
|
3153
|
+
status: "created",
|
|
3154
|
+
codeHash: prepared.codeHash,
|
|
3155
|
+
codeVersion: prepared.codeVersion,
|
|
3156
|
+
codeIssuedAt: input.now,
|
|
3157
|
+
createdAt: input.now,
|
|
3158
|
+
updatedAt: input.now
|
|
3159
|
+
};
|
|
3160
|
+
const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
|
|
3161
|
+
mutationId: `gift-intent:${input.id}`
|
|
3162
|
+
});
|
|
3163
|
+
stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
|
|
3164
|
+
if (!write.duplicate) giftCode = prepared.code;
|
|
3165
|
+
}
|
|
3166
|
+
if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
|
|
3167
|
+
throw new Error("gift purchase intent does not match this checkout");
|
|
3168
|
+
}
|
|
3169
|
+
if (stored.status === "paid") throw new Error("gift purchase is already paid");
|
|
3170
|
+
const checkout = await startGiftProviderCheckout(
|
|
3171
|
+
input.stripeSecret,
|
|
3172
|
+
stored,
|
|
3173
|
+
input.purchaserEmail,
|
|
3174
|
+
typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
|
|
3175
|
+
);
|
|
3176
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
|
|
3177
|
+
status: "checkout_ready",
|
|
3178
|
+
providerObjectId: checkout.providerObjectId,
|
|
3179
|
+
...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
|
|
3180
|
+
checkoutStartedAt: input.now,
|
|
3181
|
+
updatedAt: input.now
|
|
3182
|
+
} }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
|
|
3183
|
+
return {
|
|
3184
|
+
gift: true,
|
|
3185
|
+
intentId: input.id,
|
|
3186
|
+
clientSecret: checkout.clientSecret,
|
|
3187
|
+
publishableKey: input.publishableKey,
|
|
3188
|
+
lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
|
|
3189
|
+
...giftCode ? { giftCode } : {}
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
|
|
2240
3193
|
// src/worker-routes-payments.ts
|
|
2241
3194
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2242
3195
|
async function firstRow2(db, ns, q) {
|
|
2243
3196
|
const res = await db.query({ [ns]: { $: q } });
|
|
2244
|
-
const
|
|
2245
|
-
return Array.isArray(
|
|
3197
|
+
const rows4 = res[ns];
|
|
3198
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2246
3199
|
}
|
|
2247
3200
|
function lineItems(group) {
|
|
2248
3201
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2327,6 +3280,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2327
3280
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2328
3281
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2329
3282
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3283
|
+
if (typeof app.giftOfferId === "string") {
|
|
3284
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3285
|
+
const offer = findGiftOffer(control?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3286
|
+
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3287
|
+
return json({ error: "gift offer is unavailable" }, 409);
|
|
3288
|
+
}
|
|
3289
|
+
try {
|
|
3290
|
+
const started = await startGiftIntent(db, {
|
|
3291
|
+
id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
|
|
3292
|
+
chapterId: ctx.chapter.id,
|
|
3293
|
+
environment: env.ODLA_ENV,
|
|
3294
|
+
offer,
|
|
3295
|
+
purchaserType: "application",
|
|
3296
|
+
purchaserEmail: String(app.email ?? ""),
|
|
3297
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
|
|
3298
|
+
purchaserApplicationId: applicationId,
|
|
3299
|
+
stripeSecret: secretKey,
|
|
3300
|
+
publishableKey: group.stripePublishableKey,
|
|
3301
|
+
now: Date.now()
|
|
3302
|
+
});
|
|
3303
|
+
await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
|
|
3304
|
+
giftIntentId: started.intentId,
|
|
3305
|
+
giftCheckoutStartedAt: Date.now(),
|
|
3306
|
+
refundPolicyAckAt: Date.now()
|
|
3307
|
+
} }]);
|
|
3308
|
+
return json(started);
|
|
3309
|
+
} catch (error) {
|
|
3310
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
2330
3313
|
const tier = findTier(
|
|
2331
3314
|
await loadTiers(db, group),
|
|
2332
3315
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2368,17 +3351,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2368
3351
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2369
3352
|
const { eventId, event } = ingest;
|
|
2370
3353
|
if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
|
|
3354
|
+
if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
|
|
3355
|
+
const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
|
|
3356
|
+
if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
|
|
3357
|
+
return json({ ok: true, matched: false });
|
|
3358
|
+
}
|
|
3359
|
+
if (event.kind === "gift_paid") {
|
|
3360
|
+
if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
|
|
3361
|
+
if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
|
|
3362
|
+
try {
|
|
3363
|
+
const issued = await issueGiftClaim(db, intent, {
|
|
3364
|
+
eventId,
|
|
3365
|
+
objectId: event.providerObjectId
|
|
3366
|
+
}, Date.now());
|
|
3367
|
+
return json({ ok: true, matched: true, claimId: issued.claimId });
|
|
3368
|
+
} catch {
|
|
3369
|
+
return json({ error: "gift issuance failed" }, 503);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
|
|
3373
|
+
status: event.reason,
|
|
3374
|
+
updatedAt: Date.now()
|
|
3375
|
+
} }], { mutationId: `gift-revoked-intent:${eventId}` });
|
|
3376
|
+
const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
|
|
3377
|
+
if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
|
|
3378
|
+
return json({ ok: true, matched: Boolean(claim) });
|
|
3379
|
+
}
|
|
2371
3380
|
const app = await findApplication(db, event);
|
|
2372
3381
|
if (!app) return json({ ok: true, matched: false });
|
|
2373
3382
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3383
|
+
let next = app;
|
|
2374
3384
|
if (Object.keys(patch).length) {
|
|
2375
3385
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2376
|
-
|
|
3386
|
+
next = { ...app, ...patch };
|
|
2377
3387
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2378
3388
|
}
|
|
2379
3389
|
if (event.kind === "first_payment") {
|
|
2380
3390
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2381
3391
|
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
3392
|
+
if (typeof next.interviewWaivedAt === "number") {
|
|
3393
|
+
await approveApplication({
|
|
3394
|
+
db,
|
|
3395
|
+
env,
|
|
3396
|
+
ctx,
|
|
3397
|
+
app: next,
|
|
3398
|
+
origin: new URL(req.url).origin,
|
|
3399
|
+
reason: "interview_waiver"
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
2382
3402
|
}
|
|
2383
3403
|
return json({ ok: true });
|
|
2384
3404
|
}
|
|
@@ -2389,6 +3409,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2389
3409
|
return null;
|
|
2390
3410
|
};
|
|
2391
3411
|
|
|
3412
|
+
// src/worker-routes-gifts.ts
|
|
3413
|
+
var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3414
|
+
var rows2 = (value) => Array.isArray(value) ? value : [];
|
|
3415
|
+
var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
|
|
3416
|
+
var userFor = async (req, env, ctx) => {
|
|
3417
|
+
const user = await ctx.verifyUser(req, env);
|
|
3418
|
+
return user?.email ? user : null;
|
|
3419
|
+
};
|
|
3420
|
+
var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
|
|
3421
|
+
async function consumeRateLimit(db, chapterId, requester, now) {
|
|
3422
|
+
const bucket = await giftRequesterBucket(chapterId, requester);
|
|
3423
|
+
const id = `gift-rate:${chapterId}:${bucket}`;
|
|
3424
|
+
const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
|
|
3425
|
+
const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
|
|
3426
|
+
const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
|
|
3427
|
+
if (attemptCount > 10) return false;
|
|
3428
|
+
await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
|
|
3429
|
+
id,
|
|
3430
|
+
chapterId,
|
|
3431
|
+
bucket,
|
|
3432
|
+
attemptCount,
|
|
3433
|
+
windowStartedAt: within ? Number(current?.windowStartedAt) : now,
|
|
3434
|
+
updatedAt: now
|
|
3435
|
+
} }]);
|
|
3436
|
+
return true;
|
|
3437
|
+
}
|
|
3438
|
+
var handleGifts = async (req, url, env, ctx) => {
|
|
3439
|
+
if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
|
|
3440
|
+
const db = ctx.makeDb(env);
|
|
3441
|
+
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3442
|
+
const user = await userFor(req, env, ctx);
|
|
3443
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3444
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3445
|
+
const found = await db.query({ giftClaims: { $: { where: {
|
|
3446
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
|
|
3447
|
+
chapterId: ctx.chapter.id
|
|
3448
|
+
}, limit: 200 } } });
|
|
3449
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3450
|
+
return json({
|
|
3451
|
+
offers: publicGiftOffers(control?.document.giftOffers, Date.now()),
|
|
3452
|
+
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
3453
|
+
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
|
|
3457
|
+
const user = await userFor(req, env, ctx);
|
|
3458
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3459
|
+
let body;
|
|
3460
|
+
try {
|
|
3461
|
+
body = JSON.parse(await req.text());
|
|
3462
|
+
} catch {
|
|
3463
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3464
|
+
}
|
|
3465
|
+
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
3466
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
3467
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3468
|
+
const offer = findGiftOffer(control?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
3469
|
+
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
3470
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3471
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
3472
|
+
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
3473
|
+
const emailNormalized = normalizeAdmissionEmail(user.email);
|
|
3474
|
+
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
3475
|
+
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3476
|
+
try {
|
|
3477
|
+
return json(await startGiftIntent(db, {
|
|
3478
|
+
id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
|
|
3479
|
+
chapterId: ctx.chapter.id,
|
|
3480
|
+
environment: env.ODLA_ENV,
|
|
3481
|
+
offer,
|
|
3482
|
+
purchaserType: "member",
|
|
3483
|
+
purchaserEmail: user.email,
|
|
3484
|
+
purchaserEmailNormalized: emailNormalized,
|
|
3485
|
+
purchaserUserId: user.userId,
|
|
3486
|
+
...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
|
|
3487
|
+
stripeSecret,
|
|
3488
|
+
publishableKey: group.stripePublishableKey,
|
|
3489
|
+
now: Date.now()
|
|
3490
|
+
}));
|
|
3491
|
+
} catch (error) {
|
|
3492
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
|
|
3496
|
+
if (req.method === "POST" && rotateMatch) {
|
|
3497
|
+
let claimId = "";
|
|
3498
|
+
try {
|
|
3499
|
+
claimId = decodeURIComponent(rotateMatch[1]);
|
|
3500
|
+
} catch {
|
|
3501
|
+
return invalid();
|
|
3502
|
+
}
|
|
3503
|
+
if (!claimId.startsWith("gift-claim:")) return invalid();
|
|
3504
|
+
const claim = await loadClaim(db, claimId);
|
|
3505
|
+
if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
|
|
3506
|
+
const user = await userFor(req, env, ctx);
|
|
3507
|
+
let applicationId = "";
|
|
3508
|
+
if (!user) {
|
|
3509
|
+
try {
|
|
3510
|
+
applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
|
|
3511
|
+
} catch {
|
|
3512
|
+
return invalid();
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
3516
|
+
if (!owns) return invalid();
|
|
3517
|
+
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
3518
|
+
return rotated ? json(rotated) : invalid();
|
|
3519
|
+
}
|
|
3520
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
|
|
3521
|
+
const user = await userFor(req, env, ctx);
|
|
3522
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3523
|
+
const now = Date.now();
|
|
3524
|
+
if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
|
|
3525
|
+
return json({ error: "too many attempts" }, 429);
|
|
3526
|
+
}
|
|
3527
|
+
let code = "";
|
|
3528
|
+
try {
|
|
3529
|
+
code = String(JSON.parse(await req.text()).code ?? "").trim();
|
|
3530
|
+
} catch {
|
|
3531
|
+
return invalid();
|
|
3532
|
+
}
|
|
3533
|
+
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
|
|
3534
|
+
const codeHash = await hashGiftCode(ctx.chapter.id, code);
|
|
3535
|
+
const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
|
|
3536
|
+
if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
|
|
3537
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
|
|
3538
|
+
await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3539
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1,
|
|
3540
|
+
...claim.status === "pending" ? { status: "expired" } : {}
|
|
3541
|
+
} }]).catch(() => void 0);
|
|
3542
|
+
return invalid();
|
|
3543
|
+
}
|
|
3544
|
+
const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
|
|
3545
|
+
return redeemed ? json({ ok: true, ...redeemed }) : invalid();
|
|
3546
|
+
}
|
|
3547
|
+
return null;
|
|
3548
|
+
};
|
|
3549
|
+
|
|
2392
3550
|
// src/worker-routes-admin.ts
|
|
2393
3551
|
var import_calendar2 = require("@odla-ai/calendar");
|
|
2394
3552
|
|
|
@@ -2460,8 +3618,8 @@ async function upcomingEvents(env) {
|
|
|
2460
3618
|
const res = await cal.availability.upcoming();
|
|
2461
3619
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2462
3620
|
}
|
|
2463
|
-
function toReconcile(
|
|
2464
|
-
return
|
|
3621
|
+
function toReconcile(rows4) {
|
|
3622
|
+
return rows4.map((m) => ({
|
|
2465
3623
|
id: String(m.id),
|
|
2466
3624
|
applicationId: String(m.applicationId),
|
|
2467
3625
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2483,12 +3641,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2483
3641
|
const query = await db.query({
|
|
2484
3642
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2485
3643
|
});
|
|
2486
|
-
let
|
|
2487
|
-
if (Number.isFinite(from))
|
|
2488
|
-
if (Number.isFinite(to))
|
|
3644
|
+
let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
|
|
3645
|
+
if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
|
|
3646
|
+
if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
|
|
2489
3647
|
let decisions = [];
|
|
2490
3648
|
try {
|
|
2491
|
-
decisions = reconcileMeetings(toReconcile(
|
|
3649
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2492
3650
|
} catch {
|
|
2493
3651
|
}
|
|
2494
3652
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2515,69 +3673,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2515
3673
|
};
|
|
2516
3674
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2517
3675
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2518
|
-
const meetings =
|
|
3676
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2519
3677
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2520
3678
|
};
|
|
2521
3679
|
|
|
2522
|
-
// src/clerk-roles.ts
|
|
2523
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2524
|
-
var DEFAULT_ROLE = "provisional";
|
|
2525
|
-
var PAGE = 100;
|
|
2526
|
-
function toRecord(u) {
|
|
2527
|
-
if (typeof u.id !== "string") return null;
|
|
2528
|
-
const pm = u.public_metadata ?? {};
|
|
2529
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2530
|
-
const rawProfile = privateMetadata.profile;
|
|
2531
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2532
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2533
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2534
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2535
|
-
return {
|
|
2536
|
-
id: u.id,
|
|
2537
|
-
email: typeof email === "string" ? email : void 0,
|
|
2538
|
-
role,
|
|
2539
|
-
publicMetadata: pm,
|
|
2540
|
-
privateMetadata,
|
|
2541
|
-
profile,
|
|
2542
|
-
...applicationId ? { applicationId } : {}
|
|
2543
|
-
};
|
|
2544
|
-
}
|
|
2545
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2546
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2547
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2548
|
-
return res.json();
|
|
2549
|
-
}
|
|
2550
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2551
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2552
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2553
|
-
return user ? toRecord(user) : null;
|
|
2554
|
-
}
|
|
2555
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2556
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2557
|
-
return data ? toRecord(data) : null;
|
|
2558
|
-
}
|
|
2559
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2560
|
-
const out = [];
|
|
2561
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2562
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2563
|
-
const page = Array.isArray(data) ? data : [];
|
|
2564
|
-
for (const u of page) {
|
|
2565
|
-
const record = toRecord(u);
|
|
2566
|
-
if (record) out.push(record);
|
|
2567
|
-
}
|
|
2568
|
-
if (page.length < PAGE) break;
|
|
2569
|
-
}
|
|
2570
|
-
return out;
|
|
2571
|
-
}
|
|
2572
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2573
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2574
|
-
method: "PATCH",
|
|
2575
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2576
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2577
|
-
});
|
|
2578
|
-
return res.ok;
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
3680
|
// src/worker-routes-admin-people.ts
|
|
2582
3681
|
async function adminGate(req, env, ctx) {
|
|
2583
3682
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2595,16 +3694,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2595
3694
|
});
|
|
2596
3695
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2597
3696
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2598
|
-
const
|
|
2599
|
-
if (
|
|
2600
|
-
const result = await backfillCrm(crmDeps2(
|
|
3697
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3698
|
+
if (gate9 instanceof Response) return gate9;
|
|
3699
|
+
const result = await backfillCrm(crmDeps2(gate9.db, ctx));
|
|
2601
3700
|
return json({ ok: true, ...result });
|
|
2602
3701
|
};
|
|
2603
3702
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2604
3703
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2605
|
-
const
|
|
2606
|
-
if (
|
|
2607
|
-
const { db } =
|
|
3704
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3705
|
+
if (gate9 instanceof Response) return gate9;
|
|
3706
|
+
const { db } = gate9;
|
|
2608
3707
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2609
3708
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2610
3709
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2648,16 +3747,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2648
3747
|
});
|
|
2649
3748
|
}
|
|
2650
3749
|
}
|
|
2651
|
-
const
|
|
3750
|
+
const rows4 = [...people.values()].sort(
|
|
2652
3751
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2653
3752
|
);
|
|
2654
|
-
return json({ people:
|
|
3753
|
+
return json({ people: rows4 });
|
|
2655
3754
|
};
|
|
2656
3755
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2657
3756
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2658
|
-
const
|
|
2659
|
-
if (
|
|
2660
|
-
const { db } =
|
|
3757
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3758
|
+
if (gate9 instanceof Response) return gate9;
|
|
3759
|
+
const { db } = gate9;
|
|
2661
3760
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2662
3761
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2663
3762
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2668,9 +3767,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2668
3767
|
};
|
|
2669
3768
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2670
3769
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2671
|
-
const
|
|
2672
|
-
if (
|
|
2673
|
-
const { db, actor } =
|
|
3770
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3771
|
+
if (gate9 instanceof Response) return gate9;
|
|
3772
|
+
const { db, actor } = gate9;
|
|
2674
3773
|
let body;
|
|
2675
3774
|
try {
|
|
2676
3775
|
body = await req.json();
|
|
@@ -2817,17 +3916,17 @@ function subAnnualCents(sub) {
|
|
|
2817
3916
|
}
|
|
2818
3917
|
|
|
2819
3918
|
// src/worker-routes-admin-dashboard.ts
|
|
2820
|
-
async function
|
|
3919
|
+
async function gate2(req, env, ctx) {
|
|
2821
3920
|
const rawDb = ctx.makeDb(env);
|
|
2822
3921
|
const u = await ctx.verifyUser(req, env);
|
|
2823
3922
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2824
3923
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2825
3924
|
return rawDb;
|
|
2826
3925
|
}
|
|
2827
|
-
var
|
|
3926
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2828
3927
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2829
3928
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2830
|
-
const got = await
|
|
3929
|
+
const got = await gate2(req, env, ctx);
|
|
2831
3930
|
if (got instanceof Response) return got;
|
|
2832
3931
|
const db = got;
|
|
2833
3932
|
const now = Date.now();
|
|
@@ -2839,7 +3938,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2839
3938
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2840
3939
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2841
3940
|
]);
|
|
2842
|
-
const apps =
|
|
3941
|
+
const apps = rows3(appsRes.applications);
|
|
2843
3942
|
const applications = {
|
|
2844
3943
|
total: apps.length,
|
|
2845
3944
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2851,7 +3950,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2851
3950
|
pipeline[s] = 0;
|
|
2852
3951
|
pipelineDelta[s] = 0;
|
|
2853
3952
|
}
|
|
2854
|
-
for (const r of
|
|
3953
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2855
3954
|
const s = r.stage;
|
|
2856
3955
|
if (s in pipeline) {
|
|
2857
3956
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2859,7 +3958,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2859
3958
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2860
3959
|
}
|
|
2861
3960
|
}
|
|
2862
|
-
const meetings =
|
|
3961
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2863
3962
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2864
3963
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2865
3964
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2884,7 +3983,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2884
3983
|
if (sk) {
|
|
2885
3984
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2886
3985
|
if (subsRes.ok) {
|
|
2887
|
-
const subs =
|
|
3986
|
+
const subs = rows3(subsRes.body.data);
|
|
2888
3987
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2889
3988
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2890
3989
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2904,7 +4003,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2904
4003
|
};
|
|
2905
4004
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2906
4005
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2907
|
-
const got = await
|
|
4006
|
+
const got = await gate2(req, env, ctx);
|
|
2908
4007
|
if (got instanceof Response) return got;
|
|
2909
4008
|
const db = got;
|
|
2910
4009
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2916,9 +4015,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2916
4015
|
]);
|
|
2917
4016
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2918
4017
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2919
|
-
const subById = new Map(
|
|
4018
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2920
4019
|
const billingRows = [];
|
|
2921
|
-
for (const a of
|
|
4020
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2922
4021
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2923
4022
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2924
4023
|
const items = sub?.items?.data ?? [];
|
|
@@ -2962,7 +4061,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2962
4061
|
|
|
2963
4062
|
// src/worker-routes-admin-lifecycle.ts
|
|
2964
4063
|
var import_calendar3 = require("@odla-ai/calendar");
|
|
2965
|
-
async function
|
|
4064
|
+
async function gate3(req, env, ctx) {
|
|
2966
4065
|
const rawDb = ctx.makeDb(env);
|
|
2967
4066
|
const u = await ctx.verifyUser(req, env);
|
|
2968
4067
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2988,7 +4087,7 @@ async function loadGroup(db, id) {
|
|
|
2988
4087
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2989
4088
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2990
4089
|
if (req.method !== "POST" || !m) return null;
|
|
2991
|
-
const got = await
|
|
4090
|
+
const got = await gate3(req, env, ctx);
|
|
2992
4091
|
if (got instanceof Response) return got;
|
|
2993
4092
|
const db = got;
|
|
2994
4093
|
const body = await readJson(req);
|
|
@@ -3029,7 +4128,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3029
4128
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3030
4129
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3031
4130
|
if (req.method !== "POST" || !m) return null;
|
|
3032
|
-
const got = await
|
|
4131
|
+
const got = await gate3(req, env, ctx);
|
|
3033
4132
|
if (got instanceof Response) return got;
|
|
3034
4133
|
const db = got;
|
|
3035
4134
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3052,43 +4151,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3052
4151
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3053
4152
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3054
4153
|
if (req.method !== "POST" || !m) return null;
|
|
3055
|
-
const got = await
|
|
4154
|
+
const got = await gate3(req, env, ctx);
|
|
3056
4155
|
if (got instanceof Response) return got;
|
|
3057
4156
|
const db = got;
|
|
3058
4157
|
const id = m[1];
|
|
3059
4158
|
const app = await loadApp(db, id);
|
|
3060
4159
|
if (!app) return json({ error: "not found" }, 404);
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3065
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3066
|
-
let rolePromoted = false;
|
|
3067
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3068
|
-
if (promoteTo !== false && sk) {
|
|
3069
|
-
let userId = app.clerkUserId || null;
|
|
3070
|
-
if (!userId) {
|
|
3071
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3072
|
-
userId = found?.id ?? null;
|
|
3073
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3074
|
-
}
|
|
3075
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3076
|
-
}
|
|
3077
|
-
let emailLogged = false;
|
|
3078
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3079
|
-
if (send !== false && group) {
|
|
3080
|
-
const res = await sendTemplated(
|
|
3081
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3082
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3083
|
-
);
|
|
3084
|
-
emailLogged = res.sent;
|
|
3085
|
-
}
|
|
3086
|
-
return json({ ok: true, status: target, rolePromoted, emailLogged });
|
|
4160
|
+
const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
|
|
4161
|
+
if (!result.ok) return json({ error: result.error }, result.status);
|
|
4162
|
+
return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
|
|
3087
4163
|
};
|
|
3088
4164
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3089
4165
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3090
4166
|
if (req.method !== "POST" || !m) return null;
|
|
3091
|
-
const got = await
|
|
4167
|
+
const got = await gate3(req, env, ctx);
|
|
3092
4168
|
if (got instanceof Response) return got;
|
|
3093
4169
|
const db = got;
|
|
3094
4170
|
const app = await loadApp(db, m[1]);
|
|
@@ -3120,7 +4196,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3120
4196
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3121
4197
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3122
4198
|
if (req.method !== "PATCH" || !m) return null;
|
|
3123
|
-
const got = await
|
|
4199
|
+
const got = await gate3(req, env, ctx);
|
|
3124
4200
|
if (got instanceof Response) return got;
|
|
3125
4201
|
const db = got;
|
|
3126
4202
|
const id = m[1];
|
|
@@ -3148,7 +4224,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3148
4224
|
};
|
|
3149
4225
|
|
|
3150
4226
|
// src/worker-routes-admin-comms.ts
|
|
3151
|
-
async function
|
|
4227
|
+
async function gate4(req, env, ctx) {
|
|
3152
4228
|
const rawDb = ctx.makeDb(env);
|
|
3153
4229
|
const u = await ctx.verifyUser(req, env);
|
|
3154
4230
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3170,7 +4246,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3170
4246
|
});
|
|
3171
4247
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3172
4248
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3173
|
-
const got = await
|
|
4249
|
+
const got = await gate4(req, env, ctx);
|
|
3174
4250
|
if (got instanceof Response) return got;
|
|
3175
4251
|
const db = got;
|
|
3176
4252
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3211,11 +4287,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3211
4287
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3212
4288
|
const t = templates[key];
|
|
3213
4289
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3214
|
-
const
|
|
3215
|
-
if (!subject || !
|
|
4290
|
+
const text3 = typeof t?.text === "string" ? t.text : "";
|
|
4291
|
+
if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
|
|
3216
4292
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3217
|
-
if (
|
|
3218
|
-
clean[key] = { subject, text:
|
|
4293
|
+
if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
|
|
4294
|
+
clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
|
|
3219
4295
|
}
|
|
3220
4296
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3221
4297
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3232,7 +4308,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3232
4308
|
};
|
|
3233
4309
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3234
4310
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3235
|
-
const got = await
|
|
4311
|
+
const got = await gate4(req, env, ctx);
|
|
3236
4312
|
if (got instanceof Response) return got;
|
|
3237
4313
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3238
4314
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3249,7 +4325,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3249
4325
|
};
|
|
3250
4326
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3251
4327
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3252
|
-
const got = await
|
|
4328
|
+
const got = await gate4(req, env, ctx);
|
|
3253
4329
|
if (got instanceof Response) return got;
|
|
3254
4330
|
const db = got;
|
|
3255
4331
|
let body;
|
|
@@ -3277,7 +4353,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3277
4353
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3278
4354
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3279
4355
|
if (req.method !== "GET" || !m) return null;
|
|
3280
|
-
const got = await
|
|
4356
|
+
const got = await gate4(req, env, ctx);
|
|
3281
4357
|
if (got instanceof Response) return got;
|
|
3282
4358
|
const db = got;
|
|
3283
4359
|
const appId = m[1];
|
|
@@ -3310,7 +4386,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3310
4386
|
|
|
3311
4387
|
// src/worker-routes-network.ts
|
|
3312
4388
|
var import_crm6 = require("@odla-ai/crm");
|
|
3313
|
-
var
|
|
4389
|
+
var import_db6 = require("@odla-ai/db");
|
|
3314
4390
|
|
|
3315
4391
|
// src/worker-network-fetch.ts
|
|
3316
4392
|
function targetFetcher(env, target) {
|
|
@@ -3328,8 +4404,8 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3328
4404
|
|
|
3329
4405
|
// src/worker-network-push.ts
|
|
3330
4406
|
var import_crm5 = require("@odla-ai/crm");
|
|
3331
|
-
var
|
|
3332
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4407
|
+
var import_db5 = require("@odla-ai/db");
|
|
4408
|
+
async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
3333
4409
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3334
4410
|
const crmDeps3 = {
|
|
3335
4411
|
crm: ctx.chapter.crm,
|
|
@@ -3340,7 +4416,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3340
4416
|
if (!secret) {
|
|
3341
4417
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3342
4418
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3343
|
-
recordId:
|
|
4419
|
+
recordId: record2.id,
|
|
3344
4420
|
targetId: target.id,
|
|
3345
4421
|
status: "failed",
|
|
3346
4422
|
payloadVersion: 2,
|
|
@@ -3350,7 +4426,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3350
4426
|
}
|
|
3351
4427
|
let payload;
|
|
3352
4428
|
try {
|
|
3353
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4429
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
|
|
3354
4430
|
} catch (err) {
|
|
3355
4431
|
return {
|
|
3356
4432
|
id: target.id,
|
|
@@ -3362,7 +4438,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3362
4438
|
const payloadBody = JSON.stringify(payload);
|
|
3363
4439
|
const destination = new URL("/api/network/shared", target.url);
|
|
3364
4440
|
try {
|
|
3365
|
-
const signed = await (0,
|
|
4441
|
+
const signed = await (0, import_db5.signFederatedRequest)({
|
|
3366
4442
|
secret,
|
|
3367
4443
|
sender: ctx.chapter.id,
|
|
3368
4444
|
method: "POST",
|
|
@@ -3379,7 +4455,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3379
4455
|
if (!res.ok) {
|
|
3380
4456
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3381
4457
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3382
|
-
recordId:
|
|
4458
|
+
recordId: record2.id,
|
|
3383
4459
|
targetId: target.id,
|
|
3384
4460
|
status: "failed",
|
|
3385
4461
|
payloadVersion: 2,
|
|
@@ -3394,7 +4470,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3394
4470
|
};
|
|
3395
4471
|
}
|
|
3396
4472
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3397
|
-
recordId:
|
|
4473
|
+
recordId: record2.id,
|
|
3398
4474
|
targetId: target.id,
|
|
3399
4475
|
status: "delivered",
|
|
3400
4476
|
payloadVersion: 2,
|
|
@@ -3410,7 +4486,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3410
4486
|
} catch (err) {
|
|
3411
4487
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3412
4488
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3413
|
-
recordId:
|
|
4489
|
+
recordId: record2.id,
|
|
3414
4490
|
targetId: target.id,
|
|
3415
4491
|
status: "failed",
|
|
3416
4492
|
payloadVersion: 2,
|
|
@@ -3421,7 +4497,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3421
4497
|
}
|
|
3422
4498
|
|
|
3423
4499
|
// src/worker-routes-network.ts
|
|
3424
|
-
async function
|
|
4500
|
+
async function gate5(req, env, ctx) {
|
|
3425
4501
|
const db = ctx.makeDb(env);
|
|
3426
4502
|
const user = await ctx.verifyUser(req, env);
|
|
3427
4503
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3430,7 +4506,7 @@ async function gate4(req, env, ctx) {
|
|
|
3430
4506
|
}
|
|
3431
4507
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3432
4508
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3433
|
-
const got = await
|
|
4509
|
+
const got = await gate5(req, env, ctx);
|
|
3434
4510
|
if ("response" in got) return got.response;
|
|
3435
4511
|
return json({
|
|
3436
4512
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3447,7 +4523,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3447
4523
|
const db = ctx.makeDb(env);
|
|
3448
4524
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3449
4525
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3450
|
-
const verified = await (0,
|
|
4526
|
+
const verified = await (0, import_db6.verifyFederatedRequest)(req, { secret });
|
|
3451
4527
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3452
4528
|
const types = await Promise.all(
|
|
3453
4529
|
Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3465,7 +4541,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3465
4541
|
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
3466
4542
|
const destination = new URL("/api/network/snapshot", target.url);
|
|
3467
4543
|
try {
|
|
3468
|
-
const headers = await (0,
|
|
4544
|
+
const headers = await (0, import_db6.signFederatedRequest)({
|
|
3469
4545
|
secret,
|
|
3470
4546
|
sender: ctx.chapter.id,
|
|
3471
4547
|
method: "GET",
|
|
@@ -3506,7 +4582,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3506
4582
|
}
|
|
3507
4583
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3508
4584
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3509
|
-
const got = await
|
|
4585
|
+
const got = await gate5(req, env, ctx);
|
|
3510
4586
|
if ("response" in got) return got.response;
|
|
3511
4587
|
const targets = await Promise.all(
|
|
3512
4588
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3533,7 +4609,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3533
4609
|
};
|
|
3534
4610
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3535
4611
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3536
|
-
const got = await
|
|
4612
|
+
const got = await gate5(req, env, ctx);
|
|
3537
4613
|
if ("response" in got) return got.response;
|
|
3538
4614
|
let body;
|
|
3539
4615
|
try {
|
|
@@ -3548,22 +4624,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3548
4624
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3549
4625
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3550
4626
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3551
|
-
const
|
|
3552
|
-
if (!
|
|
4627
|
+
const record2 = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
4628
|
+
if (!record2) return json({ error: "record not found" }, 404);
|
|
3553
4629
|
const results = await Promise.all(
|
|
3554
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
4630
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
|
|
3555
4631
|
);
|
|
3556
4632
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3557
4633
|
};
|
|
3558
4634
|
|
|
3559
4635
|
// src/worker-routes-network-records.ts
|
|
3560
4636
|
var import_crm7 = require("@odla-ai/crm");
|
|
3561
|
-
var
|
|
4637
|
+
var import_db7 = require("@odla-ai/db");
|
|
3562
4638
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3563
4639
|
var MAX_PAGE = 50;
|
|
3564
4640
|
var MAX_OFFSET = 1e4;
|
|
3565
4641
|
var MAX_SEARCH = 200;
|
|
3566
|
-
async function
|
|
4642
|
+
async function gate6(req, env, ctx) {
|
|
3567
4643
|
const db = ctx.makeDb(env);
|
|
3568
4644
|
const user = await ctx.verifyUser(req, env);
|
|
3569
4645
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3576,18 +4652,18 @@ function integer(value, fallback, min, max) {
|
|
|
3576
4652
|
const parsed = Number(value);
|
|
3577
4653
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3578
4654
|
}
|
|
3579
|
-
function projectRecord(
|
|
4655
|
+
function projectRecord(record2, allowed) {
|
|
3580
4656
|
const fields = Object.fromEntries(
|
|
3581
|
-
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(
|
|
4657
|
+
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
|
|
3582
4658
|
);
|
|
3583
4659
|
return {
|
|
3584
|
-
id:
|
|
3585
|
-
type:
|
|
3586
|
-
name:
|
|
3587
|
-
...
|
|
4660
|
+
id: record2.id,
|
|
4661
|
+
type: record2.type,
|
|
4662
|
+
name: record2.name,
|
|
4663
|
+
...record2.stage ? { stage: record2.stage } : {},
|
|
3588
4664
|
fields,
|
|
3589
|
-
createdAt:
|
|
3590
|
-
updatedAt:
|
|
4665
|
+
createdAt: record2.createdAt,
|
|
4666
|
+
updatedAt: record2.updatedAt
|
|
3591
4667
|
};
|
|
3592
4668
|
}
|
|
3593
4669
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3596,7 +4672,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3596
4672
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3597
4673
|
const readers = ctx.chapter.network.readers;
|
|
3598
4674
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3599
|
-
const verified = await (0,
|
|
4675
|
+
const verified = await (0, import_db7.verifyFederatedRequest)(req, {
|
|
3600
4676
|
secret,
|
|
3601
4677
|
senders: readers.map((reader2) => reader2.id)
|
|
3602
4678
|
});
|
|
@@ -3625,7 +4701,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3625
4701
|
version: 1,
|
|
3626
4702
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3627
4703
|
type,
|
|
3628
|
-
records: page.records.map((
|
|
4704
|
+
records: page.records.map((record2) => projectRecord(record2, allowed)),
|
|
3629
4705
|
total: page.total,
|
|
3630
4706
|
limit: page.limit,
|
|
3631
4707
|
offset: page.offset
|
|
@@ -3641,7 +4717,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3641
4717
|
const destination = new URL("/api/network/records", target.url);
|
|
3642
4718
|
destination.search = query.toString();
|
|
3643
4719
|
try {
|
|
3644
|
-
const headers = await (0,
|
|
4720
|
+
const headers = await (0, import_db7.signFederatedRequest)({
|
|
3645
4721
|
secret,
|
|
3646
4722
|
sender: ctx.chapter.id,
|
|
3647
4723
|
method: "GET",
|
|
@@ -3666,7 +4742,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3666
4742
|
}
|
|
3667
4743
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3668
4744
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3669
|
-
const got = await
|
|
4745
|
+
const got = await gate6(req, env, ctx);
|
|
3670
4746
|
if ("response" in got) return got.response;
|
|
3671
4747
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3672
4748
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3693,8 +4769,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3693
4769
|
// src/worker-routes-network-notes.ts
|
|
3694
4770
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3695
4771
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3696
|
-
var
|
|
3697
|
-
async function
|
|
4772
|
+
var MAX_NOTE2 = 4e3;
|
|
4773
|
+
async function gate7(req, env, ctx) {
|
|
3698
4774
|
const db = ctx.makeDb(env);
|
|
3699
4775
|
const user = await ctx.verifyUser(req, env);
|
|
3700
4776
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3728,7 +4804,7 @@ function asNote(row) {
|
|
|
3728
4804
|
}
|
|
3729
4805
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3730
4806
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3731
|
-
const got = await
|
|
4807
|
+
const got = await gate7(req, env, ctx);
|
|
3732
4808
|
if ("response" in got) return got.response;
|
|
3733
4809
|
const identity = noteIdentity(url, ctx);
|
|
3734
4810
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3745,7 +4821,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3745
4821
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3746
4822
|
}
|
|
3747
4823
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3748
|
-
if (contentLength >
|
|
4824
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3749
4825
|
let input;
|
|
3750
4826
|
try {
|
|
3751
4827
|
input = await req.json();
|
|
@@ -3753,7 +4829,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3753
4829
|
return json({ error: "invalid JSON body" }, 400);
|
|
3754
4830
|
}
|
|
3755
4831
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3756
|
-
if (!body || body.length >
|
|
4832
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3757
4833
|
const id = crypto.randomUUID();
|
|
3758
4834
|
const note = {
|
|
3759
4835
|
id,
|
|
@@ -3776,13 +4852,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3776
4852
|
|
|
3777
4853
|
// src/worker-routes-network-shared-notes.ts
|
|
3778
4854
|
var import_crm8 = require("@odla-ai/crm");
|
|
3779
|
-
var
|
|
4855
|
+
var import_db8 = require("@odla-ai/db");
|
|
3780
4856
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3781
4857
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3782
4858
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3783
|
-
var
|
|
4859
|
+
var MAX_NOTE3 = 4e3;
|
|
3784
4860
|
var NETWORK_META = "chapterNetwork";
|
|
3785
|
-
async function
|
|
4861
|
+
async function gate8(req, env, ctx) {
|
|
3786
4862
|
const db = ctx.makeDb(env);
|
|
3787
4863
|
const user = await ctx.verifyUser(req, env);
|
|
3788
4864
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3828,14 +4904,14 @@ function sourceLabel(sourceId) {
|
|
|
3828
4904
|
}
|
|
3829
4905
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3830
4906
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3831
|
-
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) >
|
|
4907
|
+
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
|
|
3832
4908
|
return json({ error: "note is too large" }, 413);
|
|
3833
4909
|
}
|
|
3834
4910
|
const db = ctx.makeDb(env);
|
|
3835
4911
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3836
4912
|
const readers = ctx.chapter.network.readers;
|
|
3837
4913
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3838
|
-
const verified = await (0,
|
|
4914
|
+
const verified = await (0, import_db8.verifyFederatedRequest)(req, {
|
|
3839
4915
|
secret,
|
|
3840
4916
|
senders: readers.map((reader2) => reader2.id)
|
|
3841
4917
|
});
|
|
@@ -3846,8 +4922,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3846
4922
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3847
4923
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3848
4924
|
}
|
|
3849
|
-
const
|
|
3850
|
-
if (!
|
|
4925
|
+
const record2 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
|
|
4926
|
+
if (!record2 || record2.type !== recordType) return json({ error: "record not found" }, 404);
|
|
3851
4927
|
if (req.method === "GET") {
|
|
3852
4928
|
return json({
|
|
3853
4929
|
version: 1,
|
|
@@ -3863,8 +4939,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3863
4939
|
}
|
|
3864
4940
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3865
4941
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3866
|
-
if (!body || body.length >
|
|
3867
|
-
return json({ error: `body must be 1\u2013${
|
|
4942
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
|
|
4943
|
+
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
3868
4944
|
}
|
|
3869
4945
|
const createdAt = Date.now();
|
|
3870
4946
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3906,7 +4982,7 @@ function targetFor2(ctx, id) {
|
|
|
3906
4982
|
}
|
|
3907
4983
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3908
4984
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3909
|
-
const got = await
|
|
4985
|
+
const got = await gate8(req, env, ctx);
|
|
3910
4986
|
if ("response" in got) return got.response;
|
|
3911
4987
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3912
4988
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3924,7 +5000,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3924
5000
|
return json({ error: "invalid JSON body" }, 400);
|
|
3925
5001
|
}
|
|
3926
5002
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3927
|
-
if (!note || note.length >
|
|
5003
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3928
5004
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3929
5005
|
}
|
|
3930
5006
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -3932,7 +5008,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3932
5008
|
const destination = new URL("/api/network/shared-notes", target.url);
|
|
3933
5009
|
destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
|
|
3934
5010
|
try {
|
|
3935
|
-
const headers = await (0,
|
|
5011
|
+
const headers = await (0, import_db8.signFederatedRequest)({
|
|
3936
5012
|
secret,
|
|
3937
5013
|
sender: ctx.chapter.id,
|
|
3938
5014
|
method: req.method,
|
|
@@ -4053,11 +5129,11 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
4053
5129
|
};
|
|
4054
5130
|
|
|
4055
5131
|
// src/signup-control-delivery.ts
|
|
4056
|
-
var
|
|
5132
|
+
var import_db9 = require("@odla-ai/db");
|
|
4057
5133
|
async function deliverSignupControl(options) {
|
|
4058
5134
|
const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
|
|
4059
5135
|
const body = JSON.stringify(options.envelope);
|
|
4060
|
-
const signed = await (0,
|
|
5136
|
+
const signed = await (0, import_db9.signFederatedRequest)({
|
|
4061
5137
|
secret: options.secret,
|
|
4062
5138
|
sender: options.sender,
|
|
4063
5139
|
method: "POST",
|
|
@@ -4077,6 +5153,162 @@ async function deliverSignupControl(options) {
|
|
|
4077
5153
|
return result;
|
|
4078
5154
|
}
|
|
4079
5155
|
|
|
5156
|
+
// src/admission-grants-delivery.ts
|
|
5157
|
+
var import_db10 = require("@odla-ai/db");
|
|
5158
|
+
async function deliverAdmissionGrants(options) {
|
|
5159
|
+
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5160
|
+
const body = JSON.stringify(options.envelope);
|
|
5161
|
+
const signed = await (0, import_db10.signFederatedRequest)({
|
|
5162
|
+
secret: options.secret,
|
|
5163
|
+
sender: options.sender,
|
|
5164
|
+
method: "POST",
|
|
5165
|
+
url: destination,
|
|
5166
|
+
body
|
|
5167
|
+
});
|
|
5168
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5169
|
+
method: "POST",
|
|
5170
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5171
|
+
body
|
|
5172
|
+
});
|
|
5173
|
+
const result = await response.json();
|
|
5174
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5175
|
+
const failure = result;
|
|
5176
|
+
throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5177
|
+
}
|
|
5178
|
+
return result;
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
// src/signup-control-theme.ts
|
|
5182
|
+
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
5183
|
+
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
5184
|
+
var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
|
|
5185
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
5186
|
+
var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
|
|
5187
|
+
var MAX_TOKENS = 256;
|
|
5188
|
+
var MAX_VALUE_LENGTH = 1e3;
|
|
5189
|
+
var MAX_SERIALIZED_LENGTH = 65536;
|
|
5190
|
+
function validatedTokens(input) {
|
|
5191
|
+
const entries = Object.entries(input ?? {});
|
|
5192
|
+
if (entries.length > MAX_TOKENS) {
|
|
5193
|
+
throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
|
|
5194
|
+
}
|
|
5195
|
+
const tokens = {};
|
|
5196
|
+
let serializedLength = 0;
|
|
5197
|
+
for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
5198
|
+
if (!TOKEN_NAME.test(name)) {
|
|
5199
|
+
throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
|
|
5200
|
+
}
|
|
5201
|
+
if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
|
|
5202
|
+
throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
|
|
5203
|
+
}
|
|
5204
|
+
const value = String(raw).trim();
|
|
5205
|
+
if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
|
|
5206
|
+
throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
|
|
5207
|
+
}
|
|
5208
|
+
serializedLength += name.length + value.length + 4;
|
|
5209
|
+
if (serializedLength > MAX_SERIALIZED_LENGTH) {
|
|
5210
|
+
throw new TypeError("signup-control theme exceeds the serialized size bound");
|
|
5211
|
+
}
|
|
5212
|
+
tokens[name] = value;
|
|
5213
|
+
}
|
|
5214
|
+
return tokens;
|
|
5215
|
+
}
|
|
5216
|
+
function cssFor(tokens) {
|
|
5217
|
+
const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
|
|
5218
|
+
return declarations.length ? `:root {
|
|
5219
|
+
${declarations.join("\n")}
|
|
5220
|
+
}` : "";
|
|
5221
|
+
}
|
|
5222
|
+
function stateFor(revision, options) {
|
|
5223
|
+
if (!revision) {
|
|
5224
|
+
return {
|
|
5225
|
+
css: cssFor(validatedTokens(options.bootstrapTokens)),
|
|
5226
|
+
metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
|
|
5227
|
+
};
|
|
5228
|
+
}
|
|
5229
|
+
if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
|
|
5230
|
+
throw new TypeError("signup-control theme revision metadata is invalid");
|
|
5231
|
+
}
|
|
5232
|
+
return {
|
|
5233
|
+
css: cssFor(validatedTokens(revision.theme?.tokens)),
|
|
5234
|
+
metadata: {
|
|
5235
|
+
source: "active",
|
|
5236
|
+
revision: revision.revision,
|
|
5237
|
+
digest: revision.digest,
|
|
5238
|
+
cacheKey: `signup-control:${revision.digest}`
|
|
5239
|
+
}
|
|
5240
|
+
};
|
|
5241
|
+
}
|
|
5242
|
+
function attributes(metadata) {
|
|
5243
|
+
return [
|
|
5244
|
+
`id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
|
|
5245
|
+
`data-signup-control-source="${metadata.source}"`,
|
|
5246
|
+
`data-signup-control-cache-key="${metadata.cacheKey}"`,
|
|
5247
|
+
...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
|
|
5248
|
+
...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
|
|
5249
|
+
].join(" ");
|
|
5250
|
+
}
|
|
5251
|
+
function renderSignupControlThemeHead(revision, options = {}) {
|
|
5252
|
+
const { css, metadata } = stateFor(revision, options);
|
|
5253
|
+
return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
|
|
5254
|
+
}
|
|
5255
|
+
function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
|
|
5256
|
+
const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
|
|
5257
|
+
const existingBlocks = html.match(existing) ?? [];
|
|
5258
|
+
if (existingBlocks.length > 1) {
|
|
5259
|
+
throw new TypeError("signup-control theme style block must appear at most once");
|
|
5260
|
+
}
|
|
5261
|
+
if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
|
|
5262
|
+
const markerIndex = html.indexOf(marker);
|
|
5263
|
+
if (markerIndex >= 0) {
|
|
5264
|
+
if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
|
|
5265
|
+
throw new TypeError("signup-control theme marker must appear exactly once");
|
|
5266
|
+
}
|
|
5267
|
+
return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
|
|
5268
|
+
}
|
|
5269
|
+
const closingHead = html.search(/<\/head\s*>/i);
|
|
5270
|
+
if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
|
|
5271
|
+
return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
// src/signup-control-theme-worker.ts
|
|
5275
|
+
async function activeTheme(options) {
|
|
5276
|
+
const active = await loadActiveSignupControl(options.db, options.chapterId, options.environment);
|
|
5277
|
+
if (!active) return null;
|
|
5278
|
+
const checked = validateSignupControlDocument(active.document);
|
|
5279
|
+
if (!checked.ok) throw new TypeError(`active signup-control document is invalid: ${checked.errors.join("; ")}`);
|
|
5280
|
+
const document = checked.value;
|
|
5281
|
+
if (document.revision !== active.revision || document.target.chapterId !== options.chapterId || document.target.environment !== options.environment || active.digest !== await signupControlDigest(document)) {
|
|
5282
|
+
throw new TypeError("active signup-control revision metadata or digest is invalid");
|
|
5283
|
+
}
|
|
5284
|
+
return { revision: active.revision, digest: active.digest, theme: document.theme };
|
|
5285
|
+
}
|
|
5286
|
+
async function renderActiveSignupControlHtml(html, options) {
|
|
5287
|
+
const theme = renderSignupControlThemeHead(await activeTheme(options), {
|
|
5288
|
+
bootstrapTokens: options.bootstrapTokens
|
|
5289
|
+
});
|
|
5290
|
+
return { html: injectSignupControlThemeHead(html, theme, options.marker), theme };
|
|
5291
|
+
}
|
|
5292
|
+
async function contentEtag(html) {
|
|
5293
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(html));
|
|
5294
|
+
const hex = Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5295
|
+
return `W/"sha256-${hex}"`;
|
|
5296
|
+
}
|
|
5297
|
+
async function withActiveSignupControlTheme(response, options) {
|
|
5298
|
+
if (!response.ok || !(response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")) {
|
|
5299
|
+
throw new TypeError("signup-control theme can only transform a successful HTML response");
|
|
5300
|
+
}
|
|
5301
|
+
const rendered = await renderActiveSignupControlHtml(await response.text(), options);
|
|
5302
|
+
const headers = new Headers(response.headers);
|
|
5303
|
+
headers.delete("content-length");
|
|
5304
|
+
headers.set("cache-control", "no-store");
|
|
5305
|
+
headers.set("etag", await contentEtag(rendered.html));
|
|
5306
|
+
headers.set("x-odla-signup-control-digest", rendered.theme.metadata.digest ?? "bootstrap");
|
|
5307
|
+
if (rendered.theme.metadata.revision === null) headers.delete("x-odla-signup-control-revision");
|
|
5308
|
+
else headers.set("x-odla-signup-control-revision", String(rendered.theme.metadata.revision));
|
|
5309
|
+
return new Response(rendered.html, { status: response.status, statusText: response.statusText, headers });
|
|
5310
|
+
}
|
|
5311
|
+
|
|
4080
5312
|
// src/worker.ts
|
|
4081
5313
|
var BUILTIN_ROUTES = [
|
|
4082
5314
|
handleHealth,
|
|
@@ -4085,11 +5317,13 @@ var BUILTIN_ROUTES = [
|
|
|
4085
5317
|
handleCrm,
|
|
4086
5318
|
handleNetworkShared,
|
|
4087
5319
|
handleSignupControl,
|
|
5320
|
+
handleAdmissionGrants,
|
|
4088
5321
|
handleNetworkSnapshot,
|
|
4089
5322
|
handleNetworkRecords,
|
|
4090
5323
|
handleNetworkSharedNotes,
|
|
4091
5324
|
handleFormation,
|
|
4092
5325
|
handleMember,
|
|
5326
|
+
handleGifts,
|
|
4093
5327
|
handleSchedule,
|
|
4094
5328
|
handlePayments,
|
|
4095
5329
|
handleAdminMeetings,
|
|
@@ -4099,6 +5333,7 @@ var BUILTIN_ROUTES = [
|
|
|
4099
5333
|
handleAdminPeopleAccess,
|
|
4100
5334
|
handleAdminPeopleRole,
|
|
4101
5335
|
handleAdminCrmSync,
|
|
5336
|
+
handleAdminAdmissionGrants,
|
|
4102
5337
|
handleAdminClerkPrivateProfileSync,
|
|
4103
5338
|
// Aggregation
|
|
4104
5339
|
handleAdminDashboard,
|