@odla-ai/chapter 0.35.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/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 +843 -293
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +200 -3
- package/dist/index.d.ts +200 -3
- package/dist/index.js +843 -293
- 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 +1806 -710
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +102 -3
- package/dist/worker/index.d.ts +102 -3
- package/dist/worker/index.js +1824 -728
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -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,6 +22,7 @@ var worker_exports = {};
|
|
|
22
22
|
__export(worker_exports, {
|
|
23
23
|
chapterWorker: () => chapterWorker,
|
|
24
24
|
createWorkerContext: () => createWorkerContext,
|
|
25
|
+
deliverAdmissionGrants: () => deliverAdmissionGrants,
|
|
25
26
|
deliverSignupControl: () => deliverSignupControl,
|
|
26
27
|
renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
|
|
27
28
|
withActiveSignupControlTheme: () => withActiveSignupControlTheme
|
|
@@ -139,7 +140,7 @@ function createWorkerContext(options) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
// src/worker-routes.ts
|
|
142
|
-
var
|
|
143
|
+
var import_crm4 = require("@odla-ai/crm");
|
|
143
144
|
|
|
144
145
|
// src/discussion-reference-security.ts
|
|
145
146
|
async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
|
|
@@ -197,6 +198,205 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
197
198
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
198
199
|
}
|
|
199
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
|
+
|
|
200
400
|
// src/member.ts
|
|
201
401
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
202
402
|
var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
|
|
@@ -248,9 +448,9 @@ function resolveApplication(a) {
|
|
|
248
448
|
validateEmail: a?.validateEmail ?? true
|
|
249
449
|
};
|
|
250
450
|
}
|
|
251
|
-
var
|
|
451
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
252
452
|
function isValidEmail(value) {
|
|
253
|
-
return typeof value === "string" &&
|
|
453
|
+
return typeof value === "string" && EMAIL_RE2.test(value);
|
|
254
454
|
}
|
|
255
455
|
function clampArray(value, max) {
|
|
256
456
|
if (!Array.isArray(value)) return value;
|
|
@@ -300,6 +500,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
300
500
|
}
|
|
301
501
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
302
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;
|
|
303
512
|
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
304
513
|
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
305
514
|
return { ok: false, error: "tierId is not an offered tier" };
|
|
@@ -307,8 +516,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
307
516
|
row.tierId = fields.tierId;
|
|
308
517
|
}
|
|
309
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
|
+
}
|
|
310
528
|
const { duplicate } = await db.transact(
|
|
311
|
-
|
|
529
|
+
ops,
|
|
312
530
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
313
531
|
);
|
|
314
532
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
@@ -342,8 +560,8 @@ function tierPayable(tier, group, hasSecretKey) {
|
|
|
342
560
|
function byOrder(a, b) {
|
|
343
561
|
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
344
562
|
}
|
|
345
|
-
function resolveTiers(group,
|
|
346
|
-
const stored =
|
|
563
|
+
function resolveTiers(group, rows4 = []) {
|
|
564
|
+
const stored = rows4.filter((tier) => tier.active);
|
|
347
565
|
if (stored.length) return [...stored].sort(byOrder);
|
|
348
566
|
return [{
|
|
349
567
|
id: "standard",
|
|
@@ -389,15 +607,15 @@ function rowToTier(row, groupId) {
|
|
|
389
607
|
}
|
|
390
608
|
async function loadTiers(db, group) {
|
|
391
609
|
const groupId = String(group.id ?? "");
|
|
392
|
-
let
|
|
610
|
+
let rows4;
|
|
393
611
|
try {
|
|
394
|
-
|
|
612
|
+
rows4 = (await db.query({
|
|
395
613
|
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
396
614
|
})).tiers;
|
|
397
615
|
} catch {
|
|
398
|
-
|
|
616
|
+
rows4 = void 0;
|
|
399
617
|
}
|
|
400
|
-
const list = Array.isArray(
|
|
618
|
+
const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
|
|
401
619
|
return resolveTiers(group, list);
|
|
402
620
|
}
|
|
403
621
|
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
@@ -511,341 +729,252 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
511
729
|
};
|
|
512
730
|
}
|
|
513
731
|
|
|
514
|
-
// src/
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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 } };
|
|
519
811
|
}
|
|
520
|
-
|
|
521
|
-
|
|
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);
|
|
522
815
|
}
|
|
523
|
-
function
|
|
524
|
-
return
|
|
816
|
+
function findGiftOffer(catalog, offerId, now) {
|
|
817
|
+
return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
|
|
525
818
|
}
|
|
526
819
|
|
|
527
|
-
// src/
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
};
|
|
531
842
|
}
|
|
532
|
-
async function
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
|
|
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;
|
|
536
867
|
}
|
|
537
|
-
|
|
538
|
-
return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
868
|
+
return out;
|
|
539
869
|
}
|
|
540
|
-
async function
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
548
|
-
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;
|
|
549
877
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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;
|
|
559
902
|
}
|
|
560
|
-
function
|
|
561
|
-
|
|
562
|
-
|
|
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)}`;
|
|
563
912
|
}
|
|
564
|
-
function
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
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}`)}`;
|
|
568
920
|
}
|
|
569
|
-
function
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
}
|
|
572
953
|
return {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
954
|
+
version: 1,
|
|
955
|
+
sourceId: fallbackSourceId,
|
|
956
|
+
sourceRecordId: record2.hubRecordId,
|
|
957
|
+
type: "person",
|
|
958
|
+
input: sharedPersonInput(record2)
|
|
578
959
|
};
|
|
579
960
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
value
|
|
584
|
-
});
|
|
585
|
-
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
586
|
-
const { document, digest } = envelope;
|
|
587
|
-
const currentRows = (await db.query({ tiers: { $: {
|
|
588
|
-
where: { groupId: document.target.chapterId },
|
|
589
|
-
limit: 100
|
|
590
|
-
} } })).tiers ?? [];
|
|
591
|
-
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
592
|
-
const ops = [];
|
|
593
|
-
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
594
|
-
for (const row of currentRows) {
|
|
595
|
-
const id2 = String(row.id ?? "");
|
|
596
|
-
if (id2 && !incoming.has(id2)) {
|
|
597
|
-
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
for (const tier of document.tiers) {
|
|
601
|
-
const tierRef = byPublicId("tiers", tier.id);
|
|
602
|
-
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
603
|
-
id: tier.id,
|
|
604
|
-
groupId: document.target.chapterId,
|
|
605
|
-
name: tier.name,
|
|
606
|
-
priceCents: tier.priceCents,
|
|
607
|
-
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
608
|
-
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
609
|
-
sortOrder: tier.sortOrder,
|
|
610
|
-
active: tier.available,
|
|
611
|
-
createdAt: receivedAt
|
|
612
|
-
} });
|
|
613
|
-
const retract = [
|
|
614
|
-
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
615
|
-
...!tier.blurb ? ["blurb"] : []
|
|
616
|
-
];
|
|
617
|
-
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
618
|
-
}
|
|
619
|
-
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
620
|
-
const attrs = groupAttrs(document);
|
|
621
|
-
ops.push({
|
|
622
|
-
t: "update",
|
|
623
|
-
ns: "groups",
|
|
624
|
-
id: groupRef,
|
|
625
|
-
attrs
|
|
626
|
-
});
|
|
627
|
-
if (!("stripePriceId" in attrs)) {
|
|
628
|
-
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
629
|
-
}
|
|
630
|
-
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
631
|
-
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
632
|
-
id,
|
|
633
|
-
sourceId: document.source.appId,
|
|
634
|
-
targetId: document.target.chapterId,
|
|
635
|
-
environment: document.target.environment,
|
|
636
|
-
revision: document.revision,
|
|
637
|
-
digest,
|
|
638
|
-
document,
|
|
639
|
-
active: true,
|
|
640
|
-
receivedAt
|
|
641
|
-
} });
|
|
642
|
-
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// src/session.ts
|
|
646
|
-
function applicationSummary(app) {
|
|
647
|
-
return {
|
|
648
|
-
id: app.id,
|
|
649
|
-
firstName: app.firstName ?? null,
|
|
650
|
-
lastName: app.lastName ?? null,
|
|
651
|
-
email: app.email ?? null,
|
|
652
|
-
status: app.status,
|
|
653
|
-
createdAt: app.createdAt ?? null,
|
|
654
|
-
meetingLink: app.meetingLink ?? null,
|
|
655
|
-
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
656
|
-
renewalAt: app.renewalAt ?? null,
|
|
657
|
-
canceled: app.canceled === true
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
function memberApplication(app, meeting, defaultTimezone) {
|
|
661
|
-
const summary = applicationSummary(app);
|
|
662
|
-
let meetingAt = app.meetingAt ?? null;
|
|
663
|
-
let meetUrl = null;
|
|
664
|
-
let timezone = defaultTimezone;
|
|
665
|
-
if (meeting) {
|
|
666
|
-
timezone = meeting.timezone ?? timezone;
|
|
667
|
-
if (meeting.status === "scheduled") {
|
|
668
|
-
meetingAt = meeting.startAt ?? null;
|
|
669
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
670
|
-
} else {
|
|
671
|
-
meetingAt = null;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
return { ...summary, meetingAt, meetUrl, timezone };
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// src/clerk.ts
|
|
678
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
679
|
-
function clerkInviteRequest(input) {
|
|
680
|
-
return {
|
|
681
|
-
path: "/v1/invitations",
|
|
682
|
-
body: {
|
|
683
|
-
email_address: input.email,
|
|
684
|
-
notify: true,
|
|
685
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
686
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
687
|
-
}
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
691
|
-
const { path, body } = clerkInviteRequest(input);
|
|
692
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
693
|
-
method: "POST",
|
|
694
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
695
|
-
body: JSON.stringify(body)
|
|
696
|
-
});
|
|
697
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
698
|
-
}
|
|
699
|
-
function clerkUserRequest(input) {
|
|
700
|
-
return {
|
|
701
|
-
path: "/v1/users",
|
|
702
|
-
body: {
|
|
703
|
-
email_address: [input.email],
|
|
704
|
-
skip_password_requirement: true,
|
|
705
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
706
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
707
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
708
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
713
|
-
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
714
|
-
method: "PATCH",
|
|
715
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
716
|
-
body: JSON.stringify({
|
|
717
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
718
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
719
|
-
})
|
|
720
|
-
});
|
|
721
|
-
return res.ok;
|
|
722
|
-
}
|
|
723
|
-
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
724
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
725
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
726
|
-
if (!found.ok) return false;
|
|
727
|
-
const users = await found.json().catch(() => null);
|
|
728
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
729
|
-
if (!id) return false;
|
|
730
|
-
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
731
|
-
}
|
|
732
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
733
|
-
const { path, body } = clerkUserRequest(input);
|
|
734
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
735
|
-
method: "POST",
|
|
736
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
737
|
-
body: JSON.stringify(body)
|
|
738
|
-
});
|
|
739
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
740
|
-
const healed = heal(res.status);
|
|
741
|
-
const repair = input.repairMetadata ?? {
|
|
742
|
-
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
743
|
-
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
744
|
-
};
|
|
745
|
-
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
746
|
-
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
747
|
-
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
748
|
-
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
749
|
-
}, fetchImpl).catch(() => false);
|
|
750
|
-
return { ...healed, refreshed };
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// src/network.ts
|
|
754
|
-
var import_crm2 = require("@odla-ai/crm");
|
|
755
|
-
|
|
756
|
-
// src/network-contract.ts
|
|
757
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
758
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
759
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
// src/network.ts
|
|
763
|
-
function sharedPersonInput(person) {
|
|
764
|
-
const email = person.email.toLowerCase();
|
|
765
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
766
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
767
|
-
if (input.name === "") input.name = email;
|
|
768
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
769
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
770
|
-
if (person.phone) input.phone = person.phone;
|
|
771
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
772
|
-
return input;
|
|
773
|
-
}
|
|
774
|
-
function shortHash(value) {
|
|
775
|
-
let a = 2166136261;
|
|
776
|
-
let b = 2654435769;
|
|
777
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
778
|
-
const n = value.charCodeAt(i);
|
|
779
|
-
a = Math.imul(a ^ n, 16777619);
|
|
780
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
781
|
-
}
|
|
782
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
783
|
-
}
|
|
784
|
-
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
785
|
-
const typeKey = type.toLowerCase();
|
|
786
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
787
|
-
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
788
|
-
const raw = `${prefix}:${hubRecordId}`;
|
|
789
|
-
if (readable && raw.length <= 64) return raw;
|
|
790
|
-
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
791
|
-
}
|
|
792
|
-
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
793
|
-
if ("input" in record && record.version === 2) {
|
|
794
|
-
return {
|
|
795
|
-
version: 2,
|
|
796
|
-
sourceId: record.source.siteId,
|
|
797
|
-
sourceRecordId: record.source.recordId,
|
|
798
|
-
type: record.type,
|
|
799
|
-
input: record.input
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
if ("input" in record) {
|
|
803
|
-
return {
|
|
804
|
-
version: 1,
|
|
805
|
-
sourceId: fallbackSourceId,
|
|
806
|
-
sourceRecordId: record.hubRecordId,
|
|
807
|
-
type: record.type,
|
|
808
|
-
input: record.input
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
if ("type" in record && record.type === "company") {
|
|
812
|
-
const input = { name: record.name };
|
|
813
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
814
|
-
if (record[key]) input[key] = record[key];
|
|
815
|
-
}
|
|
816
|
-
return {
|
|
817
|
-
version: 1,
|
|
818
|
-
sourceId: fallbackSourceId,
|
|
819
|
-
sourceRecordId: record.hubRecordId,
|
|
820
|
-
type: "company",
|
|
821
|
-
input
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
return {
|
|
825
|
-
version: 1,
|
|
826
|
-
sourceId: fallbackSourceId,
|
|
827
|
-
sourceRecordId: record.hubRecordId,
|
|
828
|
-
type: "person",
|
|
829
|
-
input: sharedPersonInput(record)
|
|
830
|
-
};
|
|
831
|
-
}
|
|
832
|
-
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
833
|
-
if (target.fields && !target.fields[record.type]) {
|
|
834
|
-
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`);
|
|
835
964
|
}
|
|
836
|
-
const def = crm.type(
|
|
837
|
-
const fields = target.fields?.[
|
|
965
|
+
const def = crm.type(record2.type);
|
|
966
|
+
const fields = target.fields?.[record2.type] ?? DEFAULT_SHARE_FIELDS[record2.type];
|
|
838
967
|
if (!fields) {
|
|
839
|
-
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`);
|
|
840
969
|
}
|
|
841
970
|
const nameField = def.nameField ?? "name";
|
|
842
971
|
const input = {};
|
|
843
972
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
844
|
-
const value =
|
|
973
|
+
const value = record2.fields?.[field];
|
|
845
974
|
if (value !== void 0) input[field] = value;
|
|
846
975
|
}
|
|
847
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
848
|
-
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 };
|
|
849
978
|
}
|
|
850
979
|
async function upsertPerson(deps, opts) {
|
|
851
980
|
const email = opts.email.toLowerCase();
|
|
@@ -859,11 +988,11 @@ async function upsertPerson(deps, opts) {
|
|
|
859
988
|
const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
860
989
|
return { recordId: created.id };
|
|
861
990
|
}
|
|
862
|
-
async function findSharedRecord(deps,
|
|
991
|
+
async function findSharedRecord(deps, record2, tag) {
|
|
863
992
|
const structured = await (0, import_crm2.getRecordByOrigin)(
|
|
864
993
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
865
|
-
|
|
866
|
-
|
|
994
|
+
record2.sourceId,
|
|
995
|
+
record2.sourceRecordId
|
|
867
996
|
);
|
|
868
997
|
if (structured) return structured;
|
|
869
998
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -872,53 +1001,53 @@ async function findSharedRecord(deps, record, tag) {
|
|
|
872
1001
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
873
1002
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
874
1003
|
}
|
|
875
|
-
const def = deps.crm.type(
|
|
1004
|
+
const def = deps.crm.type(record2.type);
|
|
876
1005
|
const emailField = def.emailField;
|
|
877
|
-
if (emailField && typeof
|
|
878
|
-
const primaryEmail =
|
|
1006
|
+
if (emailField && typeof record2.input[emailField] === "string") {
|
|
1007
|
+
const primaryEmail = record2.input[emailField].toLowerCase();
|
|
879
1008
|
const found = await deps.db.query({
|
|
880
|
-
crm_record: { $: { where: { type:
|
|
1009
|
+
crm_record: { $: { where: { type: record2.type, primaryEmail }, limit: 1 } }
|
|
881
1010
|
});
|
|
882
1011
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
883
1012
|
}
|
|
884
|
-
const domain =
|
|
1013
|
+
const domain = record2.input.domain;
|
|
885
1014
|
const domainSlot = def.fields.domain?.slot;
|
|
886
1015
|
if (typeof domain === "string" && domainSlot) {
|
|
887
1016
|
const found = await deps.db.query({
|
|
888
|
-
crm_record: { $: { where: { type:
|
|
1017
|
+
crm_record: { $: { where: { type: record2.type, [domainSlot]: domain }, limit: 1 } }
|
|
889
1018
|
});
|
|
890
1019
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
891
1020
|
}
|
|
892
1021
|
const nameField = def.nameField ?? "name";
|
|
893
|
-
const name =
|
|
894
|
-
if (
|
|
1022
|
+
const name = record2.input[nameField];
|
|
1023
|
+
if (record2.type === "company" && typeof name === "string" && name.trim()) {
|
|
895
1024
|
const found = await deps.db.query({
|
|
896
|
-
crm_record: { $: { where: { type:
|
|
1025
|
+
crm_record: { $: { where: { type: record2.type, name: name.trim() }, limit: 1 } }
|
|
897
1026
|
});
|
|
898
1027
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
899
1028
|
}
|
|
900
1029
|
return void 0;
|
|
901
1030
|
}
|
|
902
1031
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
903
|
-
const
|
|
904
|
-
if (!
|
|
1032
|
+
const record2 = normalizeSharedRecord(shared, options.sourceId);
|
|
1033
|
+
if (!record2.type.trim() || !record2.sourceId.trim() || !record2.sourceRecordId.trim()) {
|
|
905
1034
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
906
1035
|
}
|
|
907
|
-
if (options.sourceId && options.sourceId !==
|
|
1036
|
+
if (options.sourceId && options.sourceId !== record2.sourceId) {
|
|
908
1037
|
throw new Error("signed sender does not match payload source");
|
|
909
1038
|
}
|
|
910
|
-
const tag = networkSourceTag(
|
|
1039
|
+
const tag = networkSourceTag(record2.type, record2.sourceRecordId, record2.version === 2 ? record2.sourceId : void 0);
|
|
911
1040
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
912
|
-
const existing = await findSharedRecord(deps,
|
|
1041
|
+
const existing = await findSharedRecord(deps, record2, tag);
|
|
913
1042
|
let recordId;
|
|
914
1043
|
if (existing && typeof existing.id === "string") {
|
|
915
|
-
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input:
|
|
1044
|
+
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record2.input });
|
|
916
1045
|
recordId = existing.id;
|
|
917
1046
|
} else {
|
|
918
|
-
recordId = `network_${shortHash(`${
|
|
1047
|
+
recordId = `network_${shortHash(`${record2.sourceId}\0${record2.type}\0${record2.sourceRecordId}`)}`;
|
|
919
1048
|
await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
|
|
920
|
-
type:
|
|
921
|
-
input:
|
|
1049
|
+
type: record2.type,
|
|
1050
|
+
input: record2.input,
|
|
922
1051
|
mutationId: `share-create:${tag}`
|
|
923
1052
|
});
|
|
924
1053
|
}
|
|
@@ -928,10 +1057,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
928
1057
|
);
|
|
929
1058
|
await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
|
|
930
1059
|
recordId,
|
|
931
|
-
sourceId:
|
|
932
|
-
sourceRecordId:
|
|
1060
|
+
sourceId: record2.sourceId,
|
|
1061
|
+
sourceRecordId: record2.sourceRecordId,
|
|
933
1062
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
934
|
-
payloadVersion:
|
|
1063
|
+
payloadVersion: record2.version
|
|
935
1064
|
});
|
|
936
1065
|
return { recordId };
|
|
937
1066
|
}
|
|
@@ -954,6 +1083,92 @@ async function projectApplicant(deps, applicant) {
|
|
|
954
1083
|
}
|
|
955
1084
|
}
|
|
956
1085
|
|
|
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;
|
|
1109
|
+
}
|
|
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
|
+
|
|
957
1172
|
// src/email.ts
|
|
958
1173
|
function render(template, vars) {
|
|
959
1174
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
@@ -984,10 +1199,10 @@ function planDelivery(input) {
|
|
|
984
1199
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
985
1200
|
const to = redirect ? input.group.debugEmail : input.to;
|
|
986
1201
|
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
987
|
-
const
|
|
1202
|
+
const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
988
1203
|
|
|
989
1204
|
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
990
|
-
return { deliver: true, transport, to, subject, text:
|
|
1205
|
+
return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
|
|
991
1206
|
}
|
|
992
1207
|
|
|
993
1208
|
// src/notify.ts
|
|
@@ -1023,38 +1238,371 @@ async function sendTemplated(deps, input) {
|
|
|
1023
1238
|
error = e instanceof Error ? e.message : String(e);
|
|
1024
1239
|
}
|
|
1025
1240
|
}
|
|
1026
|
-
const id = deps.newId();
|
|
1027
|
-
const row = {
|
|
1028
|
-
id,
|
|
1029
|
-
groupId: input.group.id,
|
|
1030
|
-
to: decision.to,
|
|
1031
|
-
template: input.template,
|
|
1032
|
-
subject: decision.subject,
|
|
1033
|
-
body: decision.text,
|
|
1034
|
-
transport: decision.transport,
|
|
1035
|
-
redirected: decision.redirected,
|
|
1036
|
-
dedupeKey: input.dedupeKey,
|
|
1037
|
-
sentAt: deps.now(),
|
|
1038
|
-
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1039
|
-
...messageId ? { messageId } : {},
|
|
1040
|
-
...error ? { error } : {}
|
|
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
|
+
}
|
|
1041
1543
|
};
|
|
1042
|
-
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1043
|
-
return error ? { sent: false, reason: error } : { sent: true };
|
|
1044
1544
|
}
|
|
1045
|
-
function
|
|
1046
|
-
const
|
|
1047
|
-
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) {
|
|
1048
1555
|
return {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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 } : {}
|
|
1057
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 };
|
|
1058
1606
|
}
|
|
1059
1607
|
|
|
1060
1608
|
// src/worker-provisioning.ts
|
|
@@ -1184,8 +1732,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1184
1732
|
|
|
1185
1733
|
// src/worker-routes.ts
|
|
1186
1734
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1187
|
-
|
|
1188
|
-
|
|
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;
|
|
1189
1741
|
if (!app) return null;
|
|
1190
1742
|
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
1191
1743
|
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
@@ -1241,7 +1793,7 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
1241
1793
|
const crmBase = ctx.crmBase;
|
|
1242
1794
|
if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
|
|
1243
1795
|
const db = ctx.makeDb(env);
|
|
1244
|
-
const routes = (0,
|
|
1796
|
+
const routes = (0, import_crm4.createCrmRoutes)({
|
|
1245
1797
|
crm: ctx.chapter.crm,
|
|
1246
1798
|
db,
|
|
1247
1799
|
authorize: async (r) => {
|
|
@@ -1287,7 +1839,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1287
1839
|
membershipPage: activeControl.document.membershipPage,
|
|
1288
1840
|
application: activeControl.document.application,
|
|
1289
1841
|
theme: activeControl.document.theme
|
|
1290
|
-
} } : {}
|
|
1842
|
+
} } : {},
|
|
1843
|
+
giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
|
|
1291
1844
|
});
|
|
1292
1845
|
}
|
|
1293
1846
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
@@ -1326,18 +1879,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1326
1879
|
})).groups;
|
|
1327
1880
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1328
1881
|
if (!group) return json({ error: "not found" }, 404);
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1333
|
-
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1334
|
-
if (!payable) return json({ state: "booking", applicationId });
|
|
1335
|
-
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1336
|
-
return json({ state: "paymentPending", applicationId });
|
|
1337
|
-
}
|
|
1338
|
-
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
1339
|
-
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
1340
|
-
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);
|
|
1341
1885
|
}
|
|
1342
1886
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
1343
1887
|
const raw = await req.text();
|
|
@@ -1353,12 +1897,19 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1353
1897
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
1354
1898
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
1355
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;
|
|
1356
1905
|
const result = await submitApplication(db, effectiveChapter, parsed, {
|
|
1357
1906
|
submissionId,
|
|
1358
1907
|
groupId: chapter.id,
|
|
1359
|
-
now
|
|
1908
|
+
now,
|
|
1360
1909
|
newId: () => crypto.randomUUID(),
|
|
1361
|
-
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1910
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
1911
|
+
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
1912
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
1362
1913
|
});
|
|
1363
1914
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1364
1915
|
if (!result.duplicate) {
|
|
@@ -1367,10 +1918,16 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1367
1918
|
}
|
|
1368
1919
|
await provisionApplicant(db, effectiveChapter, result.id, parsed);
|
|
1369
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;
|
|
1370
1926
|
return json({
|
|
1371
1927
|
id: result.id,
|
|
1372
1928
|
duplicate: result.duplicate,
|
|
1373
1929
|
status: result.status,
|
|
1930
|
+
...state ? { state } : {},
|
|
1374
1931
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
1375
1932
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
1376
1933
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -1426,8 +1983,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1426
1983
|
payload,
|
|
1427
1984
|
sourceId ? { sourceId } : {}
|
|
1428
1985
|
);
|
|
1429
|
-
const
|
|
1430
|
-
return json({ recordId, type:
|
|
1986
|
+
const record2 = normalizeSharedRecord(payload, sourceId);
|
|
1987
|
+
return json({ recordId, type: record2.type });
|
|
1431
1988
|
} catch (error) {
|
|
1432
1989
|
return json({
|
|
1433
1990
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1438,29 +1995,6 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1438
1995
|
// src/worker-routes-signup-control.ts
|
|
1439
1996
|
var import_db3 = require("@odla-ai/db");
|
|
1440
1997
|
|
|
1441
|
-
// src/signup-control-canonical.ts
|
|
1442
|
-
var encoder = new TextEncoder();
|
|
1443
|
-
function canonicalValue(value) {
|
|
1444
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
1445
|
-
return JSON.stringify(value);
|
|
1446
|
-
}
|
|
1447
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
1448
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
1449
|
-
const record = value;
|
|
1450
|
-
const entries = Object.keys(record).sort().flatMap((key) => {
|
|
1451
|
-
const item = record[key];
|
|
1452
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
1453
|
-
});
|
|
1454
|
-
return `{${entries.join(",")}}`;
|
|
1455
|
-
}
|
|
1456
|
-
function canonicalSignupControl(value) {
|
|
1457
|
-
return canonicalValue(value);
|
|
1458
|
-
}
|
|
1459
|
-
async function signupControlDigest(document) {
|
|
1460
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
1461
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
1998
|
// src/signup-control-contract.ts
|
|
1465
1999
|
var SIGNUP_CONTROL_PATH = "/api/signup-control";
|
|
1466
2000
|
|
|
@@ -1504,10 +2038,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
1504
2038
|
errors.push(`${path} must be a string`);
|
|
1505
2039
|
return void 0;
|
|
1506
2040
|
}
|
|
1507
|
-
const
|
|
1508
|
-
if (required && !
|
|
1509
|
-
if (
|
|
1510
|
-
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;
|
|
1511
2045
|
}
|
|
1512
2046
|
function validateMembershipPageDocument(input, options = {}) {
|
|
1513
2047
|
const errors = [];
|
|
@@ -1556,8 +2090,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1556
2090
|
else {
|
|
1557
2091
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
1558
2092
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
1559
|
-
const
|
|
1560
|
-
return
|
|
2093
|
+
const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
2094
|
+
return text3 ? [text3] : [];
|
|
1561
2095
|
});
|
|
1562
2096
|
}
|
|
1563
2097
|
}
|
|
@@ -1631,7 +2165,7 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1631
2165
|
var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1632
2166
|
var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
1633
2167
|
var TIER = /^[a-z][a-z0-9-]{0,79}$/;
|
|
1634
|
-
var
|
|
2168
|
+
var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
|
|
1635
2169
|
var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
1636
2170
|
"schemaVersion",
|
|
1637
2171
|
"revision",
|
|
@@ -1643,14 +2177,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
1643
2177
|
"application",
|
|
1644
2178
|
"policy",
|
|
1645
2179
|
"scheduling",
|
|
1646
|
-
"theme"
|
|
2180
|
+
"theme",
|
|
2181
|
+
"giftOffers"
|
|
1647
2182
|
]);
|
|
1648
2183
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
1649
2184
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1650
2185
|
function unknownKeys(value, allowed, path, errors) {
|
|
1651
2186
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1652
2187
|
}
|
|
1653
|
-
function
|
|
2188
|
+
function text2(value, path, errors, max = 65536) {
|
|
1654
2189
|
if (typeof value !== "string" || !value.trim()) {
|
|
1655
2190
|
errors.push(`${path} is required`);
|
|
1656
2191
|
return "";
|
|
@@ -1673,23 +2208,23 @@ function tiersOf(value, errors) {
|
|
|
1673
2208
|
return [];
|
|
1674
2209
|
}
|
|
1675
2210
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
1676
|
-
const id =
|
|
2211
|
+
const id = text2(item.id, `${path}.id`, errors, 80);
|
|
1677
2212
|
if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
1678
2213
|
if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
1679
2214
|
seen.add(id);
|
|
1680
|
-
const name =
|
|
2215
|
+
const name = text2(item.name, `${path}.name`, errors, 160);
|
|
1681
2216
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
1682
2217
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
1683
2218
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
1684
2219
|
errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
|
|
1685
2220
|
}
|
|
1686
|
-
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !
|
|
2221
|
+
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
|
|
1687
2222
|
errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
1688
2223
|
}
|
|
1689
2224
|
if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
|
|
1690
2225
|
errors.push(`${path}.stripePriceId is required for an available paid tier`);
|
|
1691
2226
|
}
|
|
1692
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
2227
|
+
const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
1693
2228
|
return [{
|
|
1694
2229
|
id,
|
|
1695
2230
|
name,
|
|
@@ -1738,7 +2273,7 @@ function policyOf(value, errors) {
|
|
|
1738
2273
|
const result = {};
|
|
1739
2274
|
for (const key of allowed) {
|
|
1740
2275
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
1741
|
-
result[key] =
|
|
2276
|
+
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
1742
2277
|
}
|
|
1743
2278
|
return result;
|
|
1744
2279
|
}
|
|
@@ -1776,9 +2311,9 @@ function validateSignupControlDocument(input) {
|
|
|
1776
2311
|
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1777
2312
|
const source = recordOf(root.source);
|
|
1778
2313
|
const target = recordOf(root.target);
|
|
1779
|
-
const appId =
|
|
1780
|
-
const chapterId =
|
|
1781
|
-
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);
|
|
1782
2317
|
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1783
2318
|
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1784
2319
|
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
@@ -1799,6 +2334,8 @@ function validateSignupControlDocument(input) {
|
|
|
1799
2334
|
else scheduling = root.scheduling;
|
|
1800
2335
|
}
|
|
1801
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);
|
|
1802
2339
|
if (errors.length || !page.ok) return { ok: false, errors };
|
|
1803
2340
|
return { ok: true, value: {
|
|
1804
2341
|
schemaVersion: 1,
|
|
@@ -1811,7 +2348,8 @@ function validateSignupControlDocument(input) {
|
|
|
1811
2348
|
...application ? { application } : {},
|
|
1812
2349
|
...policy ? { policy } : {},
|
|
1813
2350
|
...scheduling ? { scheduling } : {},
|
|
1814
|
-
...theme ? { theme } : {}
|
|
2351
|
+
...theme ? { theme } : {},
|
|
2352
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
1815
2353
|
} };
|
|
1816
2354
|
}
|
|
1817
2355
|
|
|
@@ -1872,92 +2410,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1872
2410
|
});
|
|
1873
2411
|
};
|
|
1874
2412
|
|
|
1875
|
-
// src/
|
|
1876
|
-
var
|
|
1877
|
-
var
|
|
1878
|
-
var
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
});
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
hubRecordId: str(app.id)
|
|
1893
|
-
});
|
|
1894
|
-
for (const f of chapter.application.crmFields) {
|
|
1895
|
-
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"]);
|
|
1896
2430
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (
|
|
1907
|
-
if (
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
});
|
|
1918
|
-
const existing = crm_record?.[0] ?? null;
|
|
1919
|
-
const stage = opts.stage || void 0;
|
|
1920
|
-
let recordId;
|
|
1921
|
-
if (existing && typeof existing.id === "string") {
|
|
1922
|
-
recordId = existing.id;
|
|
1923
|
-
await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
|
|
1924
|
-
} else {
|
|
1925
|
-
const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1926
|
-
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
|
+
});
|
|
1927
2451
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
2452
|
+
const receivedAt = Date.now();
|
|
2453
|
+
try {
|
|
2454
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2455
|
+
} catch {
|
|
2456
|
+
return reject2("materialization_failed", 503);
|
|
1930
2457
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
const
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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;
|
|
1947
2492
|
try {
|
|
1948
|
-
await
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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 = "";
|
|
1952
2540
|
}
|
|
1953
|
-
};
|
|
1954
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1955
|
-
for (const u of usersRes.$users ?? []) {
|
|
1956
|
-
if (u.deleted === true) continue;
|
|
1957
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1958
2541
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
+
};
|
|
1961
2563
|
|
|
1962
2564
|
// src/worker-routes-schedule.ts
|
|
1963
2565
|
var import_calendar = require("@odla-ai/calendar");
|
|
@@ -1973,8 +2575,8 @@ function makeCalendar(env) {
|
|
|
1973
2575
|
}
|
|
1974
2576
|
async function firstRow(db, ns, q) {
|
|
1975
2577
|
const res = await db.query({ [ns]: { $: q } });
|
|
1976
|
-
const
|
|
1977
|
-
return Array.isArray(
|
|
2578
|
+
const rows4 = res[ns];
|
|
2579
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1978
2580
|
}
|
|
1979
2581
|
async function computeSlots(cal, cfg) {
|
|
1980
2582
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2111,8 +2713,26 @@ function findApplicationRef(obj) {
|
|
|
2111
2713
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2112
2714
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2113
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
|
+
}
|
|
2114
2725
|
function normalizeWebhookEvent(event) {
|
|
2115
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
|
+
}
|
|
2116
2736
|
const ref = findApplicationRef(obj);
|
|
2117
2737
|
switch (event.type) {
|
|
2118
2738
|
case "invoice.paid": {
|
|
@@ -2213,9 +2833,9 @@ function createStripeProvider(config) {
|
|
|
2213
2833
|
`sub:${input.applicationId}`
|
|
2214
2834
|
);
|
|
2215
2835
|
if (!sub.ok) fail("subscription create", sub);
|
|
2216
|
-
const
|
|
2217
|
-
if (!
|
|
2218
|
-
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 };
|
|
2219
2839
|
},
|
|
2220
2840
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2221
2841
|
if (!webhookSecret || !await (0, import_stripe2.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2228,8 +2848,8 @@ function createStripeProvider(config) {
|
|
|
2228
2848
|
const sk = requireSecret(secretKey);
|
|
2229
2849
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2230
2850
|
if (!charges.ok) fail("charges list", charges);
|
|
2231
|
-
const
|
|
2232
|
-
const paid =
|
|
2851
|
+
const rows4 = charges.body.data ?? [];
|
|
2852
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2233
2853
|
const charge = paid[paid.length - 1];
|
|
2234
2854
|
if (!charge) {
|
|
2235
2855
|
const err = new Error("no paid charge to refund");
|
|
@@ -2245,12 +2865,337 @@ function createStripeProvider(config) {
|
|
|
2245
2865
|
};
|
|
2246
2866
|
}
|
|
2247
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
|
+
|
|
2248
3193
|
// src/worker-routes-payments.ts
|
|
2249
3194
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2250
3195
|
async function firstRow2(db, ns, q) {
|
|
2251
3196
|
const res = await db.query({ [ns]: { $: q } });
|
|
2252
|
-
const
|
|
2253
|
-
return Array.isArray(
|
|
3197
|
+
const rows4 = res[ns];
|
|
3198
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2254
3199
|
}
|
|
2255
3200
|
function lineItems(group) {
|
|
2256
3201
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2335,6 +3280,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2335
3280
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2336
3281
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2337
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
|
+
}
|
|
2338
3313
|
const tier = findTier(
|
|
2339
3314
|
await loadTiers(db, group),
|
|
2340
3315
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2376,17 +3351,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2376
3351
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2377
3352
|
const { eventId, event } = ingest;
|
|
2378
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
|
+
}
|
|
2379
3380
|
const app = await findApplication(db, event);
|
|
2380
3381
|
if (!app) return json({ ok: true, matched: false });
|
|
2381
3382
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3383
|
+
let next = app;
|
|
2382
3384
|
if (Object.keys(patch).length) {
|
|
2383
3385
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2384
|
-
|
|
3386
|
+
next = { ...app, ...patch };
|
|
2385
3387
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2386
3388
|
}
|
|
2387
3389
|
if (event.kind === "first_payment") {
|
|
2388
3390
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2389
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
|
+
}
|
|
2390
3402
|
}
|
|
2391
3403
|
return json({ ok: true });
|
|
2392
3404
|
}
|
|
@@ -2397,6 +3409,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2397
3409
|
return null;
|
|
2398
3410
|
};
|
|
2399
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
|
+
|
|
2400
3550
|
// src/worker-routes-admin.ts
|
|
2401
3551
|
var import_calendar2 = require("@odla-ai/calendar");
|
|
2402
3552
|
|
|
@@ -2468,8 +3618,8 @@ async function upcomingEvents(env) {
|
|
|
2468
3618
|
const res = await cal.availability.upcoming();
|
|
2469
3619
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2470
3620
|
}
|
|
2471
|
-
function toReconcile(
|
|
2472
|
-
return
|
|
3621
|
+
function toReconcile(rows4) {
|
|
3622
|
+
return rows4.map((m) => ({
|
|
2473
3623
|
id: String(m.id),
|
|
2474
3624
|
applicationId: String(m.applicationId),
|
|
2475
3625
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2491,12 +3641,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2491
3641
|
const query = await db.query({
|
|
2492
3642
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2493
3643
|
});
|
|
2494
|
-
let
|
|
2495
|
-
if (Number.isFinite(from))
|
|
2496
|
-
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);
|
|
2497
3647
|
let decisions = [];
|
|
2498
3648
|
try {
|
|
2499
|
-
decisions = reconcileMeetings(toReconcile(
|
|
3649
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2500
3650
|
} catch {
|
|
2501
3651
|
}
|
|
2502
3652
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2523,69 +3673,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2523
3673
|
};
|
|
2524
3674
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2525
3675
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2526
|
-
const meetings =
|
|
3676
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2527
3677
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2528
3678
|
};
|
|
2529
3679
|
|
|
2530
|
-
// src/clerk-roles.ts
|
|
2531
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2532
|
-
var DEFAULT_ROLE = "provisional";
|
|
2533
|
-
var PAGE = 100;
|
|
2534
|
-
function toRecord(u) {
|
|
2535
|
-
if (typeof u.id !== "string") return null;
|
|
2536
|
-
const pm = u.public_metadata ?? {};
|
|
2537
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2538
|
-
const rawProfile = privateMetadata.profile;
|
|
2539
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2540
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2541
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2542
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2543
|
-
return {
|
|
2544
|
-
id: u.id,
|
|
2545
|
-
email: typeof email === "string" ? email : void 0,
|
|
2546
|
-
role,
|
|
2547
|
-
publicMetadata: pm,
|
|
2548
|
-
privateMetadata,
|
|
2549
|
-
profile,
|
|
2550
|
-
...applicationId ? { applicationId } : {}
|
|
2551
|
-
};
|
|
2552
|
-
}
|
|
2553
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2554
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2555
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2556
|
-
return res.json();
|
|
2557
|
-
}
|
|
2558
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2559
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2560
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2561
|
-
return user ? toRecord(user) : null;
|
|
2562
|
-
}
|
|
2563
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2564
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2565
|
-
return data ? toRecord(data) : null;
|
|
2566
|
-
}
|
|
2567
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2568
|
-
const out = [];
|
|
2569
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2570
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2571
|
-
const page = Array.isArray(data) ? data : [];
|
|
2572
|
-
for (const u of page) {
|
|
2573
|
-
const record = toRecord(u);
|
|
2574
|
-
if (record) out.push(record);
|
|
2575
|
-
}
|
|
2576
|
-
if (page.length < PAGE) break;
|
|
2577
|
-
}
|
|
2578
|
-
return out;
|
|
2579
|
-
}
|
|
2580
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2581
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2582
|
-
method: "PATCH",
|
|
2583
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2584
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2585
|
-
});
|
|
2586
|
-
return res.ok;
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
3680
|
// src/worker-routes-admin-people.ts
|
|
2590
3681
|
async function adminGate(req, env, ctx) {
|
|
2591
3682
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2603,16 +3694,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2603
3694
|
});
|
|
2604
3695
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2605
3696
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2606
|
-
const
|
|
2607
|
-
if (
|
|
2608
|
-
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));
|
|
2609
3700
|
return json({ ok: true, ...result });
|
|
2610
3701
|
};
|
|
2611
3702
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2612
3703
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2613
|
-
const
|
|
2614
|
-
if (
|
|
2615
|
-
const { db } =
|
|
3704
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3705
|
+
if (gate9 instanceof Response) return gate9;
|
|
3706
|
+
const { db } = gate9;
|
|
2616
3707
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2617
3708
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2618
3709
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2656,16 +3747,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2656
3747
|
});
|
|
2657
3748
|
}
|
|
2658
3749
|
}
|
|
2659
|
-
const
|
|
3750
|
+
const rows4 = [...people.values()].sort(
|
|
2660
3751
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2661
3752
|
);
|
|
2662
|
-
return json({ people:
|
|
3753
|
+
return json({ people: rows4 });
|
|
2663
3754
|
};
|
|
2664
3755
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2665
3756
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2666
|
-
const
|
|
2667
|
-
if (
|
|
2668
|
-
const { db } =
|
|
3757
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3758
|
+
if (gate9 instanceof Response) return gate9;
|
|
3759
|
+
const { db } = gate9;
|
|
2669
3760
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2670
3761
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2671
3762
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2676,9 +3767,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2676
3767
|
};
|
|
2677
3768
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2678
3769
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2679
|
-
const
|
|
2680
|
-
if (
|
|
2681
|
-
const { db, actor } =
|
|
3770
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3771
|
+
if (gate9 instanceof Response) return gate9;
|
|
3772
|
+
const { db, actor } = gate9;
|
|
2682
3773
|
let body;
|
|
2683
3774
|
try {
|
|
2684
3775
|
body = await req.json();
|
|
@@ -2825,17 +3916,17 @@ function subAnnualCents(sub) {
|
|
|
2825
3916
|
}
|
|
2826
3917
|
|
|
2827
3918
|
// src/worker-routes-admin-dashboard.ts
|
|
2828
|
-
async function
|
|
3919
|
+
async function gate2(req, env, ctx) {
|
|
2829
3920
|
const rawDb = ctx.makeDb(env);
|
|
2830
3921
|
const u = await ctx.verifyUser(req, env);
|
|
2831
3922
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2832
3923
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2833
3924
|
return rawDb;
|
|
2834
3925
|
}
|
|
2835
|
-
var
|
|
3926
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2836
3927
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2837
3928
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2838
|
-
const got = await
|
|
3929
|
+
const got = await gate2(req, env, ctx);
|
|
2839
3930
|
if (got instanceof Response) return got;
|
|
2840
3931
|
const db = got;
|
|
2841
3932
|
const now = Date.now();
|
|
@@ -2847,7 +3938,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2847
3938
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2848
3939
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2849
3940
|
]);
|
|
2850
|
-
const apps =
|
|
3941
|
+
const apps = rows3(appsRes.applications);
|
|
2851
3942
|
const applications = {
|
|
2852
3943
|
total: apps.length,
|
|
2853
3944
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2859,7 +3950,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2859
3950
|
pipeline[s] = 0;
|
|
2860
3951
|
pipelineDelta[s] = 0;
|
|
2861
3952
|
}
|
|
2862
|
-
for (const r of
|
|
3953
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2863
3954
|
const s = r.stage;
|
|
2864
3955
|
if (s in pipeline) {
|
|
2865
3956
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2867,7 +3958,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2867
3958
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2868
3959
|
}
|
|
2869
3960
|
}
|
|
2870
|
-
const meetings =
|
|
3961
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2871
3962
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2872
3963
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2873
3964
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2892,7 +3983,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2892
3983
|
if (sk) {
|
|
2893
3984
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2894
3985
|
if (subsRes.ok) {
|
|
2895
|
-
const subs =
|
|
3986
|
+
const subs = rows3(subsRes.body.data);
|
|
2896
3987
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2897
3988
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2898
3989
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2912,7 +4003,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2912
4003
|
};
|
|
2913
4004
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2914
4005
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2915
|
-
const got = await
|
|
4006
|
+
const got = await gate2(req, env, ctx);
|
|
2916
4007
|
if (got instanceof Response) return got;
|
|
2917
4008
|
const db = got;
|
|
2918
4009
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2924,9 +4015,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2924
4015
|
]);
|
|
2925
4016
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2926
4017
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2927
|
-
const subById = new Map(
|
|
4018
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2928
4019
|
const billingRows = [];
|
|
2929
|
-
for (const a of
|
|
4020
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2930
4021
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2931
4022
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2932
4023
|
const items = sub?.items?.data ?? [];
|
|
@@ -2970,7 +4061,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2970
4061
|
|
|
2971
4062
|
// src/worker-routes-admin-lifecycle.ts
|
|
2972
4063
|
var import_calendar3 = require("@odla-ai/calendar");
|
|
2973
|
-
async function
|
|
4064
|
+
async function gate3(req, env, ctx) {
|
|
2974
4065
|
const rawDb = ctx.makeDb(env);
|
|
2975
4066
|
const u = await ctx.verifyUser(req, env);
|
|
2976
4067
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2996,7 +4087,7 @@ async function loadGroup(db, id) {
|
|
|
2996
4087
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2997
4088
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2998
4089
|
if (req.method !== "POST" || !m) return null;
|
|
2999
|
-
const got = await
|
|
4090
|
+
const got = await gate3(req, env, ctx);
|
|
3000
4091
|
if (got instanceof Response) return got;
|
|
3001
4092
|
const db = got;
|
|
3002
4093
|
const body = await readJson(req);
|
|
@@ -3037,7 +4128,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3037
4128
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3038
4129
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3039
4130
|
if (req.method !== "POST" || !m) return null;
|
|
3040
|
-
const got = await
|
|
4131
|
+
const got = await gate3(req, env, ctx);
|
|
3041
4132
|
if (got instanceof Response) return got;
|
|
3042
4133
|
const db = got;
|
|
3043
4134
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3060,43 +4151,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3060
4151
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3061
4152
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3062
4153
|
if (req.method !== "POST" || !m) return null;
|
|
3063
|
-
const got = await
|
|
4154
|
+
const got = await gate3(req, env, ctx);
|
|
3064
4155
|
if (got instanceof Response) return got;
|
|
3065
4156
|
const db = got;
|
|
3066
4157
|
const id = m[1];
|
|
3067
4158
|
const app = await loadApp(db, id);
|
|
3068
4159
|
if (!app) return json({ error: "not found" }, 404);
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3073
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3074
|
-
let rolePromoted = false;
|
|
3075
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3076
|
-
if (promoteTo !== false && sk) {
|
|
3077
|
-
let userId = app.clerkUserId || null;
|
|
3078
|
-
if (!userId) {
|
|
3079
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3080
|
-
userId = found?.id ?? null;
|
|
3081
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3082
|
-
}
|
|
3083
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3084
|
-
}
|
|
3085
|
-
let emailLogged = false;
|
|
3086
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3087
|
-
if (send !== false && group) {
|
|
3088
|
-
const res = await sendTemplated(
|
|
3089
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3090
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3091
|
-
);
|
|
3092
|
-
emailLogged = res.sent;
|
|
3093
|
-
}
|
|
3094
|
-
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 });
|
|
3095
4163
|
};
|
|
3096
4164
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3097
4165
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3098
4166
|
if (req.method !== "POST" || !m) return null;
|
|
3099
|
-
const got = await
|
|
4167
|
+
const got = await gate3(req, env, ctx);
|
|
3100
4168
|
if (got instanceof Response) return got;
|
|
3101
4169
|
const db = got;
|
|
3102
4170
|
const app = await loadApp(db, m[1]);
|
|
@@ -3128,7 +4196,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3128
4196
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3129
4197
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3130
4198
|
if (req.method !== "PATCH" || !m) return null;
|
|
3131
|
-
const got = await
|
|
4199
|
+
const got = await gate3(req, env, ctx);
|
|
3132
4200
|
if (got instanceof Response) return got;
|
|
3133
4201
|
const db = got;
|
|
3134
4202
|
const id = m[1];
|
|
@@ -3156,7 +4224,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3156
4224
|
};
|
|
3157
4225
|
|
|
3158
4226
|
// src/worker-routes-admin-comms.ts
|
|
3159
|
-
async function
|
|
4227
|
+
async function gate4(req, env, ctx) {
|
|
3160
4228
|
const rawDb = ctx.makeDb(env);
|
|
3161
4229
|
const u = await ctx.verifyUser(req, env);
|
|
3162
4230
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3178,7 +4246,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3178
4246
|
});
|
|
3179
4247
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3180
4248
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3181
|
-
const got = await
|
|
4249
|
+
const got = await gate4(req, env, ctx);
|
|
3182
4250
|
if (got instanceof Response) return got;
|
|
3183
4251
|
const db = got;
|
|
3184
4252
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3219,11 +4287,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3219
4287
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3220
4288
|
const t = templates[key];
|
|
3221
4289
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3222
|
-
const
|
|
3223
|
-
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);
|
|
3224
4292
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3225
|
-
if (
|
|
3226
|
-
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 };
|
|
3227
4295
|
}
|
|
3228
4296
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3229
4297
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3240,7 +4308,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3240
4308
|
};
|
|
3241
4309
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3242
4310
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3243
|
-
const got = await
|
|
4311
|
+
const got = await gate4(req, env, ctx);
|
|
3244
4312
|
if (got instanceof Response) return got;
|
|
3245
4313
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3246
4314
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3257,7 +4325,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3257
4325
|
};
|
|
3258
4326
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3259
4327
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3260
|
-
const got = await
|
|
4328
|
+
const got = await gate4(req, env, ctx);
|
|
3261
4329
|
if (got instanceof Response) return got;
|
|
3262
4330
|
const db = got;
|
|
3263
4331
|
let body;
|
|
@@ -3285,7 +4353,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3285
4353
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3286
4354
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3287
4355
|
if (req.method !== "GET" || !m) return null;
|
|
3288
|
-
const got = await
|
|
4356
|
+
const got = await gate4(req, env, ctx);
|
|
3289
4357
|
if (got instanceof Response) return got;
|
|
3290
4358
|
const db = got;
|
|
3291
4359
|
const appId = m[1];
|
|
@@ -3318,7 +4386,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3318
4386
|
|
|
3319
4387
|
// src/worker-routes-network.ts
|
|
3320
4388
|
var import_crm6 = require("@odla-ai/crm");
|
|
3321
|
-
var
|
|
4389
|
+
var import_db6 = require("@odla-ai/db");
|
|
3322
4390
|
|
|
3323
4391
|
// src/worker-network-fetch.ts
|
|
3324
4392
|
function targetFetcher(env, target) {
|
|
@@ -3336,8 +4404,8 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3336
4404
|
|
|
3337
4405
|
// src/worker-network-push.ts
|
|
3338
4406
|
var import_crm5 = require("@odla-ai/crm");
|
|
3339
|
-
var
|
|
3340
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4407
|
+
var import_db5 = require("@odla-ai/db");
|
|
4408
|
+
async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
3341
4409
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3342
4410
|
const crmDeps3 = {
|
|
3343
4411
|
crm: ctx.chapter.crm,
|
|
@@ -3348,7 +4416,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3348
4416
|
if (!secret) {
|
|
3349
4417
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3350
4418
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3351
|
-
recordId:
|
|
4419
|
+
recordId: record2.id,
|
|
3352
4420
|
targetId: target.id,
|
|
3353
4421
|
status: "failed",
|
|
3354
4422
|
payloadVersion: 2,
|
|
@@ -3358,7 +4426,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3358
4426
|
}
|
|
3359
4427
|
let payload;
|
|
3360
4428
|
try {
|
|
3361
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4429
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
|
|
3362
4430
|
} catch (err) {
|
|
3363
4431
|
return {
|
|
3364
4432
|
id: target.id,
|
|
@@ -3370,7 +4438,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3370
4438
|
const payloadBody = JSON.stringify(payload);
|
|
3371
4439
|
const destination = new URL("/api/network/shared", target.url);
|
|
3372
4440
|
try {
|
|
3373
|
-
const signed = await (0,
|
|
4441
|
+
const signed = await (0, import_db5.signFederatedRequest)({
|
|
3374
4442
|
secret,
|
|
3375
4443
|
sender: ctx.chapter.id,
|
|
3376
4444
|
method: "POST",
|
|
@@ -3387,7 +4455,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3387
4455
|
if (!res.ok) {
|
|
3388
4456
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3389
4457
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3390
|
-
recordId:
|
|
4458
|
+
recordId: record2.id,
|
|
3391
4459
|
targetId: target.id,
|
|
3392
4460
|
status: "failed",
|
|
3393
4461
|
payloadVersion: 2,
|
|
@@ -3402,7 +4470,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3402
4470
|
};
|
|
3403
4471
|
}
|
|
3404
4472
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3405
|
-
recordId:
|
|
4473
|
+
recordId: record2.id,
|
|
3406
4474
|
targetId: target.id,
|
|
3407
4475
|
status: "delivered",
|
|
3408
4476
|
payloadVersion: 2,
|
|
@@ -3418,7 +4486,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3418
4486
|
} catch (err) {
|
|
3419
4487
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3420
4488
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3421
|
-
recordId:
|
|
4489
|
+
recordId: record2.id,
|
|
3422
4490
|
targetId: target.id,
|
|
3423
4491
|
status: "failed",
|
|
3424
4492
|
payloadVersion: 2,
|
|
@@ -3429,7 +4497,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3429
4497
|
}
|
|
3430
4498
|
|
|
3431
4499
|
// src/worker-routes-network.ts
|
|
3432
|
-
async function
|
|
4500
|
+
async function gate5(req, env, ctx) {
|
|
3433
4501
|
const db = ctx.makeDb(env);
|
|
3434
4502
|
const user = await ctx.verifyUser(req, env);
|
|
3435
4503
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3438,7 +4506,7 @@ async function gate4(req, env, ctx) {
|
|
|
3438
4506
|
}
|
|
3439
4507
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3440
4508
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3441
|
-
const got = await
|
|
4509
|
+
const got = await gate5(req, env, ctx);
|
|
3442
4510
|
if ("response" in got) return got.response;
|
|
3443
4511
|
return json({
|
|
3444
4512
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3455,7 +4523,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3455
4523
|
const db = ctx.makeDb(env);
|
|
3456
4524
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3457
4525
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3458
|
-
const verified = await (0,
|
|
4526
|
+
const verified = await (0, import_db6.verifyFederatedRequest)(req, { secret });
|
|
3459
4527
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3460
4528
|
const types = await Promise.all(
|
|
3461
4529
|
Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3473,7 +4541,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3473
4541
|
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
3474
4542
|
const destination = new URL("/api/network/snapshot", target.url);
|
|
3475
4543
|
try {
|
|
3476
|
-
const headers = await (0,
|
|
4544
|
+
const headers = await (0, import_db6.signFederatedRequest)({
|
|
3477
4545
|
secret,
|
|
3478
4546
|
sender: ctx.chapter.id,
|
|
3479
4547
|
method: "GET",
|
|
@@ -3514,7 +4582,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3514
4582
|
}
|
|
3515
4583
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3516
4584
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3517
|
-
const got = await
|
|
4585
|
+
const got = await gate5(req, env, ctx);
|
|
3518
4586
|
if ("response" in got) return got.response;
|
|
3519
4587
|
const targets = await Promise.all(
|
|
3520
4588
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3541,7 +4609,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3541
4609
|
};
|
|
3542
4610
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3543
4611
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3544
|
-
const got = await
|
|
4612
|
+
const got = await gate5(req, env, ctx);
|
|
3545
4613
|
if ("response" in got) return got.response;
|
|
3546
4614
|
let body;
|
|
3547
4615
|
try {
|
|
@@ -3556,22 +4624,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3556
4624
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3557
4625
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3558
4626
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3559
|
-
const
|
|
3560
|
-
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);
|
|
3561
4629
|
const results = await Promise.all(
|
|
3562
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
4630
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
|
|
3563
4631
|
);
|
|
3564
4632
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3565
4633
|
};
|
|
3566
4634
|
|
|
3567
4635
|
// src/worker-routes-network-records.ts
|
|
3568
4636
|
var import_crm7 = require("@odla-ai/crm");
|
|
3569
|
-
var
|
|
4637
|
+
var import_db7 = require("@odla-ai/db");
|
|
3570
4638
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3571
4639
|
var MAX_PAGE = 50;
|
|
3572
4640
|
var MAX_OFFSET = 1e4;
|
|
3573
4641
|
var MAX_SEARCH = 200;
|
|
3574
|
-
async function
|
|
4642
|
+
async function gate6(req, env, ctx) {
|
|
3575
4643
|
const db = ctx.makeDb(env);
|
|
3576
4644
|
const user = await ctx.verifyUser(req, env);
|
|
3577
4645
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3584,18 +4652,18 @@ function integer(value, fallback, min, max) {
|
|
|
3584
4652
|
const parsed = Number(value);
|
|
3585
4653
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3586
4654
|
}
|
|
3587
|
-
function projectRecord(
|
|
4655
|
+
function projectRecord(record2, allowed) {
|
|
3588
4656
|
const fields = Object.fromEntries(
|
|
3589
|
-
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(
|
|
4657
|
+
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
|
|
3590
4658
|
);
|
|
3591
4659
|
return {
|
|
3592
|
-
id:
|
|
3593
|
-
type:
|
|
3594
|
-
name:
|
|
3595
|
-
...
|
|
4660
|
+
id: record2.id,
|
|
4661
|
+
type: record2.type,
|
|
4662
|
+
name: record2.name,
|
|
4663
|
+
...record2.stage ? { stage: record2.stage } : {},
|
|
3596
4664
|
fields,
|
|
3597
|
-
createdAt:
|
|
3598
|
-
updatedAt:
|
|
4665
|
+
createdAt: record2.createdAt,
|
|
4666
|
+
updatedAt: record2.updatedAt
|
|
3599
4667
|
};
|
|
3600
4668
|
}
|
|
3601
4669
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3604,7 +4672,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3604
4672
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3605
4673
|
const readers = ctx.chapter.network.readers;
|
|
3606
4674
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3607
|
-
const verified = await (0,
|
|
4675
|
+
const verified = await (0, import_db7.verifyFederatedRequest)(req, {
|
|
3608
4676
|
secret,
|
|
3609
4677
|
senders: readers.map((reader2) => reader2.id)
|
|
3610
4678
|
});
|
|
@@ -3633,7 +4701,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3633
4701
|
version: 1,
|
|
3634
4702
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3635
4703
|
type,
|
|
3636
|
-
records: page.records.map((
|
|
4704
|
+
records: page.records.map((record2) => projectRecord(record2, allowed)),
|
|
3637
4705
|
total: page.total,
|
|
3638
4706
|
limit: page.limit,
|
|
3639
4707
|
offset: page.offset
|
|
@@ -3649,7 +4717,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3649
4717
|
const destination = new URL("/api/network/records", target.url);
|
|
3650
4718
|
destination.search = query.toString();
|
|
3651
4719
|
try {
|
|
3652
|
-
const headers = await (0,
|
|
4720
|
+
const headers = await (0, import_db7.signFederatedRequest)({
|
|
3653
4721
|
secret,
|
|
3654
4722
|
sender: ctx.chapter.id,
|
|
3655
4723
|
method: "GET",
|
|
@@ -3674,7 +4742,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3674
4742
|
}
|
|
3675
4743
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3676
4744
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3677
|
-
const got = await
|
|
4745
|
+
const got = await gate6(req, env, ctx);
|
|
3678
4746
|
if ("response" in got) return got.response;
|
|
3679
4747
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3680
4748
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3701,8 +4769,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3701
4769
|
// src/worker-routes-network-notes.ts
|
|
3702
4770
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3703
4771
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3704
|
-
var
|
|
3705
|
-
async function
|
|
4772
|
+
var MAX_NOTE2 = 4e3;
|
|
4773
|
+
async function gate7(req, env, ctx) {
|
|
3706
4774
|
const db = ctx.makeDb(env);
|
|
3707
4775
|
const user = await ctx.verifyUser(req, env);
|
|
3708
4776
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3736,7 +4804,7 @@ function asNote(row) {
|
|
|
3736
4804
|
}
|
|
3737
4805
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3738
4806
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3739
|
-
const got = await
|
|
4807
|
+
const got = await gate7(req, env, ctx);
|
|
3740
4808
|
if ("response" in got) return got.response;
|
|
3741
4809
|
const identity = noteIdentity(url, ctx);
|
|
3742
4810
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3753,7 +4821,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3753
4821
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3754
4822
|
}
|
|
3755
4823
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3756
|
-
if (contentLength >
|
|
4824
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3757
4825
|
let input;
|
|
3758
4826
|
try {
|
|
3759
4827
|
input = await req.json();
|
|
@@ -3761,7 +4829,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3761
4829
|
return json({ error: "invalid JSON body" }, 400);
|
|
3762
4830
|
}
|
|
3763
4831
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3764
|
-
if (!body || body.length >
|
|
4832
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3765
4833
|
const id = crypto.randomUUID();
|
|
3766
4834
|
const note = {
|
|
3767
4835
|
id,
|
|
@@ -3784,13 +4852,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3784
4852
|
|
|
3785
4853
|
// src/worker-routes-network-shared-notes.ts
|
|
3786
4854
|
var import_crm8 = require("@odla-ai/crm");
|
|
3787
|
-
var
|
|
4855
|
+
var import_db8 = require("@odla-ai/db");
|
|
3788
4856
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3789
4857
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3790
4858
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3791
|
-
var
|
|
4859
|
+
var MAX_NOTE3 = 4e3;
|
|
3792
4860
|
var NETWORK_META = "chapterNetwork";
|
|
3793
|
-
async function
|
|
4861
|
+
async function gate8(req, env, ctx) {
|
|
3794
4862
|
const db = ctx.makeDb(env);
|
|
3795
4863
|
const user = await ctx.verifyUser(req, env);
|
|
3796
4864
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3836,14 +4904,14 @@ function sourceLabel(sourceId) {
|
|
|
3836
4904
|
}
|
|
3837
4905
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3838
4906
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3839
|
-
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) {
|
|
3840
4908
|
return json({ error: "note is too large" }, 413);
|
|
3841
4909
|
}
|
|
3842
4910
|
const db = ctx.makeDb(env);
|
|
3843
4911
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3844
4912
|
const readers = ctx.chapter.network.readers;
|
|
3845
4913
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3846
|
-
const verified = await (0,
|
|
4914
|
+
const verified = await (0, import_db8.verifyFederatedRequest)(req, {
|
|
3847
4915
|
secret,
|
|
3848
4916
|
senders: readers.map((reader2) => reader2.id)
|
|
3849
4917
|
});
|
|
@@ -3854,8 +4922,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3854
4922
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3855
4923
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3856
4924
|
}
|
|
3857
|
-
const
|
|
3858
|
-
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);
|
|
3859
4927
|
if (req.method === "GET") {
|
|
3860
4928
|
return json({
|
|
3861
4929
|
version: 1,
|
|
@@ -3871,8 +4939,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3871
4939
|
}
|
|
3872
4940
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3873
4941
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3874
|
-
if (!body || body.length >
|
|
3875
|
-
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);
|
|
3876
4944
|
}
|
|
3877
4945
|
const createdAt = Date.now();
|
|
3878
4946
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3914,7 +4982,7 @@ function targetFor2(ctx, id) {
|
|
|
3914
4982
|
}
|
|
3915
4983
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3916
4984
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3917
|
-
const got = await
|
|
4985
|
+
const got = await gate8(req, env, ctx);
|
|
3918
4986
|
if ("response" in got) return got.response;
|
|
3919
4987
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3920
4988
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3932,7 +5000,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3932
5000
|
return json({ error: "invalid JSON body" }, 400);
|
|
3933
5001
|
}
|
|
3934
5002
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3935
|
-
if (!note || note.length >
|
|
5003
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3936
5004
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3937
5005
|
}
|
|
3938
5006
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -3940,7 +5008,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3940
5008
|
const destination = new URL("/api/network/shared-notes", target.url);
|
|
3941
5009
|
destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
|
|
3942
5010
|
try {
|
|
3943
|
-
const headers = await (0,
|
|
5011
|
+
const headers = await (0, import_db8.signFederatedRequest)({
|
|
3944
5012
|
secret,
|
|
3945
5013
|
sender: ctx.chapter.id,
|
|
3946
5014
|
method: req.method,
|
|
@@ -4061,11 +5129,11 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
4061
5129
|
};
|
|
4062
5130
|
|
|
4063
5131
|
// src/signup-control-delivery.ts
|
|
4064
|
-
var
|
|
5132
|
+
var import_db9 = require("@odla-ai/db");
|
|
4065
5133
|
async function deliverSignupControl(options) {
|
|
4066
5134
|
const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
|
|
4067
5135
|
const body = JSON.stringify(options.envelope);
|
|
4068
|
-
const signed = await (0,
|
|
5136
|
+
const signed = await (0, import_db9.signFederatedRequest)({
|
|
4069
5137
|
secret: options.secret,
|
|
4070
5138
|
sender: options.sender,
|
|
4071
5139
|
method: "POST",
|
|
@@ -4085,6 +5153,31 @@ async function deliverSignupControl(options) {
|
|
|
4085
5153
|
return result;
|
|
4086
5154
|
}
|
|
4087
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
|
+
|
|
4088
5181
|
// src/signup-control-theme.ts
|
|
4089
5182
|
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
4090
5183
|
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
@@ -4224,11 +5317,13 @@ var BUILTIN_ROUTES = [
|
|
|
4224
5317
|
handleCrm,
|
|
4225
5318
|
handleNetworkShared,
|
|
4226
5319
|
handleSignupControl,
|
|
5320
|
+
handleAdmissionGrants,
|
|
4227
5321
|
handleNetworkSnapshot,
|
|
4228
5322
|
handleNetworkRecords,
|
|
4229
5323
|
handleNetworkSharedNotes,
|
|
4230
5324
|
handleFormation,
|
|
4231
5325
|
handleMember,
|
|
5326
|
+
handleGifts,
|
|
4232
5327
|
handleSchedule,
|
|
4233
5328
|
handlePayments,
|
|
4234
5329
|
handleAdminMeetings,
|
|
@@ -4238,6 +5333,7 @@ var BUILTIN_ROUTES = [
|
|
|
4238
5333
|
handleAdminPeopleAccess,
|
|
4239
5334
|
handleAdminPeopleRole,
|
|
4240
5335
|
handleAdminCrmSync,
|
|
5336
|
+
handleAdminAdmissionGrants,
|
|
4241
5337
|
handleAdminClerkPrivateProfileSync,
|
|
4242
5338
|
// Aggregation
|
|
4243
5339
|
handleAdminDashboard,
|