@odla-ai/chapter 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -2
- package/dist/{chunk-UJIVBGL5.js → chunk-I45VYBUV.js} +441 -239
- package/dist/chunk-I45VYBUV.js.map +1 -0
- package/dist/{chunk-JAWTIROV.js → chunk-IAWBE5PX.js} +55 -1
- package/dist/chunk-IAWBE5PX.js.map +1 -0
- package/dist/{chunk-TXE5ZDI4.js → chunk-LWM32REV.js} +166 -59
- package/dist/chunk-LWM32REV.js.map +1 -0
- package/dist/{copy-context-c1tqPXOR.d.ts → copy-context-0k0-yWHL.d.ts} +6 -2
- package/dist/index.cjs +967 -296
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +253 -3
- package/dist/index.d.ts +253 -3
- package/dist/index.js +967 -296
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +10 -3
- package/dist/ui/admin/index.js +6 -2
- package/dist/ui/index.d.ts +3 -3
- package/dist/ui/index.js +11 -3
- package/dist/ui/member/index.d.ts +86 -7
- package/dist/ui/member/index.js +6 -2
- package/dist/worker/index.cjs +1957 -722
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +200 -3
- package/dist/worker/index.d.ts +200 -3
- package/dist/worker/index.js +1968 -733
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/chunk-JAWTIROV.js.map +0 -1
- package/dist/chunk-TXE5ZDI4.js.map +0 -1
- package/dist/chunk-UJIVBGL5.js.map +0 -1
package/dist/worker/index.js
CHANGED
|
@@ -167,6 +167,205 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
167
167
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// src/admission-grants-contract.ts
|
|
171
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
172
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
173
|
+
|
|
174
|
+
// src/signup-control-canonical.ts
|
|
175
|
+
var encoder = new TextEncoder();
|
|
176
|
+
function canonicalValue(value) {
|
|
177
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
178
|
+
return JSON.stringify(value);
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
181
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
182
|
+
const record2 = value;
|
|
183
|
+
const entries = Object.keys(record2).sort().flatMap((key) => {
|
|
184
|
+
const item = record2[key];
|
|
185
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
186
|
+
});
|
|
187
|
+
return `{${entries.join(",")}}`;
|
|
188
|
+
}
|
|
189
|
+
function canonicalSignupControl(value) {
|
|
190
|
+
return canonicalValue(value);
|
|
191
|
+
}
|
|
192
|
+
async function signupControlDigest(document) {
|
|
193
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
194
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/admission-grants.ts
|
|
198
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
199
|
+
var MAX_GRANTS = 2e3;
|
|
200
|
+
var MAX_NOTE = 2e3;
|
|
201
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
202
|
+
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
203
|
+
function normalizeAdmissionEmail(email) {
|
|
204
|
+
return email.trim().toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
function validateAdmissionGrantDocument(input) {
|
|
207
|
+
const errors = [];
|
|
208
|
+
const root = input && typeof input === "object" ? input : {};
|
|
209
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
210
|
+
const revision = Number(root.revision);
|
|
211
|
+
const generatedAt = Number(root.generatedAt);
|
|
212
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
213
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
214
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
215
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
216
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
217
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
218
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
219
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
220
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
221
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
222
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
223
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
224
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
225
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
226
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
227
|
+
const grants = [];
|
|
228
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
229
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
230
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
231
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
232
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
233
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
234
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
235
|
+
const decision = grant.decision;
|
|
236
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
237
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
238
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
239
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
240
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
241
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
242
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
243
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
244
|
+
}
|
|
245
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
246
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
247
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
248
|
+
}
|
|
249
|
+
seenIds.add(id);
|
|
250
|
+
seenEmails.add(normalized);
|
|
251
|
+
grants.push({
|
|
252
|
+
id,
|
|
253
|
+
email,
|
|
254
|
+
decision,
|
|
255
|
+
...note ? { note } : {},
|
|
256
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (errors.length) return { ok: false, errors };
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
value: {
|
|
263
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
264
|
+
revision,
|
|
265
|
+
generatedAt,
|
|
266
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
267
|
+
target: { chapterId, environment },
|
|
268
|
+
grants
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
273
|
+
function asGrant(row) {
|
|
274
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
275
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
276
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
277
|
+
return row;
|
|
278
|
+
}
|
|
279
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
280
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
281
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
282
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
283
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
284
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
285
|
+
)).sort((a, b) => {
|
|
286
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
287
|
+
return scope || b.createdAt - a.createdAt;
|
|
288
|
+
});
|
|
289
|
+
return active[0] ?? null;
|
|
290
|
+
}
|
|
291
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
292
|
+
const where = { sourceId, targetId, environment };
|
|
293
|
+
if (db.aggregate) {
|
|
294
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
295
|
+
}
|
|
296
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
297
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
298
|
+
}
|
|
299
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
300
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
301
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
302
|
+
where: { sourceId, targetId, environment },
|
|
303
|
+
order: { revision: "desc" },
|
|
304
|
+
limit: 1
|
|
305
|
+
} } });
|
|
306
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
307
|
+
return row ? row : null;
|
|
308
|
+
}
|
|
309
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
310
|
+
const { document, digest } = envelope;
|
|
311
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
312
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
313
|
+
limit: MAX_GRANTS
|
|
314
|
+
} } })).admissionGrants);
|
|
315
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
316
|
+
const ops = [];
|
|
317
|
+
for (const row of current) {
|
|
318
|
+
const id = String(row.id ?? "");
|
|
319
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
320
|
+
active: false,
|
|
321
|
+
revokedAt: receivedAt,
|
|
322
|
+
revokedBy: document.source.actor,
|
|
323
|
+
updatedAt: receivedAt,
|
|
324
|
+
updatedBy: document.source.actor
|
|
325
|
+
} });
|
|
326
|
+
}
|
|
327
|
+
for (const grant of document.grants) {
|
|
328
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
329
|
+
const existing = current.find((row) => row.id === id);
|
|
330
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
331
|
+
id,
|
|
332
|
+
sourceGrantId: grant.id,
|
|
333
|
+
sourceScope: "parent",
|
|
334
|
+
sourceId: document.source.appId,
|
|
335
|
+
targetId: document.target.chapterId,
|
|
336
|
+
email: grant.email,
|
|
337
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
338
|
+
decision: grant.decision,
|
|
339
|
+
...grant.note ? { note: grant.note } : {},
|
|
340
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
341
|
+
active: true,
|
|
342
|
+
sourceRevision: document.revision,
|
|
343
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
344
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
345
|
+
updatedAt: receivedAt,
|
|
346
|
+
updatedBy: document.source.actor
|
|
347
|
+
} });
|
|
348
|
+
const retract = [
|
|
349
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
350
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
351
|
+
];
|
|
352
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
353
|
+
}
|
|
354
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
355
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
356
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
357
|
+
id: revisionId,
|
|
358
|
+
sourceId: document.source.appId,
|
|
359
|
+
targetId: document.target.chapterId,
|
|
360
|
+
environment: document.target.environment,
|
|
361
|
+
revision: document.revision,
|
|
362
|
+
digest,
|
|
363
|
+
active: true,
|
|
364
|
+
receivedAt
|
|
365
|
+
} });
|
|
366
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
367
|
+
}
|
|
368
|
+
|
|
170
369
|
// src/member.ts
|
|
171
370
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
172
371
|
var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
|
|
@@ -218,9 +417,9 @@ function resolveApplication(a) {
|
|
|
218
417
|
validateEmail: a?.validateEmail ?? true
|
|
219
418
|
};
|
|
220
419
|
}
|
|
221
|
-
var
|
|
420
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
222
421
|
function isValidEmail(value) {
|
|
223
|
-
return typeof value === "string" &&
|
|
422
|
+
return typeof value === "string" && EMAIL_RE2.test(value);
|
|
224
423
|
}
|
|
225
424
|
function clampArray(value, max) {
|
|
226
425
|
if (!Array.isArray(value)) return value;
|
|
@@ -270,6 +469,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
270
469
|
}
|
|
271
470
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
272
471
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
472
|
+
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
473
|
+
row.emailNormalized = normalizeAdmissionEmail(fields.email);
|
|
474
|
+
}
|
|
475
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
476
|
+
row.admissionGrantId = opts.admissionGrant.id;
|
|
477
|
+
row.admissionSource = opts.admissionGrant.sourceScope;
|
|
478
|
+
row.interviewWaivedAt = opts.now;
|
|
479
|
+
}
|
|
480
|
+
if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
|
|
273
481
|
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
274
482
|
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
275
483
|
return { ok: false, error: "tierId is not an offered tier" };
|
|
@@ -277,8 +485,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
277
485
|
row.tierId = fields.tierId;
|
|
278
486
|
}
|
|
279
487
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
488
|
+
const ops = [
|
|
489
|
+
{ t: "update", ns: "applications", id, attrs: row }
|
|
490
|
+
];
|
|
491
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
492
|
+
ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
|
|
493
|
+
consumedAt: opts.now,
|
|
494
|
+
consumedApplicationId: id
|
|
495
|
+
} });
|
|
496
|
+
}
|
|
280
497
|
const { duplicate } = await db.transact(
|
|
281
|
-
|
|
498
|
+
ops,
|
|
282
499
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
283
500
|
);
|
|
284
501
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
@@ -312,8 +529,8 @@ function tierPayable(tier, group, hasSecretKey) {
|
|
|
312
529
|
function byOrder(a, b) {
|
|
313
530
|
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
314
531
|
}
|
|
315
|
-
function resolveTiers(group,
|
|
316
|
-
const stored =
|
|
532
|
+
function resolveTiers(group, rows4 = []) {
|
|
533
|
+
const stored = rows4.filter((tier) => tier.active);
|
|
317
534
|
if (stored.length) return [...stored].sort(byOrder);
|
|
318
535
|
return [{
|
|
319
536
|
id: "standard",
|
|
@@ -359,15 +576,15 @@ function rowToTier(row, groupId) {
|
|
|
359
576
|
}
|
|
360
577
|
async function loadTiers(db, group) {
|
|
361
578
|
const groupId = String(group.id ?? "");
|
|
362
|
-
let
|
|
579
|
+
let rows4;
|
|
363
580
|
try {
|
|
364
|
-
|
|
581
|
+
rows4 = (await db.query({
|
|
365
582
|
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
366
583
|
})).tiers;
|
|
367
584
|
} catch {
|
|
368
|
-
|
|
585
|
+
rows4 = void 0;
|
|
369
586
|
}
|
|
370
|
-
const list = Array.isArray(
|
|
587
|
+
const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
|
|
371
588
|
return resolveTiers(group, list);
|
|
372
589
|
}
|
|
373
590
|
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
@@ -481,346 +698,257 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
481
698
|
};
|
|
482
699
|
}
|
|
483
700
|
|
|
484
|
-
// src/
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
701
|
+
// src/gift-offers.ts
|
|
702
|
+
var GIFT_OFFER_CATALOG_VERSION = 1;
|
|
703
|
+
var MAX_GIFT_OFFERS = 20;
|
|
704
|
+
var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
|
|
705
|
+
var PRICE = /^price_[A-Za-z0-9]{12,}$/;
|
|
706
|
+
var CURRENCY = /^[a-z]{3}$/;
|
|
707
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
708
|
+
var text = (value, path, errors, max) => {
|
|
709
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
710
|
+
errors.push(`${path} is required`);
|
|
711
|
+
return "";
|
|
712
|
+
}
|
|
713
|
+
if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
|
|
714
|
+
return value.trim();
|
|
715
|
+
};
|
|
716
|
+
function validateGiftOfferCatalog(input, tierIds) {
|
|
717
|
+
const errors = [];
|
|
718
|
+
const root = record(input);
|
|
719
|
+
if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
|
|
720
|
+
for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
|
|
721
|
+
if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
|
|
722
|
+
if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
|
|
723
|
+
const rawOffers = Array.isArray(root.offers) ? root.offers : [];
|
|
724
|
+
if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
|
|
725
|
+
const ids = /* @__PURE__ */ new Set();
|
|
726
|
+
const offers = [];
|
|
727
|
+
for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
|
|
728
|
+
const path = `document.giftOffers.offers[${index}]`;
|
|
729
|
+
const item = record(raw);
|
|
730
|
+
if (!item) {
|
|
731
|
+
errors.push(`${path} must be an object`);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const allowed = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
735
|
+
for (const key of Object.keys(item)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
736
|
+
const id = text(item.id, `${path}.id`, errors, 80);
|
|
737
|
+
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
738
|
+
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
739
|
+
ids.add(id);
|
|
740
|
+
const targetRaw = record(item.target);
|
|
741
|
+
let target = { type: "entitlement", entitlementKey: "invalid" };
|
|
742
|
+
if (targetRaw?.type === "tier") {
|
|
743
|
+
const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
|
|
744
|
+
if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
|
|
745
|
+
target = { type: "tier", tierId };
|
|
746
|
+
} else if (targetRaw?.type === "entitlement") {
|
|
747
|
+
const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
|
|
748
|
+
if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
|
|
749
|
+
target = { type: "entitlement", entitlementKey };
|
|
750
|
+
} else errors.push(`${path}.target.type must be tier or entitlement`);
|
|
751
|
+
if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
|
|
752
|
+
const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
|
|
753
|
+
if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
754
|
+
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`);
|
|
755
|
+
const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
|
|
756
|
+
if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
|
|
757
|
+
const lifetime = Number(item.redemptionLifetimeHours);
|
|
758
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
|
|
759
|
+
const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
|
|
760
|
+
const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
|
|
761
|
+
if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
|
|
762
|
+
if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
|
|
763
|
+
if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
|
|
764
|
+
offers.push({
|
|
765
|
+
id,
|
|
766
|
+
target,
|
|
767
|
+
billingMode: item.billingMode,
|
|
768
|
+
stripePriceId,
|
|
769
|
+
priceCents: Number(item.priceCents),
|
|
770
|
+
currency,
|
|
771
|
+
redemptionLifetimeHours: lifetime,
|
|
772
|
+
...activeFrom !== void 0 ? { activeFrom } : {},
|
|
773
|
+
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
774
|
+
title: text(item.title, `${path}.title`, errors, 160),
|
|
775
|
+
description: text(item.description, `${path}.description`, errors, 2e3),
|
|
776
|
+
ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
|
|
489
780
|
}
|
|
490
|
-
|
|
491
|
-
|
|
781
|
+
var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
|
|
782
|
+
function publicGiftOffers(catalog, now) {
|
|
783
|
+
return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
|
|
492
784
|
}
|
|
493
|
-
function
|
|
494
|
-
return
|
|
785
|
+
function findGiftOffer(catalog, offerId, now) {
|
|
786
|
+
return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
|
|
495
787
|
}
|
|
496
788
|
|
|
497
|
-
// src/
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
789
|
+
// src/clerk-roles.ts
|
|
790
|
+
var CLERK_API = "https://api.clerk.com";
|
|
791
|
+
var DEFAULT_ROLE = "provisional";
|
|
792
|
+
var PAGE = 100;
|
|
793
|
+
function toRecord(u) {
|
|
794
|
+
if (typeof u.id !== "string") return null;
|
|
795
|
+
const pm = u.public_metadata ?? {};
|
|
796
|
+
const privateMetadata = u.private_metadata ?? {};
|
|
797
|
+
const rawProfile = privateMetadata.profile;
|
|
798
|
+
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
799
|
+
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
800
|
+
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
801
|
+
const email = u.email_addresses?.[0]?.email_address;
|
|
802
|
+
return {
|
|
803
|
+
id: u.id,
|
|
804
|
+
email: typeof email === "string" ? email : void 0,
|
|
805
|
+
role,
|
|
806
|
+
publicMetadata: pm,
|
|
807
|
+
privateMetadata,
|
|
808
|
+
profile,
|
|
809
|
+
...applicationId ? { applicationId } : {}
|
|
810
|
+
};
|
|
501
811
|
}
|
|
502
|
-
async function
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
505
|
-
|
|
812
|
+
async function clerkGet(path, secretKey, fetchImpl) {
|
|
813
|
+
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
814
|
+
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
815
|
+
return res.json();
|
|
816
|
+
}
|
|
817
|
+
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
818
|
+
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
819
|
+
const user = Array.isArray(data) ? data[0] : void 0;
|
|
820
|
+
return user ? toRecord(user) : null;
|
|
821
|
+
}
|
|
822
|
+
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
823
|
+
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
824
|
+
return data ? toRecord(data) : null;
|
|
825
|
+
}
|
|
826
|
+
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
827
|
+
const out = [];
|
|
828
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
829
|
+
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
830
|
+
const page = Array.isArray(data) ? data : [];
|
|
831
|
+
for (const u of page) {
|
|
832
|
+
const record2 = toRecord(u);
|
|
833
|
+
if (record2) out.push(record2);
|
|
834
|
+
}
|
|
835
|
+
if (page.length < PAGE) break;
|
|
506
836
|
}
|
|
507
|
-
|
|
508
|
-
return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
837
|
+
return out;
|
|
509
838
|
}
|
|
510
|
-
async function
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
518
|
-
return asStored(latest);
|
|
839
|
+
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
840
|
+
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
841
|
+
method: "PATCH",
|
|
842
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
843
|
+
body: JSON.stringify({ public_metadata: { role } })
|
|
844
|
+
});
|
|
845
|
+
return res.ok;
|
|
519
846
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
847
|
+
|
|
848
|
+
// src/crm-sync.ts
|
|
849
|
+
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
850
|
+
|
|
851
|
+
// src/network.ts
|
|
852
|
+
import {
|
|
853
|
+
createRecord,
|
|
854
|
+
getRecordByOrigin,
|
|
855
|
+
updateRecord,
|
|
856
|
+
upsertRecordOrigin
|
|
857
|
+
} from "@odla-ai/crm";
|
|
858
|
+
|
|
859
|
+
// src/network-contract.ts
|
|
860
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
861
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
862
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/network.ts
|
|
866
|
+
function sharedPersonInput(person) {
|
|
867
|
+
const email = person.email.toLowerCase();
|
|
868
|
+
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
869
|
+
const input = { name: person.name ?? fullName ?? email, email };
|
|
870
|
+
if (input.name === "") input.name = email;
|
|
871
|
+
if (person.firstName) input.firstName = person.firstName;
|
|
872
|
+
if (person.lastName) input.lastName = person.lastName;
|
|
873
|
+
if (person.phone) input.phone = person.phone;
|
|
874
|
+
if (person.linkedin) input.linkedin = person.linkedin;
|
|
875
|
+
return input;
|
|
529
876
|
}
|
|
530
|
-
function
|
|
531
|
-
|
|
532
|
-
|
|
877
|
+
function shortHash(value) {
|
|
878
|
+
let a = 2166136261;
|
|
879
|
+
let b = 2654435769;
|
|
880
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
881
|
+
const n = value.charCodeAt(i);
|
|
882
|
+
a = Math.imul(a ^ n, 16777619);
|
|
883
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
884
|
+
}
|
|
885
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
533
886
|
}
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
887
|
+
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
888
|
+
const typeKey = type.toLowerCase();
|
|
889
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
890
|
+
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
891
|
+
const raw = `${prefix}:${hubRecordId}`;
|
|
892
|
+
if (readable && raw.length <= 64) return raw;
|
|
893
|
+
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
538
894
|
}
|
|
539
|
-
function
|
|
540
|
-
|
|
541
|
-
|
|
895
|
+
function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
|
|
896
|
+
if ("input" in record2 && record2.version === 2) {
|
|
897
|
+
return {
|
|
898
|
+
version: 2,
|
|
899
|
+
sourceId: record2.source.siteId,
|
|
900
|
+
sourceRecordId: record2.source.recordId,
|
|
901
|
+
type: record2.type,
|
|
902
|
+
input: record2.input
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if ("input" in record2) {
|
|
906
|
+
return {
|
|
907
|
+
version: 1,
|
|
908
|
+
sourceId: fallbackSourceId,
|
|
909
|
+
sourceRecordId: record2.hubRecordId,
|
|
910
|
+
type: record2.type,
|
|
911
|
+
input: record2.input
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
if ("type" in record2 && record2.type === "company") {
|
|
915
|
+
const input = { name: record2.name };
|
|
916
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
917
|
+
if (record2[key]) input[key] = record2[key];
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
version: 1,
|
|
921
|
+
sourceId: fallbackSourceId,
|
|
922
|
+
sourceRecordId: record2.hubRecordId,
|
|
923
|
+
type: "company",
|
|
924
|
+
input
|
|
925
|
+
};
|
|
926
|
+
}
|
|
542
927
|
return {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
928
|
+
version: 1,
|
|
929
|
+
sourceId: fallbackSourceId,
|
|
930
|
+
sourceRecordId: record2.hubRecordId,
|
|
931
|
+
type: "person",
|
|
932
|
+
input: sharedPersonInput(record2)
|
|
548
933
|
};
|
|
549
934
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
value
|
|
554
|
-
});
|
|
555
|
-
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
556
|
-
const { document, digest } = envelope;
|
|
557
|
-
const currentRows = (await db.query({ tiers: { $: {
|
|
558
|
-
where: { groupId: document.target.chapterId },
|
|
559
|
-
limit: 100
|
|
560
|
-
} } })).tiers ?? [];
|
|
561
|
-
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
562
|
-
const ops = [];
|
|
563
|
-
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
564
|
-
for (const row of currentRows) {
|
|
565
|
-
const id2 = String(row.id ?? "");
|
|
566
|
-
if (id2 && !incoming.has(id2)) {
|
|
567
|
-
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
for (const tier of document.tiers) {
|
|
571
|
-
const tierRef = byPublicId("tiers", tier.id);
|
|
572
|
-
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
573
|
-
id: tier.id,
|
|
574
|
-
groupId: document.target.chapterId,
|
|
575
|
-
name: tier.name,
|
|
576
|
-
priceCents: tier.priceCents,
|
|
577
|
-
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
578
|
-
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
579
|
-
sortOrder: tier.sortOrder,
|
|
580
|
-
active: tier.available,
|
|
581
|
-
createdAt: receivedAt
|
|
582
|
-
} });
|
|
583
|
-
const retract = [
|
|
584
|
-
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
585
|
-
...!tier.blurb ? ["blurb"] : []
|
|
586
|
-
];
|
|
587
|
-
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
588
|
-
}
|
|
589
|
-
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
590
|
-
const attrs = groupAttrs(document);
|
|
591
|
-
ops.push({
|
|
592
|
-
t: "update",
|
|
593
|
-
ns: "groups",
|
|
594
|
-
id: groupRef,
|
|
595
|
-
attrs
|
|
596
|
-
});
|
|
597
|
-
if (!("stripePriceId" in attrs)) {
|
|
598
|
-
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
599
|
-
}
|
|
600
|
-
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
601
|
-
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
602
|
-
id,
|
|
603
|
-
sourceId: document.source.appId,
|
|
604
|
-
targetId: document.target.chapterId,
|
|
605
|
-
environment: document.target.environment,
|
|
606
|
-
revision: document.revision,
|
|
607
|
-
digest,
|
|
608
|
-
document,
|
|
609
|
-
active: true,
|
|
610
|
-
receivedAt
|
|
611
|
-
} });
|
|
612
|
-
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// src/session.ts
|
|
616
|
-
function applicationSummary(app) {
|
|
617
|
-
return {
|
|
618
|
-
id: app.id,
|
|
619
|
-
firstName: app.firstName ?? null,
|
|
620
|
-
lastName: app.lastName ?? null,
|
|
621
|
-
email: app.email ?? null,
|
|
622
|
-
status: app.status,
|
|
623
|
-
createdAt: app.createdAt ?? null,
|
|
624
|
-
meetingLink: app.meetingLink ?? null,
|
|
625
|
-
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
626
|
-
renewalAt: app.renewalAt ?? null,
|
|
627
|
-
canceled: app.canceled === true
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
function memberApplication(app, meeting, defaultTimezone) {
|
|
631
|
-
const summary = applicationSummary(app);
|
|
632
|
-
let meetingAt = app.meetingAt ?? null;
|
|
633
|
-
let meetUrl = null;
|
|
634
|
-
let timezone = defaultTimezone;
|
|
635
|
-
if (meeting) {
|
|
636
|
-
timezone = meeting.timezone ?? timezone;
|
|
637
|
-
if (meeting.status === "scheduled") {
|
|
638
|
-
meetingAt = meeting.startAt ?? null;
|
|
639
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
640
|
-
} else {
|
|
641
|
-
meetingAt = null;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return { ...summary, meetingAt, meetUrl, timezone };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// src/clerk.ts
|
|
648
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
649
|
-
function clerkInviteRequest(input) {
|
|
650
|
-
return {
|
|
651
|
-
path: "/v1/invitations",
|
|
652
|
-
body: {
|
|
653
|
-
email_address: input.email,
|
|
654
|
-
notify: true,
|
|
655
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
656
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
657
|
-
}
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
661
|
-
const { path, body } = clerkInviteRequest(input);
|
|
662
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
663
|
-
method: "POST",
|
|
664
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
665
|
-
body: JSON.stringify(body)
|
|
666
|
-
});
|
|
667
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
668
|
-
}
|
|
669
|
-
function clerkUserRequest(input) {
|
|
670
|
-
return {
|
|
671
|
-
path: "/v1/users",
|
|
672
|
-
body: {
|
|
673
|
-
email_address: [input.email],
|
|
674
|
-
skip_password_requirement: true,
|
|
675
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
676
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
677
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
678
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
679
|
-
}
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
683
|
-
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
684
|
-
method: "PATCH",
|
|
685
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
686
|
-
body: JSON.stringify({
|
|
687
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
688
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
689
|
-
})
|
|
690
|
-
});
|
|
691
|
-
return res.ok;
|
|
692
|
-
}
|
|
693
|
-
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
694
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
695
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
696
|
-
if (!found.ok) return false;
|
|
697
|
-
const users = await found.json().catch(() => null);
|
|
698
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
699
|
-
if (!id) return false;
|
|
700
|
-
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
701
|
-
}
|
|
702
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
703
|
-
const { path, body } = clerkUserRequest(input);
|
|
704
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
705
|
-
method: "POST",
|
|
706
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
707
|
-
body: JSON.stringify(body)
|
|
708
|
-
});
|
|
709
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
710
|
-
const healed = heal(res.status);
|
|
711
|
-
const repair = input.repairMetadata ?? {
|
|
712
|
-
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
713
|
-
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
714
|
-
};
|
|
715
|
-
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
716
|
-
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
717
|
-
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
718
|
-
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
719
|
-
}, fetchImpl).catch(() => false);
|
|
720
|
-
return { ...healed, refreshed };
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// src/network.ts
|
|
724
|
-
import {
|
|
725
|
-
createRecord,
|
|
726
|
-
getRecordByOrigin,
|
|
727
|
-
updateRecord,
|
|
728
|
-
upsertRecordOrigin
|
|
729
|
-
} from "@odla-ai/crm";
|
|
730
|
-
|
|
731
|
-
// src/network-contract.ts
|
|
732
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
733
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
734
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
// src/network.ts
|
|
738
|
-
function sharedPersonInput(person) {
|
|
739
|
-
const email = person.email.toLowerCase();
|
|
740
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
741
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
742
|
-
if (input.name === "") input.name = email;
|
|
743
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
744
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
745
|
-
if (person.phone) input.phone = person.phone;
|
|
746
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
747
|
-
return input;
|
|
748
|
-
}
|
|
749
|
-
function shortHash(value) {
|
|
750
|
-
let a = 2166136261;
|
|
751
|
-
let b = 2654435769;
|
|
752
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
753
|
-
const n = value.charCodeAt(i);
|
|
754
|
-
a = Math.imul(a ^ n, 16777619);
|
|
755
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
756
|
-
}
|
|
757
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
758
|
-
}
|
|
759
|
-
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
760
|
-
const typeKey = type.toLowerCase();
|
|
761
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
762
|
-
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
763
|
-
const raw = `${prefix}:${hubRecordId}`;
|
|
764
|
-
if (readable && raw.length <= 64) return raw;
|
|
765
|
-
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
766
|
-
}
|
|
767
|
-
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
768
|
-
if ("input" in record && record.version === 2) {
|
|
769
|
-
return {
|
|
770
|
-
version: 2,
|
|
771
|
-
sourceId: record.source.siteId,
|
|
772
|
-
sourceRecordId: record.source.recordId,
|
|
773
|
-
type: record.type,
|
|
774
|
-
input: record.input
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
if ("input" in record) {
|
|
778
|
-
return {
|
|
779
|
-
version: 1,
|
|
780
|
-
sourceId: fallbackSourceId,
|
|
781
|
-
sourceRecordId: record.hubRecordId,
|
|
782
|
-
type: record.type,
|
|
783
|
-
input: record.input
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
if ("type" in record && record.type === "company") {
|
|
787
|
-
const input = { name: record.name };
|
|
788
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
789
|
-
if (record[key]) input[key] = record[key];
|
|
790
|
-
}
|
|
791
|
-
return {
|
|
792
|
-
version: 1,
|
|
793
|
-
sourceId: fallbackSourceId,
|
|
794
|
-
sourceRecordId: record.hubRecordId,
|
|
795
|
-
type: "company",
|
|
796
|
-
input
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
return {
|
|
800
|
-
version: 1,
|
|
801
|
-
sourceId: fallbackSourceId,
|
|
802
|
-
sourceRecordId: record.hubRecordId,
|
|
803
|
-
type: "person",
|
|
804
|
-
input: sharedPersonInput(record)
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
808
|
-
if (target.fields && !target.fields[record.type]) {
|
|
809
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
935
|
+
function sharedRecordFromCrm(crm, record2, target, sourceId) {
|
|
936
|
+
if (target.fields && !target.fields[record2.type]) {
|
|
937
|
+
throw new Error(`${target.name} does not accept "${record2.type}" records`);
|
|
810
938
|
}
|
|
811
|
-
const def = crm.type(
|
|
812
|
-
const fields = target.fields?.[
|
|
939
|
+
const def = crm.type(record2.type);
|
|
940
|
+
const fields = target.fields?.[record2.type] ?? DEFAULT_SHARE_FIELDS[record2.type];
|
|
813
941
|
if (!fields) {
|
|
814
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
942
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record2.type}" records`);
|
|
815
943
|
}
|
|
816
944
|
const nameField = def.nameField ?? "name";
|
|
817
945
|
const input = {};
|
|
818
946
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
819
|
-
const value =
|
|
947
|
+
const value = record2.fields?.[field];
|
|
820
948
|
if (value !== void 0) input[field] = value;
|
|
821
949
|
}
|
|
822
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
823
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
950
|
+
if (input[nameField] === void 0) input[nameField] = record2.name;
|
|
951
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record2.id }, type: record2.type, input } : { version: 1, type: record2.type, hubRecordId: record2.id, input };
|
|
824
952
|
}
|
|
825
953
|
async function upsertPerson(deps, opts) {
|
|
826
954
|
const email = opts.email.toLowerCase();
|
|
@@ -834,11 +962,11 @@ async function upsertPerson(deps, opts) {
|
|
|
834
962
|
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
835
963
|
return { recordId: created.id };
|
|
836
964
|
}
|
|
837
|
-
async function findSharedRecord(deps,
|
|
965
|
+
async function findSharedRecord(deps, record2, tag) {
|
|
838
966
|
const structured = await getRecordByOrigin(
|
|
839
967
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
840
|
-
|
|
841
|
-
|
|
968
|
+
record2.sourceId,
|
|
969
|
+
record2.sourceRecordId
|
|
842
970
|
);
|
|
843
971
|
if (structured) return structured;
|
|
844
972
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -847,53 +975,53 @@ async function findSharedRecord(deps, record, tag) {
|
|
|
847
975
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
848
976
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
849
977
|
}
|
|
850
|
-
const def = deps.crm.type(
|
|
978
|
+
const def = deps.crm.type(record2.type);
|
|
851
979
|
const emailField = def.emailField;
|
|
852
|
-
if (emailField && typeof
|
|
853
|
-
const primaryEmail =
|
|
980
|
+
if (emailField && typeof record2.input[emailField] === "string") {
|
|
981
|
+
const primaryEmail = record2.input[emailField].toLowerCase();
|
|
854
982
|
const found = await deps.db.query({
|
|
855
|
-
crm_record: { $: { where: { type:
|
|
983
|
+
crm_record: { $: { where: { type: record2.type, primaryEmail }, limit: 1 } }
|
|
856
984
|
});
|
|
857
985
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
858
986
|
}
|
|
859
|
-
const domain =
|
|
987
|
+
const domain = record2.input.domain;
|
|
860
988
|
const domainSlot = def.fields.domain?.slot;
|
|
861
989
|
if (typeof domain === "string" && domainSlot) {
|
|
862
990
|
const found = await deps.db.query({
|
|
863
|
-
crm_record: { $: { where: { type:
|
|
991
|
+
crm_record: { $: { where: { type: record2.type, [domainSlot]: domain }, limit: 1 } }
|
|
864
992
|
});
|
|
865
993
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
866
994
|
}
|
|
867
995
|
const nameField = def.nameField ?? "name";
|
|
868
|
-
const name =
|
|
869
|
-
if (
|
|
996
|
+
const name = record2.input[nameField];
|
|
997
|
+
if (record2.type === "company" && typeof name === "string" && name.trim()) {
|
|
870
998
|
const found = await deps.db.query({
|
|
871
|
-
crm_record: { $: { where: { type:
|
|
999
|
+
crm_record: { $: { where: { type: record2.type, name: name.trim() }, limit: 1 } }
|
|
872
1000
|
});
|
|
873
1001
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
874
1002
|
}
|
|
875
1003
|
return void 0;
|
|
876
1004
|
}
|
|
877
1005
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
878
|
-
const
|
|
879
|
-
if (!
|
|
1006
|
+
const record2 = normalizeSharedRecord(shared, options.sourceId);
|
|
1007
|
+
if (!record2.type.trim() || !record2.sourceId.trim() || !record2.sourceRecordId.trim()) {
|
|
880
1008
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
881
1009
|
}
|
|
882
|
-
if (options.sourceId && options.sourceId !==
|
|
1010
|
+
if (options.sourceId && options.sourceId !== record2.sourceId) {
|
|
883
1011
|
throw new Error("signed sender does not match payload source");
|
|
884
1012
|
}
|
|
885
|
-
const tag = networkSourceTag(
|
|
1013
|
+
const tag = networkSourceTag(record2.type, record2.sourceRecordId, record2.version === 2 ? record2.sourceId : void 0);
|
|
886
1014
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
887
|
-
const existing = await findSharedRecord(deps,
|
|
1015
|
+
const existing = await findSharedRecord(deps, record2, tag);
|
|
888
1016
|
let recordId;
|
|
889
1017
|
if (existing && typeof existing.id === "string") {
|
|
890
|
-
await updateRecord(crmDeps3, { id: existing.id, input:
|
|
1018
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record2.input });
|
|
891
1019
|
recordId = existing.id;
|
|
892
1020
|
} else {
|
|
893
|
-
recordId = `network_${shortHash(`${
|
|
1021
|
+
recordId = `network_${shortHash(`${record2.sourceId}\0${record2.type}\0${record2.sourceRecordId}`)}`;
|
|
894
1022
|
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
895
|
-
type:
|
|
896
|
-
input:
|
|
1023
|
+
type: record2.type,
|
|
1024
|
+
input: record2.input,
|
|
897
1025
|
mutationId: `share-create:${tag}`
|
|
898
1026
|
});
|
|
899
1027
|
}
|
|
@@ -903,10 +1031,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
903
1031
|
);
|
|
904
1032
|
await upsertRecordOrigin(crmDeps3, {
|
|
905
1033
|
recordId,
|
|
906
|
-
sourceId:
|
|
907
|
-
sourceRecordId:
|
|
1034
|
+
sourceId: record2.sourceId,
|
|
1035
|
+
sourceRecordId: record2.sourceRecordId,
|
|
908
1036
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
909
|
-
payloadVersion:
|
|
1037
|
+
payloadVersion: record2.version
|
|
910
1038
|
});
|
|
911
1039
|
return { recordId };
|
|
912
1040
|
}
|
|
@@ -929,28 +1057,114 @@ async function projectApplicant(deps, applicant) {
|
|
|
929
1057
|
}
|
|
930
1058
|
}
|
|
931
1059
|
|
|
932
|
-
// src/
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1060
|
+
// src/crm-sync.ts
|
|
1061
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1062
|
+
var crmDeps = (db, ctx) => ({
|
|
1063
|
+
crm: ctx.chapter.crm,
|
|
1064
|
+
db,
|
|
1065
|
+
now: () => Date.now(),
|
|
1066
|
+
newId: () => crypto.randomUUID(),
|
|
1067
|
+
chapter: ctx.chapter
|
|
1068
|
+
});
|
|
1069
|
+
function personInputFromApp(chapter, app) {
|
|
1070
|
+
const input = sharedPersonInput({
|
|
1071
|
+
email: str(app.email),
|
|
1072
|
+
firstName: str(app.firstName) || void 0,
|
|
1073
|
+
lastName: str(app.lastName) || void 0,
|
|
1074
|
+
phone: str(app.phone) || void 0,
|
|
1075
|
+
linkedin: str(app.linkedin) || void 0,
|
|
1076
|
+
hubRecordId: str(app.id)
|
|
1077
|
+
});
|
|
1078
|
+
for (const f of chapter.application.crmFields) {
|
|
1079
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
1080
|
+
}
|
|
1081
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1082
|
+
return input;
|
|
1083
|
+
}
|
|
1084
|
+
function billingColumns(app) {
|
|
1085
|
+
const status = str(app.status);
|
|
1086
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1087
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1088
|
+
const cols = { billingStatus };
|
|
1089
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1090
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1091
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1092
|
+
return cols;
|
|
1093
|
+
}
|
|
1094
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
1095
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
1096
|
+
if (!emailKey) return null;
|
|
1097
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1098
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1099
|
+
const { crm_record } = await deps.db.query({
|
|
1100
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1101
|
+
});
|
|
1102
|
+
const existing = crm_record?.[0] ?? null;
|
|
1103
|
+
const stage = opts.stage || void 0;
|
|
1104
|
+
let recordId;
|
|
1105
|
+
if (existing && typeof existing.id === "string") {
|
|
1106
|
+
recordId = existing.id;
|
|
1107
|
+
await updateRecord2(recordDeps, { id: recordId, input });
|
|
1108
|
+
} else {
|
|
1109
|
+
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1110
|
+
recordId = created.id;
|
|
1111
|
+
}
|
|
1112
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1113
|
+
await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1114
|
+
}
|
|
1115
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1116
|
+
await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1117
|
+
return recordId;
|
|
1118
|
+
}
|
|
1119
|
+
async function backfillCrm(deps) {
|
|
1120
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1121
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1122
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1123
|
+
]);
|
|
1124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1125
|
+
let synced = 0;
|
|
1126
|
+
const errors = [];
|
|
1127
|
+
const run = async (app, stage) => {
|
|
1128
|
+
const key = str(app.email).toLowerCase();
|
|
1129
|
+
if (!key || seen.has(key)) return;
|
|
1130
|
+
seen.add(key);
|
|
1131
|
+
try {
|
|
1132
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1133
|
+
synced += 1;
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1139
|
+
for (const u of usersRes.$users ?? []) {
|
|
1140
|
+
if (u.deleted === true) continue;
|
|
1141
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1142
|
+
}
|
|
1143
|
+
return { synced, errors };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/email.ts
|
|
1147
|
+
function render(template, vars) {
|
|
1148
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
1149
|
+
}
|
|
1150
|
+
function groupVars(group, vars) {
|
|
1151
|
+
return {
|
|
1152
|
+
...vars,
|
|
1153
|
+
refundPolicyText: group.refundPolicyText ?? "",
|
|
1154
|
+
commitmentText: group.commitmentText ?? "",
|
|
1155
|
+
normsText: group.normsText ?? ""
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function renderTemplateBody(group, template, vars) {
|
|
1159
|
+
const tpl = group.emailTemplates?.[template];
|
|
1160
|
+
if (!tpl) return null;
|
|
1161
|
+
return render(tpl.text, groupVars(group, vars));
|
|
1162
|
+
}
|
|
1163
|
+
function isAlreadySent(priorRows) {
|
|
1164
|
+
return priorRows.some((row) => !row.error);
|
|
1165
|
+
}
|
|
1166
|
+
function planDelivery(input) {
|
|
1167
|
+
const tpl = input.group.emailTemplates?.[input.template];
|
|
954
1168
|
if (!tpl) return { deliver: false, reason: "template-missing" };
|
|
955
1169
|
if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
|
|
956
1170
|
const vars = groupVars(input.group, input.vars);
|
|
@@ -959,10 +1173,10 @@ function planDelivery(input) {
|
|
|
959
1173
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
960
1174
|
const to = redirect ? input.group.debugEmail : input.to;
|
|
961
1175
|
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
962
|
-
const
|
|
1176
|
+
const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
963
1177
|
|
|
964
1178
|
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
965
|
-
return { deliver: true, transport, to, subject, text:
|
|
1179
|
+
return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
|
|
966
1180
|
}
|
|
967
1181
|
|
|
968
1182
|
// src/notify.ts
|
|
@@ -997,39 +1211,372 @@ async function sendTemplated(deps, input) {
|
|
|
997
1211
|
} catch (e) {
|
|
998
1212
|
error = e instanceof Error ? e.message : String(e);
|
|
999
1213
|
}
|
|
1000
|
-
}
|
|
1001
|
-
const id = deps.newId();
|
|
1002
|
-
const row = {
|
|
1003
|
-
id,
|
|
1004
|
-
groupId: input.group.id,
|
|
1005
|
-
to: decision.to,
|
|
1006
|
-
template: input.template,
|
|
1007
|
-
subject: decision.subject,
|
|
1008
|
-
body: decision.text,
|
|
1009
|
-
transport: decision.transport,
|
|
1010
|
-
redirected: decision.redirected,
|
|
1011
|
-
dedupeKey: input.dedupeKey,
|
|
1012
|
-
sentAt: deps.now(),
|
|
1013
|
-
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1014
|
-
...messageId ? { messageId } : {},
|
|
1015
|
-
...error ? { error } : {}
|
|
1214
|
+
}
|
|
1215
|
+
const id = deps.newId();
|
|
1216
|
+
const row = {
|
|
1217
|
+
id,
|
|
1218
|
+
groupId: input.group.id,
|
|
1219
|
+
to: decision.to,
|
|
1220
|
+
template: input.template,
|
|
1221
|
+
subject: decision.subject,
|
|
1222
|
+
body: decision.text,
|
|
1223
|
+
transport: decision.transport,
|
|
1224
|
+
redirected: decision.redirected,
|
|
1225
|
+
dedupeKey: input.dedupeKey,
|
|
1226
|
+
sentAt: deps.now(),
|
|
1227
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1228
|
+
...messageId ? { messageId } : {},
|
|
1229
|
+
...error ? { error } : {}
|
|
1230
|
+
};
|
|
1231
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1232
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
1233
|
+
}
|
|
1234
|
+
function emailGroupFrom(row) {
|
|
1235
|
+
const str3 = (v) => typeof v === "string" ? v : void 0;
|
|
1236
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
1237
|
+
return {
|
|
1238
|
+
id: String(row.id),
|
|
1239
|
+
name: String(row.name ?? ""),
|
|
1240
|
+
replyTo: str3(row.replyTo) ?? "",
|
|
1241
|
+
debugEmail: str3(row.debugEmail),
|
|
1242
|
+
refundPolicyText: str3(row.refundPolicyText),
|
|
1243
|
+
commitmentText: str3(row.commitmentText),
|
|
1244
|
+
normsText: str3(row.normsText),
|
|
1245
|
+
emailTemplates: templates
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/pipeline.ts
|
|
1250
|
+
function canTransition(from, to, p) {
|
|
1251
|
+
const fi = p.stages.indexOf(from);
|
|
1252
|
+
const ti = p.stages.indexOf(to);
|
|
1253
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
1254
|
+
}
|
|
1255
|
+
function canBook(status, p) {
|
|
1256
|
+
return p.bookableFrom.includes(status);
|
|
1257
|
+
}
|
|
1258
|
+
function canApprove(status, p) {
|
|
1259
|
+
return p.approvableFrom.includes(status);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/approve-application.ts
|
|
1263
|
+
async function approveApplication(input) {
|
|
1264
|
+
const { db, env, ctx, app } = input;
|
|
1265
|
+
const id = String(app.id ?? "");
|
|
1266
|
+
const status = String(app.status ?? "");
|
|
1267
|
+
if (!id) return { ok: false, status: 404, error: "not found" };
|
|
1268
|
+
if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
|
|
1269
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1270
|
+
}
|
|
1271
|
+
if (input.reason === "interview_waiver") {
|
|
1272
|
+
if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
|
|
1273
|
+
if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
|
|
1274
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
const write = await db.transact(
|
|
1278
|
+
[{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
|
|
1279
|
+
{ mutationId: `approve:${id}` }
|
|
1280
|
+
);
|
|
1281
|
+
const approved = { ...app, status: "approved" };
|
|
1282
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
|
|
1283
|
+
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
1284
|
+
let rolePromoted = false;
|
|
1285
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1286
|
+
if (promoteTo !== false && secret) {
|
|
1287
|
+
let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
|
|
1288
|
+
if (!userId && typeof app.email === "string") {
|
|
1289
|
+
const found = await clerkGetUserByEmail(secret, app.email);
|
|
1290
|
+
userId = found?.id ?? null;
|
|
1291
|
+
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
1292
|
+
}
|
|
1293
|
+
if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
|
|
1294
|
+
}
|
|
1295
|
+
let emailLogged = false;
|
|
1296
|
+
const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
|
|
1297
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1298
|
+
if (send !== false && group && typeof app.email === "string") {
|
|
1299
|
+
const result = await sendTemplated(
|
|
1300
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1301
|
+
{
|
|
1302
|
+
group: emailGroupFrom(group),
|
|
1303
|
+
template: send,
|
|
1304
|
+
to: app.email,
|
|
1305
|
+
vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
|
|
1306
|
+
applicationId: id,
|
|
1307
|
+
dedupeKey: `approve:${id}`
|
|
1308
|
+
}
|
|
1309
|
+
);
|
|
1310
|
+
emailLogged = result.sent;
|
|
1311
|
+
}
|
|
1312
|
+
return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/join-continuation.ts
|
|
1316
|
+
function resolveJoinContinuation(input) {
|
|
1317
|
+
if (input.status === "approved") return "accepted";
|
|
1318
|
+
if (input.interviewWaived) {
|
|
1319
|
+
if (!input.payable) return "approve";
|
|
1320
|
+
if (input.status === input.initialStatus) {
|
|
1321
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1322
|
+
}
|
|
1323
|
+
return "approve";
|
|
1324
|
+
}
|
|
1325
|
+
if (!input.payable) return "booking";
|
|
1326
|
+
if (input.status === input.initialStatus) {
|
|
1327
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1328
|
+
}
|
|
1329
|
+
if (canBook(input.status, input.pipeline)) return "booking";
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/join-continuation-server.ts
|
|
1334
|
+
async function continueJoin(db, ctx, env, origin, app, group) {
|
|
1335
|
+
if (typeof app.giftOfferId === "string") {
|
|
1336
|
+
if (typeof app.giftClaimId === "string") return "gift";
|
|
1337
|
+
return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
|
|
1338
|
+
}
|
|
1339
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1340
|
+
const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
|
|
1341
|
+
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1342
|
+
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1343
|
+
const state = resolveJoinContinuation({
|
|
1344
|
+
status: String(app.status ?? ""),
|
|
1345
|
+
initialStatus: ctx.chapter.pipeline.initial,
|
|
1346
|
+
pipeline: ctx.chapter.pipeline,
|
|
1347
|
+
payable,
|
|
1348
|
+
subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
|
|
1349
|
+
interviewWaived: typeof app.interviewWaivedAt === "number"
|
|
1350
|
+
});
|
|
1351
|
+
if (state !== "approve") return state;
|
|
1352
|
+
const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
|
|
1353
|
+
return approved.ok ? "accepted" : null;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/signup-control-store.ts
|
|
1357
|
+
function asStored(row) {
|
|
1358
|
+
if (!row || typeof row.document !== "object" || row.document === null) return null;
|
|
1359
|
+
return row;
|
|
1360
|
+
}
|
|
1361
|
+
async function hasSignupControl(db, targetId, environment, active) {
|
|
1362
|
+
const where = { targetId, environment, ...active ? { active: true } : {} };
|
|
1363
|
+
if (db.aggregate) {
|
|
1364
|
+
return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
|
|
1365
|
+
}
|
|
1366
|
+
const rows4 = (await db.query({ signupControls: {} })).signupControls;
|
|
1367
|
+
return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
1368
|
+
}
|
|
1369
|
+
async function loadLatestSignupControl(db, targetId, environment) {
|
|
1370
|
+
if (!await hasSignupControl(db, targetId, environment)) return null;
|
|
1371
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1372
|
+
where: { targetId, environment },
|
|
1373
|
+
order: { revision: "desc" },
|
|
1374
|
+
limit: 1
|
|
1375
|
+
} } })).signupControls;
|
|
1376
|
+
const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1377
|
+
return asStored(latest);
|
|
1378
|
+
}
|
|
1379
|
+
async function loadActiveSignupControl(db, targetId, environment) {
|
|
1380
|
+
if (!await hasSignupControl(db, targetId, environment, true)) return null;
|
|
1381
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1382
|
+
where: { targetId, environment, active: true },
|
|
1383
|
+
order: { revision: "desc" },
|
|
1384
|
+
limit: 1
|
|
1385
|
+
} } })).signupControls;
|
|
1386
|
+
const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1387
|
+
return asStored(active);
|
|
1388
|
+
}
|
|
1389
|
+
function controlledApplication(base, document) {
|
|
1390
|
+
const delegated = document?.application;
|
|
1391
|
+
return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
|
|
1392
|
+
}
|
|
1393
|
+
function validateControlledApplication(chapter, document) {
|
|
1394
|
+
if (!document.application) return [];
|
|
1395
|
+
const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
|
|
1396
|
+
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`);
|
|
1397
|
+
}
|
|
1398
|
+
function groupAttrs(document) {
|
|
1399
|
+
const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
1400
|
+
const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
|
|
1401
|
+
return {
|
|
1402
|
+
...first4 ? { standardPriceCents: first4.priceCents } : {},
|
|
1403
|
+
foundingDiscountCents: 0,
|
|
1404
|
+
...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
|
|
1405
|
+
...document.policy ?? {},
|
|
1406
|
+
...document.scheduling ? { schedulingJson: document.scheduling } : {}
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
var byPublicId = (ns, value) => ({
|
|
1410
|
+
ns,
|
|
1411
|
+
attr: "id",
|
|
1412
|
+
value
|
|
1413
|
+
});
|
|
1414
|
+
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
1415
|
+
const { document, digest } = envelope;
|
|
1416
|
+
const currentRows = (await db.query({ tiers: { $: {
|
|
1417
|
+
where: { groupId: document.target.chapterId },
|
|
1418
|
+
limit: 100
|
|
1419
|
+
} } })).tiers ?? [];
|
|
1420
|
+
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
1421
|
+
const ops = [];
|
|
1422
|
+
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
1423
|
+
for (const row of currentRows) {
|
|
1424
|
+
const id2 = String(row.id ?? "");
|
|
1425
|
+
if (id2 && !incoming.has(id2)) {
|
|
1426
|
+
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
for (const tier of document.tiers) {
|
|
1430
|
+
const tierRef = byPublicId("tiers", tier.id);
|
|
1431
|
+
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
1432
|
+
id: tier.id,
|
|
1433
|
+
groupId: document.target.chapterId,
|
|
1434
|
+
name: tier.name,
|
|
1435
|
+
priceCents: tier.priceCents,
|
|
1436
|
+
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
1437
|
+
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
1438
|
+
sortOrder: tier.sortOrder,
|
|
1439
|
+
active: tier.available,
|
|
1440
|
+
createdAt: receivedAt
|
|
1441
|
+
} });
|
|
1442
|
+
const retract = [
|
|
1443
|
+
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
1444
|
+
...!tier.blurb ? ["blurb"] : []
|
|
1445
|
+
];
|
|
1446
|
+
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
1447
|
+
}
|
|
1448
|
+
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
1449
|
+
const attrs = groupAttrs(document);
|
|
1450
|
+
ops.push({
|
|
1451
|
+
t: "update",
|
|
1452
|
+
ns: "groups",
|
|
1453
|
+
id: groupRef,
|
|
1454
|
+
attrs
|
|
1455
|
+
});
|
|
1456
|
+
if (!("stripePriceId" in attrs)) {
|
|
1457
|
+
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
1458
|
+
}
|
|
1459
|
+
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
1460
|
+
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
1461
|
+
id,
|
|
1462
|
+
sourceId: document.source.appId,
|
|
1463
|
+
targetId: document.target.chapterId,
|
|
1464
|
+
environment: document.target.environment,
|
|
1465
|
+
revision: document.revision,
|
|
1466
|
+
digest,
|
|
1467
|
+
document,
|
|
1468
|
+
active: true,
|
|
1469
|
+
receivedAt
|
|
1470
|
+
} });
|
|
1471
|
+
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/session.ts
|
|
1475
|
+
function applicationSummary(app) {
|
|
1476
|
+
return {
|
|
1477
|
+
id: app.id,
|
|
1478
|
+
firstName: app.firstName ?? null,
|
|
1479
|
+
lastName: app.lastName ?? null,
|
|
1480
|
+
email: app.email ?? null,
|
|
1481
|
+
status: app.status,
|
|
1482
|
+
createdAt: app.createdAt ?? null,
|
|
1483
|
+
meetingLink: app.meetingLink ?? null,
|
|
1484
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1485
|
+
renewalAt: app.renewalAt ?? null,
|
|
1486
|
+
canceled: app.canceled === true
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
1490
|
+
const summary = applicationSummary(app);
|
|
1491
|
+
let meetingAt = app.meetingAt ?? null;
|
|
1492
|
+
let meetUrl = null;
|
|
1493
|
+
let timezone = defaultTimezone;
|
|
1494
|
+
if (meeting) {
|
|
1495
|
+
timezone = meeting.timezone ?? timezone;
|
|
1496
|
+
if (meeting.status === "scheduled") {
|
|
1497
|
+
meetingAt = meeting.startAt ?? null;
|
|
1498
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
1499
|
+
} else {
|
|
1500
|
+
meetingAt = null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/clerk.ts
|
|
1507
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
1508
|
+
function clerkInviteRequest(input) {
|
|
1509
|
+
return {
|
|
1510
|
+
path: "/v1/invitations",
|
|
1511
|
+
body: {
|
|
1512
|
+
email_address: input.email,
|
|
1513
|
+
notify: true,
|
|
1514
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
1515
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
1516
|
+
}
|
|
1016
1517
|
};
|
|
1017
|
-
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1018
|
-
return error ? { sent: false, reason: error } : { sent: true };
|
|
1019
1518
|
}
|
|
1020
|
-
function
|
|
1021
|
-
const
|
|
1022
|
-
const
|
|
1519
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
1520
|
+
const { path, body } = clerkInviteRequest(input);
|
|
1521
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1524
|
+
body: JSON.stringify(body)
|
|
1525
|
+
});
|
|
1526
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
1527
|
+
}
|
|
1528
|
+
function clerkUserRequest(input) {
|
|
1023
1529
|
return {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1530
|
+
path: "/v1/users",
|
|
1531
|
+
body: {
|
|
1532
|
+
email_address: [input.email],
|
|
1533
|
+
skip_password_requirement: true,
|
|
1534
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
1535
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
1536
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1537
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
1542
|
+
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
1543
|
+
method: "PATCH",
|
|
1544
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1545
|
+
body: JSON.stringify({
|
|
1546
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1547
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1548
|
+
})
|
|
1549
|
+
});
|
|
1550
|
+
return res.ok;
|
|
1551
|
+
}
|
|
1552
|
+
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
1553
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
1554
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
1555
|
+
if (!found.ok) return false;
|
|
1556
|
+
const users = await found.json().catch(() => null);
|
|
1557
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
1558
|
+
if (!id) return false;
|
|
1559
|
+
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
1560
|
+
}
|
|
1561
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
1562
|
+
const { path, body } = clerkUserRequest(input);
|
|
1563
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1564
|
+
method: "POST",
|
|
1565
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1566
|
+
body: JSON.stringify(body)
|
|
1567
|
+
});
|
|
1568
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
1569
|
+
const healed = heal(res.status);
|
|
1570
|
+
const repair = input.repairMetadata ?? {
|
|
1571
|
+
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
1572
|
+
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
1032
1573
|
};
|
|
1574
|
+
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
1575
|
+
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
1576
|
+
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
1577
|
+
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
1578
|
+
}, fetchImpl).catch(() => false);
|
|
1579
|
+
return { ...healed, refreshed };
|
|
1033
1580
|
}
|
|
1034
1581
|
|
|
1035
1582
|
// src/worker-provisioning.ts
|
|
@@ -1159,8 +1706,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1159
1706
|
|
|
1160
1707
|
// src/worker-routes.ts
|
|
1161
1708
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1709
|
+
let apps = (await db.query({ applications: { $: {
|
|
1710
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
1711
|
+
limit: 20
|
|
1712
|
+
} } })).applications;
|
|
1713
|
+
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
1714
|
+
const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
1164
1715
|
if (!app) return null;
|
|
1165
1716
|
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
1166
1717
|
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
@@ -1262,7 +1813,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1262
1813
|
membershipPage: activeControl.document.membershipPage,
|
|
1263
1814
|
application: activeControl.document.application,
|
|
1264
1815
|
theme: activeControl.document.theme
|
|
1265
|
-
} } : {}
|
|
1816
|
+
} } : {},
|
|
1817
|
+
giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
|
|
1266
1818
|
});
|
|
1267
1819
|
}
|
|
1268
1820
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
@@ -1301,18 +1853,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1301
1853
|
})).groups;
|
|
1302
1854
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1303
1855
|
if (!group) return json({ error: "not found" }, 404);
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1308
|
-
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1309
|
-
if (!payable) return json({ state: "booking", applicationId });
|
|
1310
|
-
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1311
|
-
return json({ state: "paymentPending", applicationId });
|
|
1312
|
-
}
|
|
1313
|
-
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
1314
|
-
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
1315
|
-
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
1856
|
+
const state = await continueJoin(db, ctx, env, url.origin, app, group);
|
|
1857
|
+
if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
|
|
1858
|
+
return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
|
|
1316
1859
|
}
|
|
1317
1860
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
1318
1861
|
const raw = await req.text();
|
|
@@ -1328,12 +1871,19 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1328
1871
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
1329
1872
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
1330
1873
|
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
1874
|
+
const now = Date.now();
|
|
1875
|
+
const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
|
|
1876
|
+
const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
|
|
1877
|
+
if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
|
|
1878
|
+
const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
|
|
1331
1879
|
const result = await submitApplication(db, effectiveChapter, parsed, {
|
|
1332
1880
|
submissionId,
|
|
1333
1881
|
groupId: chapter.id,
|
|
1334
|
-
now
|
|
1882
|
+
now,
|
|
1335
1883
|
newId: () => crypto.randomUUID(),
|
|
1336
|
-
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1884
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
1885
|
+
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
1886
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
1337
1887
|
});
|
|
1338
1888
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1339
1889
|
if (!result.duplicate) {
|
|
@@ -1342,10 +1892,16 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1342
1892
|
}
|
|
1343
1893
|
await provisionApplicant(db, effectiveChapter, result.id, parsed);
|
|
1344
1894
|
}
|
|
1895
|
+
const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
|
|
1896
|
+
const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
|
|
1897
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
1898
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1899
|
+
const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
|
|
1345
1900
|
return json({
|
|
1346
1901
|
id: result.id,
|
|
1347
1902
|
duplicate: result.duplicate,
|
|
1348
1903
|
status: result.status,
|
|
1904
|
+
...state ? { state } : {},
|
|
1349
1905
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
1350
1906
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
1351
1907
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -1401,8 +1957,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1401
1957
|
payload,
|
|
1402
1958
|
sourceId ? { sourceId } : {}
|
|
1403
1959
|
);
|
|
1404
|
-
const
|
|
1405
|
-
return json({ recordId, type:
|
|
1960
|
+
const record2 = normalizeSharedRecord(payload, sourceId);
|
|
1961
|
+
return json({ recordId, type: record2.type });
|
|
1406
1962
|
} catch (error) {
|
|
1407
1963
|
return json({
|
|
1408
1964
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1413,29 +1969,6 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1413
1969
|
// src/worker-routes-signup-control.ts
|
|
1414
1970
|
import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
|
|
1415
1971
|
|
|
1416
|
-
// src/signup-control-canonical.ts
|
|
1417
|
-
var encoder = new TextEncoder();
|
|
1418
|
-
function canonicalValue(value) {
|
|
1419
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
1420
|
-
return JSON.stringify(value);
|
|
1421
|
-
}
|
|
1422
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
1423
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
1424
|
-
const record = value;
|
|
1425
|
-
const entries = Object.keys(record).sort().flatMap((key) => {
|
|
1426
|
-
const item = record[key];
|
|
1427
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
1428
|
-
});
|
|
1429
|
-
return `{${entries.join(",")}}`;
|
|
1430
|
-
}
|
|
1431
|
-
function canonicalSignupControl(value) {
|
|
1432
|
-
return canonicalValue(value);
|
|
1433
|
-
}
|
|
1434
|
-
async function signupControlDigest(document) {
|
|
1435
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
1436
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
1972
|
// src/signup-control-contract.ts
|
|
1440
1973
|
var SIGNUP_CONTROL_PATH = "/api/signup-control";
|
|
1441
1974
|
|
|
@@ -1479,10 +2012,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
1479
2012
|
errors.push(`${path} must be a string`);
|
|
1480
2013
|
return void 0;
|
|
1481
2014
|
}
|
|
1482
|
-
const
|
|
1483
|
-
if (required && !
|
|
1484
|
-
if (
|
|
1485
|
-
return
|
|
2015
|
+
const text3 = value.trim();
|
|
2016
|
+
if (required && !text3) errors.push(`${path} is required`);
|
|
2017
|
+
if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2018
|
+
return text3;
|
|
1486
2019
|
}
|
|
1487
2020
|
function validateMembershipPageDocument(input, options = {}) {
|
|
1488
2021
|
const errors = [];
|
|
@@ -1531,8 +2064,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1531
2064
|
else {
|
|
1532
2065
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
1533
2066
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
1534
|
-
const
|
|
1535
|
-
return
|
|
2067
|
+
const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
2068
|
+
return text3 ? [text3] : [];
|
|
1536
2069
|
});
|
|
1537
2070
|
}
|
|
1538
2071
|
}
|
|
@@ -1606,7 +2139,7 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1606
2139
|
var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1607
2140
|
var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
1608
2141
|
var TIER = /^[a-z][a-z0-9-]{0,79}$/;
|
|
1609
|
-
var
|
|
2142
|
+
var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
|
|
1610
2143
|
var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
1611
2144
|
"schemaVersion",
|
|
1612
2145
|
"revision",
|
|
@@ -1618,14 +2151,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
1618
2151
|
"application",
|
|
1619
2152
|
"policy",
|
|
1620
2153
|
"scheduling",
|
|
1621
|
-
"theme"
|
|
2154
|
+
"theme",
|
|
2155
|
+
"giftOffers"
|
|
1622
2156
|
]);
|
|
1623
2157
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
1624
2158
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1625
2159
|
function unknownKeys(value, allowed, path, errors) {
|
|
1626
2160
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1627
2161
|
}
|
|
1628
|
-
function
|
|
2162
|
+
function text2(value, path, errors, max = 65536) {
|
|
1629
2163
|
if (typeof value !== "string" || !value.trim()) {
|
|
1630
2164
|
errors.push(`${path} is required`);
|
|
1631
2165
|
return "";
|
|
@@ -1648,23 +2182,23 @@ function tiersOf(value, errors) {
|
|
|
1648
2182
|
return [];
|
|
1649
2183
|
}
|
|
1650
2184
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
1651
|
-
const id =
|
|
2185
|
+
const id = text2(item.id, `${path}.id`, errors, 80);
|
|
1652
2186
|
if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
1653
2187
|
if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
1654
2188
|
seen.add(id);
|
|
1655
|
-
const name =
|
|
2189
|
+
const name = text2(item.name, `${path}.name`, errors, 160);
|
|
1656
2190
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
1657
2191
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
1658
2192
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
1659
2193
|
errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
|
|
1660
2194
|
}
|
|
1661
|
-
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !
|
|
2195
|
+
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
|
|
1662
2196
|
errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
1663
2197
|
}
|
|
1664
2198
|
if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
|
|
1665
2199
|
errors.push(`${path}.stripePriceId is required for an available paid tier`);
|
|
1666
2200
|
}
|
|
1667
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
2201
|
+
const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
1668
2202
|
return [{
|
|
1669
2203
|
id,
|
|
1670
2204
|
name,
|
|
@@ -1713,7 +2247,7 @@ function policyOf(value, errors) {
|
|
|
1713
2247
|
const result = {};
|
|
1714
2248
|
for (const key of allowed) {
|
|
1715
2249
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
1716
|
-
result[key] =
|
|
2250
|
+
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
1717
2251
|
}
|
|
1718
2252
|
return result;
|
|
1719
2253
|
}
|
|
@@ -1727,12 +2261,18 @@ function themeOf(value, errors) {
|
|
|
1727
2261
|
}
|
|
1728
2262
|
unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
|
|
1729
2263
|
if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
|
|
2264
|
+
let serializedLength = 0;
|
|
1730
2265
|
for (const [key, token] of Object.entries(tokens)) {
|
|
1731
|
-
if (!/^--[a-z0-9-]{
|
|
1732
|
-
|
|
1733
|
-
|
|
2266
|
+
if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
|
|
2267
|
+
errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
|
|
2268
|
+
}
|
|
2269
|
+
const rendered = String(token).trim();
|
|
2270
|
+
serializedLength += key.length + rendered.length + 4;
|
|
2271
|
+
if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
|
|
2272
|
+
errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
|
|
1734
2273
|
}
|
|
1735
2274
|
}
|
|
2275
|
+
if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
|
|
1736
2276
|
return { tokens };
|
|
1737
2277
|
}
|
|
1738
2278
|
function validateSignupControlDocument(input) {
|
|
@@ -1745,9 +2285,9 @@ function validateSignupControlDocument(input) {
|
|
|
1745
2285
|
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1746
2286
|
const source = recordOf(root.source);
|
|
1747
2287
|
const target = recordOf(root.target);
|
|
1748
|
-
const appId =
|
|
1749
|
-
const chapterId =
|
|
1750
|
-
const environment =
|
|
2288
|
+
const appId = text2(source?.appId, "document.source.appId", errors, 63);
|
|
2289
|
+
const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
2290
|
+
const environment = text2(target?.environment, "document.target.environment", errors, 63);
|
|
1751
2291
|
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1752
2292
|
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1753
2293
|
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
@@ -1768,6 +2308,8 @@ function validateSignupControlDocument(input) {
|
|
|
1768
2308
|
else scheduling = root.scheduling;
|
|
1769
2309
|
}
|
|
1770
2310
|
const theme = themeOf(root.theme, errors);
|
|
2311
|
+
const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
|
|
2312
|
+
if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
|
|
1771
2313
|
if (errors.length || !page.ok) return { ok: false, errors };
|
|
1772
2314
|
return { ok: true, value: {
|
|
1773
2315
|
schemaVersion: 1,
|
|
@@ -1780,7 +2322,8 @@ function validateSignupControlDocument(input) {
|
|
|
1780
2322
|
...application ? { application } : {},
|
|
1781
2323
|
...policy ? { policy } : {},
|
|
1782
2324
|
...scheduling ? { scheduling } : {},
|
|
1783
|
-
...theme ? { theme } : {}
|
|
2325
|
+
...theme ? { theme } : {},
|
|
2326
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
1784
2327
|
} };
|
|
1785
2328
|
}
|
|
1786
2329
|
|
|
@@ -1841,92 +2384,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1841
2384
|
});
|
|
1842
2385
|
};
|
|
1843
2386
|
|
|
1844
|
-
// src/
|
|
1845
|
-
import {
|
|
1846
|
-
var
|
|
1847
|
-
var
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
});
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
hubRecordId: str(app.id)
|
|
1862
|
-
});
|
|
1863
|
-
for (const f of chapter.application.crmFields) {
|
|
1864
|
-
if (app[f] !== void 0) input[f] = app[f];
|
|
2387
|
+
// src/worker-routes-admission-grants.ts
|
|
2388
|
+
import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
|
|
2389
|
+
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2390
|
+
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2391
|
+
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
2392
|
+
const authority = ctx.chapter.signupControl;
|
|
2393
|
+
if (!authority) return json({ error: "not found" }, 404);
|
|
2394
|
+
const db = ctx.makeDb(env);
|
|
2395
|
+
const secret = await getVaultSecret(db, authority.secretName);
|
|
2396
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2397
|
+
const verified = await verifyFederatedRequest3(req, { secret, senders: [authority.sourceId] });
|
|
2398
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2399
|
+
let input;
|
|
2400
|
+
try {
|
|
2401
|
+
input = JSON.parse(verified.body);
|
|
2402
|
+
} catch {
|
|
2403
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
1865
2404
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
if (
|
|
1876
|
-
if (
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
});
|
|
1887
|
-
const existing = crm_record?.[0] ?? null;
|
|
1888
|
-
const stage = opts.stage || void 0;
|
|
1889
|
-
let recordId;
|
|
1890
|
-
if (existing && typeof existing.id === "string") {
|
|
1891
|
-
recordId = existing.id;
|
|
1892
|
-
await updateRecord2(recordDeps, { id: recordId, input });
|
|
1893
|
-
} else {
|
|
1894
|
-
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1895
|
-
recordId = created.id;
|
|
2405
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2406
|
+
const checked = validateAdmissionGrantDocument(input.document);
|
|
2407
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2408
|
+
const document = checked.value;
|
|
2409
|
+
if (document.source.appId !== authority.sourceId) return reject2("wrong_source", 403);
|
|
2410
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2411
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2412
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2413
|
+
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2414
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2415
|
+
if (latest && document.revision === latest.revision) {
|
|
2416
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2417
|
+
return json({
|
|
2418
|
+
ok: true,
|
|
2419
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2420
|
+
revision: latest.revision,
|
|
2421
|
+
digest: latest.digest,
|
|
2422
|
+
status: "replayed",
|
|
2423
|
+
acknowledgedAt: latest.receivedAt
|
|
2424
|
+
});
|
|
1896
2425
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
2426
|
+
const receivedAt = Date.now();
|
|
2427
|
+
try {
|
|
2428
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2429
|
+
} catch {
|
|
2430
|
+
return reject2("materialization_failed", 503);
|
|
1899
2431
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2432
|
+
return json({
|
|
2433
|
+
ok: true,
|
|
2434
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2435
|
+
revision: document.revision,
|
|
2436
|
+
digest,
|
|
2437
|
+
status: "accepted",
|
|
2438
|
+
acknowledgedAt: receivedAt
|
|
2439
|
+
});
|
|
2440
|
+
};
|
|
2441
|
+
|
|
2442
|
+
// src/worker-routes-admin-admission.ts
|
|
2443
|
+
async function gate(req, env, ctx) {
|
|
2444
|
+
const rawDb = ctx.makeDb(env);
|
|
2445
|
+
const user = await ctx.verifyUser(req, env);
|
|
2446
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2447
|
+
if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
|
|
2448
|
+
return { db: rawDb, user };
|
|
2449
|
+
}
|
|
2450
|
+
var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
2451
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
|
|
2452
|
+
const auth = await gate(req, env, ctx);
|
|
2453
|
+
if (auth instanceof Response) return auth;
|
|
2454
|
+
const result = await auth.db.query({ admissionGrants: { $: {
|
|
2455
|
+
where: { targetId: ctx.chapter.id },
|
|
2456
|
+
order: { createdAt: "desc" },
|
|
2457
|
+
limit: 500
|
|
2458
|
+
} } });
|
|
2459
|
+
const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
|
|
2460
|
+
return json({ grants });
|
|
2461
|
+
}
|
|
2462
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
|
|
2463
|
+
const auth = await gate(req, env, ctx);
|
|
2464
|
+
if (auth instanceof Response) return auth;
|
|
2465
|
+
let body;
|
|
1916
2466
|
try {
|
|
1917
|
-
await
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2467
|
+
body = JSON.parse(await req.text());
|
|
2468
|
+
} catch {
|
|
2469
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2470
|
+
}
|
|
2471
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
2472
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
2473
|
+
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
2474
|
+
const decision = body.decision ?? "waive_interview";
|
|
2475
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
2476
|
+
return json({ error: "decision must be waive_interview or require_interview" }, 400);
|
|
2477
|
+
}
|
|
2478
|
+
const note = typeof body.note === "string" ? body.note.trim() : "";
|
|
2479
|
+
if (note.length > 2e3) return json({ error: "note is too long" }, 400);
|
|
2480
|
+
const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
|
|
2481
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
|
|
2482
|
+
return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
|
|
2483
|
+
}
|
|
2484
|
+
const sourceGrantId = crypto.randomUUID();
|
|
2485
|
+
const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
|
|
2486
|
+
const createdAt = Date.now();
|
|
2487
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
2488
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
2489
|
+
id,
|
|
2490
|
+
sourceGrantId,
|
|
2491
|
+
sourceScope: "local",
|
|
2492
|
+
sourceId: ctx.chapter.id,
|
|
2493
|
+
targetId: ctx.chapter.id,
|
|
2494
|
+
email,
|
|
2495
|
+
emailNormalized,
|
|
2496
|
+
decision,
|
|
2497
|
+
...note ? { note } : {},
|
|
2498
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
2499
|
+
active: true,
|
|
2500
|
+
createdAt,
|
|
2501
|
+
createdBy: actor,
|
|
2502
|
+
updatedAt: createdAt,
|
|
2503
|
+
updatedBy: actor
|
|
2504
|
+
} }], { mutationId: `admission-local-create:${sourceGrantId}` });
|
|
2505
|
+
return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
|
|
2506
|
+
}
|
|
2507
|
+
const prefix = "/api/admin/admission-grants/";
|
|
2508
|
+
let revokeId = "";
|
|
2509
|
+
if (url.pathname.startsWith(prefix)) {
|
|
2510
|
+
try {
|
|
2511
|
+
revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
2512
|
+
} catch {
|
|
2513
|
+
revokeId = "";
|
|
1921
2514
|
}
|
|
1922
|
-
};
|
|
1923
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1924
|
-
for (const u of usersRes.$users ?? []) {
|
|
1925
|
-
if (u.deleted === true) continue;
|
|
1926
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1927
2515
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
2516
|
+
if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
|
|
2517
|
+
const auth = await gate(req, env, ctx);
|
|
2518
|
+
if (auth instanceof Response) return auth;
|
|
2519
|
+
const id = revokeId;
|
|
2520
|
+
const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
|
|
2521
|
+
const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
|
|
2522
|
+
if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
|
|
2523
|
+
if (row.active !== true) return json({ ok: true, replayed: true });
|
|
2524
|
+
const revokedAt = Date.now();
|
|
2525
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
2526
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
2527
|
+
active: false,
|
|
2528
|
+
revokedAt,
|
|
2529
|
+
revokedBy: actor,
|
|
2530
|
+
updatedAt: revokedAt,
|
|
2531
|
+
updatedBy: actor
|
|
2532
|
+
} }], { mutationId: `admission-local-revoke:${id}` });
|
|
2533
|
+
return json({ ok: true, replayed: false });
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
};
|
|
1930
2537
|
|
|
1931
2538
|
// src/worker-routes-schedule.ts
|
|
1932
2539
|
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
@@ -1942,8 +2549,8 @@ function makeCalendar(env) {
|
|
|
1942
2549
|
}
|
|
1943
2550
|
async function firstRow(db, ns, q) {
|
|
1944
2551
|
const res = await db.query({ [ns]: { $: q } });
|
|
1945
|
-
const
|
|
1946
|
-
return Array.isArray(
|
|
2552
|
+
const rows4 = res[ns];
|
|
2553
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1947
2554
|
}
|
|
1948
2555
|
async function computeSlots(cal, cfg) {
|
|
1949
2556
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2080,8 +2687,26 @@ function findApplicationRef(obj) {
|
|
|
2080
2687
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2081
2688
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2082
2689
|
}
|
|
2690
|
+
function findGiftRef(obj) {
|
|
2691
|
+
const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
|
|
2692
|
+
const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
|
|
2693
|
+
const parent = obj.parent;
|
|
2694
|
+
const subscription = obj.subscription_details ?? parent?.subscription_details;
|
|
2695
|
+
const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
|
|
2696
|
+
const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
|
|
2697
|
+
return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
|
|
2698
|
+
}
|
|
2083
2699
|
function normalizeWebhookEvent(event) {
|
|
2084
2700
|
const obj = event.data?.object ?? {};
|
|
2701
|
+
const gift = findGiftRef(obj);
|
|
2702
|
+
if (gift.giftIntentId) {
|
|
2703
|
+
if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
|
|
2704
|
+
return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
|
|
2705
|
+
}
|
|
2706
|
+
if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
|
|
2707
|
+
if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
|
|
2708
|
+
return { kind: "ignored", type: event.type };
|
|
2709
|
+
}
|
|
2085
2710
|
const ref = findApplicationRef(obj);
|
|
2086
2711
|
switch (event.type) {
|
|
2087
2712
|
case "invoice.paid": {
|
|
@@ -2182,9 +2807,9 @@ function createStripeProvider(config) {
|
|
|
2182
2807
|
`sub:${input.applicationId}`
|
|
2183
2808
|
);
|
|
2184
2809
|
if (!sub.ok) fail("subscription create", sub);
|
|
2185
|
-
const
|
|
2186
|
-
if (!
|
|
2187
|
-
return { customerId, subscriptionId: String(sub.body.id), clientSecret };
|
|
2810
|
+
const clientSecret2 = clientSecretOf(sub.body);
|
|
2811
|
+
if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
|
|
2812
|
+
return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
|
|
2188
2813
|
},
|
|
2189
2814
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2190
2815
|
if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2197,8 +2822,8 @@ function createStripeProvider(config) {
|
|
|
2197
2822
|
const sk = requireSecret(secretKey);
|
|
2198
2823
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2199
2824
|
if (!charges.ok) fail("charges list", charges);
|
|
2200
|
-
const
|
|
2201
|
-
const paid =
|
|
2825
|
+
const rows4 = charges.body.data ?? [];
|
|
2826
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2202
2827
|
const charge = paid[paid.length - 1];
|
|
2203
2828
|
if (!charge) {
|
|
2204
2829
|
const err = new Error("no paid charge to refund");
|
|
@@ -2214,12 +2839,337 @@ function createStripeProvider(config) {
|
|
|
2214
2839
|
};
|
|
2215
2840
|
}
|
|
2216
2841
|
|
|
2842
|
+
// src/gift-codes.ts
|
|
2843
|
+
var GIFT_CODE_VERSION = 1;
|
|
2844
|
+
var GIFT_CODE_BYTES = 32;
|
|
2845
|
+
var base64url = (bytes) => {
|
|
2846
|
+
let binary = "";
|
|
2847
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2848
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
2849
|
+
};
|
|
2850
|
+
function createGiftCode() {
|
|
2851
|
+
const bytes = new Uint8Array(GIFT_CODE_BYTES);
|
|
2852
|
+
crypto.getRandomValues(bytes);
|
|
2853
|
+
return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
|
|
2854
|
+
}
|
|
2855
|
+
async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
|
|
2856
|
+
const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
|
|
2857
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
|
|
2858
|
+
}
|
|
2859
|
+
async function giftRequesterBucket(chapterId, requester) {
|
|
2860
|
+
const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
|
|
2861
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/gift-store.ts
|
|
2865
|
+
var first = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
2866
|
+
async function prepareGiftCode(chapterId) {
|
|
2867
|
+
const code = createGiftCode();
|
|
2868
|
+
return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
|
|
2869
|
+
}
|
|
2870
|
+
async function issueGiftClaim(db, intent, provider, now) {
|
|
2871
|
+
const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
2872
|
+
if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
|
|
2873
|
+
const offer = intent.offerSnapshot;
|
|
2874
|
+
const orderId = `gift-order:${intent.id}`;
|
|
2875
|
+
const claimId = `gift-claim:${intent.id}`;
|
|
2876
|
+
const shared = {
|
|
2877
|
+
chapterId: intent.chapterId,
|
|
2878
|
+
offerId: intent.offerId,
|
|
2879
|
+
...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
|
|
2880
|
+
...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
|
|
2881
|
+
purchaserEmailNormalized: intent.purchaserEmailNormalized
|
|
2882
|
+
};
|
|
2883
|
+
const ops = [
|
|
2884
|
+
{ t: "update", ns: "giftOrders", id: orderId, attrs: {
|
|
2885
|
+
id: orderId,
|
|
2886
|
+
intentId: intent.id,
|
|
2887
|
+
...shared,
|
|
2888
|
+
environment: intent.environment,
|
|
2889
|
+
offerSnapshot: offer,
|
|
2890
|
+
purchaserType: intent.purchaserType,
|
|
2891
|
+
providerEventId: provider.eventId,
|
|
2892
|
+
providerObjectId: provider.objectId,
|
|
2893
|
+
amountCents: offer.priceCents,
|
|
2894
|
+
currency: offer.currency,
|
|
2895
|
+
billingMode: offer.billingMode,
|
|
2896
|
+
createdAt: now
|
|
2897
|
+
} },
|
|
2898
|
+
{ t: "update", ns: "giftClaims", id: claimId, attrs: {
|
|
2899
|
+
id: claimId,
|
|
2900
|
+
orderId,
|
|
2901
|
+
intentId: intent.id,
|
|
2902
|
+
...shared,
|
|
2903
|
+
target: offer.target,
|
|
2904
|
+
codeHash: intent.codeHash,
|
|
2905
|
+
codeVersion: intent.codeVersion,
|
|
2906
|
+
rotationCount: 0,
|
|
2907
|
+
codeIssuedAt: intent.codeIssuedAt,
|
|
2908
|
+
codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
|
|
2909
|
+
attemptCount: 0,
|
|
2910
|
+
attemptWindowStartedAt: now,
|
|
2911
|
+
status: "pending",
|
|
2912
|
+
createdAt: now
|
|
2913
|
+
} },
|
|
2914
|
+
{ t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
|
|
2915
|
+
];
|
|
2916
|
+
if (intent.purchaserApplicationId) ops.push({
|
|
2917
|
+
t: "update",
|
|
2918
|
+
ns: "applications",
|
|
2919
|
+
id: intent.purchaserApplicationId,
|
|
2920
|
+
attrs: { giftPaidAt: now, giftClaimId: claimId }
|
|
2921
|
+
});
|
|
2922
|
+
try {
|
|
2923
|
+
const result = await db.transact(ops, {
|
|
2924
|
+
mutationId: `gift-paid:${provider.eventId}`,
|
|
2925
|
+
guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
|
|
2926
|
+
});
|
|
2927
|
+
return { claimId, orderId, duplicate: result.duplicate };
|
|
2928
|
+
} catch {
|
|
2929
|
+
const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
2930
|
+
if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
|
|
2931
|
+
throw new Error("gift issuance conflict");
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
function buyerGiftClaims(rows4, now) {
|
|
2935
|
+
return rows4.map((row) => {
|
|
2936
|
+
const stored = String(row.status ?? "pending");
|
|
2937
|
+
const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
|
|
2938
|
+
return {
|
|
2939
|
+
id: String(row.id),
|
|
2940
|
+
offerId: String(row.offerId),
|
|
2941
|
+
status,
|
|
2942
|
+
createdAt: Number(row.createdAt),
|
|
2943
|
+
codeExpiresAt: Number(row.codeExpiresAt),
|
|
2944
|
+
...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
|
|
2945
|
+
...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
|
|
2946
|
+
};
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
async function rotateGiftClaim(db, claim, now) {
|
|
2950
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2951
|
+
const prepared = await prepareGiftCode(String(claim.chapterId));
|
|
2952
|
+
const currentRotation = Number(claim.rotationCount ?? 0);
|
|
2953
|
+
try {
|
|
2954
|
+
const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
2955
|
+
codeHash: prepared.codeHash,
|
|
2956
|
+
codeVersion: prepared.codeVersion,
|
|
2957
|
+
rotationCount: currentRotation + 1,
|
|
2958
|
+
codeIssuedAt: now,
|
|
2959
|
+
rotatedAt: now,
|
|
2960
|
+
attemptCount: 0,
|
|
2961
|
+
attemptWindowStartedAt: now
|
|
2962
|
+
} }], {
|
|
2963
|
+
mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
|
|
2964
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
|
|
2965
|
+
});
|
|
2966
|
+
return write.duplicate ? null : { code: prepared.code };
|
|
2967
|
+
} catch {
|
|
2968
|
+
return null;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
2972
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2973
|
+
const chapterId = String(claim.chapterId);
|
|
2974
|
+
const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
|
|
2975
|
+
const target = claim.target;
|
|
2976
|
+
const ops = [];
|
|
2977
|
+
let result;
|
|
2978
|
+
if (target.type === "tier") {
|
|
2979
|
+
const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
2980
|
+
const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
2981
|
+
const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
|
|
2982
|
+
const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
|
|
2983
|
+
ops.push({ t: "update", ns: "applications", id, attrs: {
|
|
2984
|
+
id,
|
|
2985
|
+
groupId: chapterId,
|
|
2986
|
+
email: beneficiary.email,
|
|
2987
|
+
emailNormalized,
|
|
2988
|
+
tierId: target.tierId,
|
|
2989
|
+
status,
|
|
2990
|
+
giftClaimId: String(claim.id),
|
|
2991
|
+
giftOrderId: String(claim.orderId),
|
|
2992
|
+
clerkUserId: beneficiary.userId,
|
|
2993
|
+
...existing ? {} : { createdAt: now }
|
|
2994
|
+
} });
|
|
2995
|
+
result = { kind: "application", id };
|
|
2996
|
+
} else {
|
|
2997
|
+
const id = `gift-entitlement:${String(claim.id)}`;
|
|
2998
|
+
ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
|
|
2999
|
+
id,
|
|
3000
|
+
claimId: String(claim.id),
|
|
3001
|
+
orderId: String(claim.orderId),
|
|
3002
|
+
chapterId,
|
|
3003
|
+
entitlementKey: target.entitlementKey,
|
|
3004
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3005
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3006
|
+
active: true,
|
|
3007
|
+
createdAt: now
|
|
3008
|
+
} });
|
|
3009
|
+
result = { kind: "entitlement", id };
|
|
3010
|
+
}
|
|
3011
|
+
ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3012
|
+
status: "redeemed",
|
|
3013
|
+
redeemedAt: now,
|
|
3014
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3015
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3016
|
+
...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
|
|
3017
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1
|
|
3018
|
+
} });
|
|
3019
|
+
try {
|
|
3020
|
+
await db.transact(ops, {
|
|
3021
|
+
mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
|
|
3022
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
|
|
3023
|
+
status: "pending",
|
|
3024
|
+
codeVersion: Number(claim.codeVersion),
|
|
3025
|
+
codeHash: String(claim.codeHash)
|
|
3026
|
+
} }]
|
|
3027
|
+
});
|
|
3028
|
+
return result;
|
|
3029
|
+
} catch {
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
async function revokeGiftClaim(db, claim, reason, now) {
|
|
3034
|
+
if (claim.status === "revoked") return false;
|
|
3035
|
+
const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3036
|
+
status: "revoked",
|
|
3037
|
+
revokedAt: now,
|
|
3038
|
+
revokedReason: reason
|
|
3039
|
+
} }];
|
|
3040
|
+
if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
|
|
3041
|
+
if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
|
|
3042
|
+
giftRevokedAt: now,
|
|
3043
|
+
...reason === "refunded" ? { status: "refunded" } : { canceled: true }
|
|
3044
|
+
} });
|
|
3045
|
+
try {
|
|
3046
|
+
await db.transact(ops, {
|
|
3047
|
+
mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
|
|
3048
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
|
|
3049
|
+
});
|
|
3050
|
+
return true;
|
|
3051
|
+
} catch {
|
|
3052
|
+
return false;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// src/gift-checkout.ts
|
|
3057
|
+
var clientSecret = (value) => {
|
|
3058
|
+
const direct = value.client_secret;
|
|
3059
|
+
if (typeof direct === "string" && direct) return direct;
|
|
3060
|
+
const invoice = value.latest_invoice;
|
|
3061
|
+
const confirmation = invoice?.confirmation_secret;
|
|
3062
|
+
const intent = invoice?.payment_intent;
|
|
3063
|
+
const nested = confirmation?.client_secret ?? intent?.client_secret;
|
|
3064
|
+
return typeof nested === "string" && nested ? nested : null;
|
|
3065
|
+
};
|
|
3066
|
+
async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
|
|
3067
|
+
const offer = intent.offerSnapshot;
|
|
3068
|
+
const metadata = {
|
|
3069
|
+
giftIntentId: intent.id,
|
|
3070
|
+
chapterId: intent.chapterId,
|
|
3071
|
+
environment: intent.environment,
|
|
3072
|
+
offerId: intent.offerId
|
|
3073
|
+
};
|
|
3074
|
+
let customerId = existingCustomerId;
|
|
3075
|
+
if (!customerId) {
|
|
3076
|
+
const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
|
|
3077
|
+
if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
|
|
3078
|
+
customerId = String(customer.body.id);
|
|
3079
|
+
}
|
|
3080
|
+
if (offer.billingMode === "subscription") {
|
|
3081
|
+
const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
|
|
3082
|
+
customer: customerId,
|
|
3083
|
+
"items[0][price]": offer.stripePriceId,
|
|
3084
|
+
payment_behavior: "default_incomplete",
|
|
3085
|
+
"payment_settings[save_default_payment_method]": "on_subscription",
|
|
3086
|
+
"payment_settings[payment_method_types][0]": "card",
|
|
3087
|
+
"expand[0]": "latest_invoice.confirmation_secret",
|
|
3088
|
+
metadata
|
|
3089
|
+
}, `gift-subscription:${intent.id}`);
|
|
3090
|
+
const secret2 = created2.ok ? clientSecret(created2.body) : null;
|
|
3091
|
+
if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
|
|
3092
|
+
return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
|
|
3093
|
+
}
|
|
3094
|
+
const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
|
|
3095
|
+
if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
|
|
3096
|
+
throw new Error("gift price does not match the signed offer");
|
|
3097
|
+
}
|
|
3098
|
+
const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
|
|
3099
|
+
customer: customerId,
|
|
3100
|
+
amount: offer.priceCents,
|
|
3101
|
+
currency: offer.currency,
|
|
3102
|
+
"automatic_payment_methods[enabled]": true,
|
|
3103
|
+
metadata
|
|
3104
|
+
}, `gift-payment:${intent.id}`);
|
|
3105
|
+
const secret = created.ok ? clientSecret(created.body) : null;
|
|
3106
|
+
if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
|
|
3107
|
+
return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// src/gift-intents.ts
|
|
3111
|
+
var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3112
|
+
async function startGiftIntent(db, input) {
|
|
3113
|
+
let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
|
|
3114
|
+
let giftCode;
|
|
3115
|
+
if (!stored) {
|
|
3116
|
+
const prepared = await prepareGiftCode(input.chapterId);
|
|
3117
|
+
const attrs = {
|
|
3118
|
+
id: input.id,
|
|
3119
|
+
chapterId: input.chapterId,
|
|
3120
|
+
environment: input.environment,
|
|
3121
|
+
purchaserType: input.purchaserType,
|
|
3122
|
+
purchaserEmailNormalized: input.purchaserEmailNormalized,
|
|
3123
|
+
...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
|
|
3124
|
+
...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
|
|
3125
|
+
offerId: input.offer.id,
|
|
3126
|
+
offerSnapshot: input.offer,
|
|
3127
|
+
status: "created",
|
|
3128
|
+
codeHash: prepared.codeHash,
|
|
3129
|
+
codeVersion: prepared.codeVersion,
|
|
3130
|
+
codeIssuedAt: input.now,
|
|
3131
|
+
createdAt: input.now,
|
|
3132
|
+
updatedAt: input.now
|
|
3133
|
+
};
|
|
3134
|
+
const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
|
|
3135
|
+
mutationId: `gift-intent:${input.id}`
|
|
3136
|
+
});
|
|
3137
|
+
stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
|
|
3138
|
+
if (!write.duplicate) giftCode = prepared.code;
|
|
3139
|
+
}
|
|
3140
|
+
if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
|
|
3141
|
+
throw new Error("gift purchase intent does not match this checkout");
|
|
3142
|
+
}
|
|
3143
|
+
if (stored.status === "paid") throw new Error("gift purchase is already paid");
|
|
3144
|
+
const checkout = await startGiftProviderCheckout(
|
|
3145
|
+
input.stripeSecret,
|
|
3146
|
+
stored,
|
|
3147
|
+
input.purchaserEmail,
|
|
3148
|
+
typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
|
|
3149
|
+
);
|
|
3150
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
|
|
3151
|
+
status: "checkout_ready",
|
|
3152
|
+
providerObjectId: checkout.providerObjectId,
|
|
3153
|
+
...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
|
|
3154
|
+
checkoutStartedAt: input.now,
|
|
3155
|
+
updatedAt: input.now
|
|
3156
|
+
} }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
|
|
3157
|
+
return {
|
|
3158
|
+
gift: true,
|
|
3159
|
+
intentId: input.id,
|
|
3160
|
+
clientSecret: checkout.clientSecret,
|
|
3161
|
+
publishableKey: input.publishableKey,
|
|
3162
|
+
lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
|
|
3163
|
+
...giftCode ? { giftCode } : {}
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
|
|
2217
3167
|
// src/worker-routes-payments.ts
|
|
2218
3168
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2219
3169
|
async function firstRow2(db, ns, q) {
|
|
2220
3170
|
const res = await db.query({ [ns]: { $: q } });
|
|
2221
|
-
const
|
|
2222
|
-
return Array.isArray(
|
|
3171
|
+
const rows4 = res[ns];
|
|
3172
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2223
3173
|
}
|
|
2224
3174
|
function lineItems(group) {
|
|
2225
3175
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2304,6 +3254,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2304
3254
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2305
3255
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2306
3256
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3257
|
+
if (typeof app.giftOfferId === "string") {
|
|
3258
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3259
|
+
const offer = findGiftOffer(control?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3260
|
+
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3261
|
+
return json({ error: "gift offer is unavailable" }, 409);
|
|
3262
|
+
}
|
|
3263
|
+
try {
|
|
3264
|
+
const started = await startGiftIntent(db, {
|
|
3265
|
+
id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
|
|
3266
|
+
chapterId: ctx.chapter.id,
|
|
3267
|
+
environment: env.ODLA_ENV,
|
|
3268
|
+
offer,
|
|
3269
|
+
purchaserType: "application",
|
|
3270
|
+
purchaserEmail: String(app.email ?? ""),
|
|
3271
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
|
|
3272
|
+
purchaserApplicationId: applicationId,
|
|
3273
|
+
stripeSecret: secretKey,
|
|
3274
|
+
publishableKey: group.stripePublishableKey,
|
|
3275
|
+
now: Date.now()
|
|
3276
|
+
});
|
|
3277
|
+
await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
|
|
3278
|
+
giftIntentId: started.intentId,
|
|
3279
|
+
giftCheckoutStartedAt: Date.now(),
|
|
3280
|
+
refundPolicyAckAt: Date.now()
|
|
3281
|
+
} }]);
|
|
3282
|
+
return json(started);
|
|
3283
|
+
} catch (error) {
|
|
3284
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
2307
3287
|
const tier = findTier(
|
|
2308
3288
|
await loadTiers(db, group),
|
|
2309
3289
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2345,17 +3325,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2345
3325
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2346
3326
|
const { eventId, event } = ingest;
|
|
2347
3327
|
if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
|
|
3328
|
+
if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
|
|
3329
|
+
const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
|
|
3330
|
+
if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
|
|
3331
|
+
return json({ ok: true, matched: false });
|
|
3332
|
+
}
|
|
3333
|
+
if (event.kind === "gift_paid") {
|
|
3334
|
+
if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
|
|
3335
|
+
if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
|
|
3336
|
+
try {
|
|
3337
|
+
const issued = await issueGiftClaim(db, intent, {
|
|
3338
|
+
eventId,
|
|
3339
|
+
objectId: event.providerObjectId
|
|
3340
|
+
}, Date.now());
|
|
3341
|
+
return json({ ok: true, matched: true, claimId: issued.claimId });
|
|
3342
|
+
} catch {
|
|
3343
|
+
return json({ error: "gift issuance failed" }, 503);
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
|
|
3347
|
+
status: event.reason,
|
|
3348
|
+
updatedAt: Date.now()
|
|
3349
|
+
} }], { mutationId: `gift-revoked-intent:${eventId}` });
|
|
3350
|
+
const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
|
|
3351
|
+
if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
|
|
3352
|
+
return json({ ok: true, matched: Boolean(claim) });
|
|
3353
|
+
}
|
|
2348
3354
|
const app = await findApplication(db, event);
|
|
2349
3355
|
if (!app) return json({ ok: true, matched: false });
|
|
2350
3356
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3357
|
+
let next = app;
|
|
2351
3358
|
if (Object.keys(patch).length) {
|
|
2352
3359
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2353
|
-
|
|
3360
|
+
next = { ...app, ...patch };
|
|
2354
3361
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2355
3362
|
}
|
|
2356
3363
|
if (event.kind === "first_payment") {
|
|
2357
3364
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2358
3365
|
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
3366
|
+
if (typeof next.interviewWaivedAt === "number") {
|
|
3367
|
+
await approveApplication({
|
|
3368
|
+
db,
|
|
3369
|
+
env,
|
|
3370
|
+
ctx,
|
|
3371
|
+
app: next,
|
|
3372
|
+
origin: new URL(req.url).origin,
|
|
3373
|
+
reason: "interview_waiver"
|
|
3374
|
+
});
|
|
3375
|
+
}
|
|
2359
3376
|
}
|
|
2360
3377
|
return json({ ok: true });
|
|
2361
3378
|
}
|
|
@@ -2366,6 +3383,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2366
3383
|
return null;
|
|
2367
3384
|
};
|
|
2368
3385
|
|
|
3386
|
+
// src/worker-routes-gifts.ts
|
|
3387
|
+
var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3388
|
+
var rows2 = (value) => Array.isArray(value) ? value : [];
|
|
3389
|
+
var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
|
|
3390
|
+
var userFor = async (req, env, ctx) => {
|
|
3391
|
+
const user = await ctx.verifyUser(req, env);
|
|
3392
|
+
return user?.email ? user : null;
|
|
3393
|
+
};
|
|
3394
|
+
var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
|
|
3395
|
+
async function consumeRateLimit(db, chapterId, requester, now) {
|
|
3396
|
+
const bucket = await giftRequesterBucket(chapterId, requester);
|
|
3397
|
+
const id = `gift-rate:${chapterId}:${bucket}`;
|
|
3398
|
+
const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
|
|
3399
|
+
const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
|
|
3400
|
+
const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
|
|
3401
|
+
if (attemptCount > 10) return false;
|
|
3402
|
+
await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
|
|
3403
|
+
id,
|
|
3404
|
+
chapterId,
|
|
3405
|
+
bucket,
|
|
3406
|
+
attemptCount,
|
|
3407
|
+
windowStartedAt: within ? Number(current?.windowStartedAt) : now,
|
|
3408
|
+
updatedAt: now
|
|
3409
|
+
} }]);
|
|
3410
|
+
return true;
|
|
3411
|
+
}
|
|
3412
|
+
var handleGifts = async (req, url, env, ctx) => {
|
|
3413
|
+
if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
|
|
3414
|
+
const db = ctx.makeDb(env);
|
|
3415
|
+
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3416
|
+
const user = await userFor(req, env, ctx);
|
|
3417
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3418
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3419
|
+
const found = await db.query({ giftClaims: { $: { where: {
|
|
3420
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
|
|
3421
|
+
chapterId: ctx.chapter.id
|
|
3422
|
+
}, limit: 200 } } });
|
|
3423
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3424
|
+
return json({
|
|
3425
|
+
offers: publicGiftOffers(control?.document.giftOffers, Date.now()),
|
|
3426
|
+
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
3427
|
+
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
3428
|
+
});
|
|
3429
|
+
}
|
|
3430
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
|
|
3431
|
+
const user = await userFor(req, env, ctx);
|
|
3432
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3433
|
+
let body;
|
|
3434
|
+
try {
|
|
3435
|
+
body = JSON.parse(await req.text());
|
|
3436
|
+
} catch {
|
|
3437
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3438
|
+
}
|
|
3439
|
+
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
3440
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
3441
|
+
const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3442
|
+
const offer = findGiftOffer(control?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
3443
|
+
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
3444
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3445
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
3446
|
+
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
3447
|
+
const emailNormalized = normalizeAdmissionEmail(user.email);
|
|
3448
|
+
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
3449
|
+
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3450
|
+
try {
|
|
3451
|
+
return json(await startGiftIntent(db, {
|
|
3452
|
+
id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
|
|
3453
|
+
chapterId: ctx.chapter.id,
|
|
3454
|
+
environment: env.ODLA_ENV,
|
|
3455
|
+
offer,
|
|
3456
|
+
purchaserType: "member",
|
|
3457
|
+
purchaserEmail: user.email,
|
|
3458
|
+
purchaserEmailNormalized: emailNormalized,
|
|
3459
|
+
purchaserUserId: user.userId,
|
|
3460
|
+
...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
|
|
3461
|
+
stripeSecret,
|
|
3462
|
+
publishableKey: group.stripePublishableKey,
|
|
3463
|
+
now: Date.now()
|
|
3464
|
+
}));
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
|
|
3470
|
+
if (req.method === "POST" && rotateMatch) {
|
|
3471
|
+
let claimId = "";
|
|
3472
|
+
try {
|
|
3473
|
+
claimId = decodeURIComponent(rotateMatch[1]);
|
|
3474
|
+
} catch {
|
|
3475
|
+
return invalid();
|
|
3476
|
+
}
|
|
3477
|
+
if (!claimId.startsWith("gift-claim:")) return invalid();
|
|
3478
|
+
const claim = await loadClaim(db, claimId);
|
|
3479
|
+
if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
|
|
3480
|
+
const user = await userFor(req, env, ctx);
|
|
3481
|
+
let applicationId = "";
|
|
3482
|
+
if (!user) {
|
|
3483
|
+
try {
|
|
3484
|
+
applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
|
|
3485
|
+
} catch {
|
|
3486
|
+
return invalid();
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
3490
|
+
if (!owns) return invalid();
|
|
3491
|
+
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
3492
|
+
return rotated ? json(rotated) : invalid();
|
|
3493
|
+
}
|
|
3494
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
|
|
3495
|
+
const user = await userFor(req, env, ctx);
|
|
3496
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3497
|
+
const now = Date.now();
|
|
3498
|
+
if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
|
|
3499
|
+
return json({ error: "too many attempts" }, 429);
|
|
3500
|
+
}
|
|
3501
|
+
let code = "";
|
|
3502
|
+
try {
|
|
3503
|
+
code = String(JSON.parse(await req.text()).code ?? "").trim();
|
|
3504
|
+
} catch {
|
|
3505
|
+
return invalid();
|
|
3506
|
+
}
|
|
3507
|
+
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
|
|
3508
|
+
const codeHash = await hashGiftCode(ctx.chapter.id, code);
|
|
3509
|
+
const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
|
|
3510
|
+
if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
|
|
3511
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
|
|
3512
|
+
await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3513
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1,
|
|
3514
|
+
...claim.status === "pending" ? { status: "expired" } : {}
|
|
3515
|
+
} }]).catch(() => void 0);
|
|
3516
|
+
return invalid();
|
|
3517
|
+
}
|
|
3518
|
+
const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
|
|
3519
|
+
return redeemed ? json({ ok: true, ...redeemed }) : invalid();
|
|
3520
|
+
}
|
|
3521
|
+
return null;
|
|
3522
|
+
};
|
|
3523
|
+
|
|
2369
3524
|
// src/worker-routes-admin.ts
|
|
2370
3525
|
import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
|
|
2371
3526
|
|
|
@@ -2437,8 +3592,8 @@ async function upcomingEvents(env) {
|
|
|
2437
3592
|
const res = await cal.availability.upcoming();
|
|
2438
3593
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2439
3594
|
}
|
|
2440
|
-
function toReconcile(
|
|
2441
|
-
return
|
|
3595
|
+
function toReconcile(rows4) {
|
|
3596
|
+
return rows4.map((m) => ({
|
|
2442
3597
|
id: String(m.id),
|
|
2443
3598
|
applicationId: String(m.applicationId),
|
|
2444
3599
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2460,12 +3615,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2460
3615
|
const query = await db.query({
|
|
2461
3616
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2462
3617
|
});
|
|
2463
|
-
let
|
|
2464
|
-
if (Number.isFinite(from))
|
|
2465
|
-
if (Number.isFinite(to))
|
|
3618
|
+
let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
|
|
3619
|
+
if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
|
|
3620
|
+
if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
|
|
2466
3621
|
let decisions = [];
|
|
2467
3622
|
try {
|
|
2468
|
-
decisions = reconcileMeetings(toReconcile(
|
|
3623
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2469
3624
|
} catch {
|
|
2470
3625
|
}
|
|
2471
3626
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2492,69 +3647,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2492
3647
|
};
|
|
2493
3648
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2494
3649
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2495
|
-
const meetings =
|
|
3650
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2496
3651
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2497
3652
|
};
|
|
2498
3653
|
|
|
2499
|
-
// src/clerk-roles.ts
|
|
2500
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2501
|
-
var DEFAULT_ROLE = "provisional";
|
|
2502
|
-
var PAGE = 100;
|
|
2503
|
-
function toRecord(u) {
|
|
2504
|
-
if (typeof u.id !== "string") return null;
|
|
2505
|
-
const pm = u.public_metadata ?? {};
|
|
2506
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2507
|
-
const rawProfile = privateMetadata.profile;
|
|
2508
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2509
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2510
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2511
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2512
|
-
return {
|
|
2513
|
-
id: u.id,
|
|
2514
|
-
email: typeof email === "string" ? email : void 0,
|
|
2515
|
-
role,
|
|
2516
|
-
publicMetadata: pm,
|
|
2517
|
-
privateMetadata,
|
|
2518
|
-
profile,
|
|
2519
|
-
...applicationId ? { applicationId } : {}
|
|
2520
|
-
};
|
|
2521
|
-
}
|
|
2522
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2523
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2524
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2525
|
-
return res.json();
|
|
2526
|
-
}
|
|
2527
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2528
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2529
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2530
|
-
return user ? toRecord(user) : null;
|
|
2531
|
-
}
|
|
2532
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2533
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2534
|
-
return data ? toRecord(data) : null;
|
|
2535
|
-
}
|
|
2536
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2537
|
-
const out = [];
|
|
2538
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2539
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2540
|
-
const page = Array.isArray(data) ? data : [];
|
|
2541
|
-
for (const u of page) {
|
|
2542
|
-
const record = toRecord(u);
|
|
2543
|
-
if (record) out.push(record);
|
|
2544
|
-
}
|
|
2545
|
-
if (page.length < PAGE) break;
|
|
2546
|
-
}
|
|
2547
|
-
return out;
|
|
2548
|
-
}
|
|
2549
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2550
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2551
|
-
method: "PATCH",
|
|
2552
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2553
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2554
|
-
});
|
|
2555
|
-
return res.ok;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
3654
|
// src/worker-routes-admin-people.ts
|
|
2559
3655
|
async function adminGate(req, env, ctx) {
|
|
2560
3656
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2572,16 +3668,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2572
3668
|
});
|
|
2573
3669
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2574
3670
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2575
|
-
const
|
|
2576
|
-
if (
|
|
2577
|
-
const result = await backfillCrm(crmDeps2(
|
|
3671
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3672
|
+
if (gate9 instanceof Response) return gate9;
|
|
3673
|
+
const result = await backfillCrm(crmDeps2(gate9.db, ctx));
|
|
2578
3674
|
return json({ ok: true, ...result });
|
|
2579
3675
|
};
|
|
2580
3676
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2581
3677
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2582
|
-
const
|
|
2583
|
-
if (
|
|
2584
|
-
const { db } =
|
|
3678
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3679
|
+
if (gate9 instanceof Response) return gate9;
|
|
3680
|
+
const { db } = gate9;
|
|
2585
3681
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2586
3682
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2587
3683
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2625,16 +3721,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2625
3721
|
});
|
|
2626
3722
|
}
|
|
2627
3723
|
}
|
|
2628
|
-
const
|
|
3724
|
+
const rows4 = [...people.values()].sort(
|
|
2629
3725
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2630
3726
|
);
|
|
2631
|
-
return json({ people:
|
|
3727
|
+
return json({ people: rows4 });
|
|
2632
3728
|
};
|
|
2633
3729
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2634
3730
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2635
|
-
const
|
|
2636
|
-
if (
|
|
2637
|
-
const { db } =
|
|
3731
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3732
|
+
if (gate9 instanceof Response) return gate9;
|
|
3733
|
+
const { db } = gate9;
|
|
2638
3734
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2639
3735
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2640
3736
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2645,9 +3741,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2645
3741
|
};
|
|
2646
3742
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2647
3743
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2648
|
-
const
|
|
2649
|
-
if (
|
|
2650
|
-
const { db, actor } =
|
|
3744
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3745
|
+
if (gate9 instanceof Response) return gate9;
|
|
3746
|
+
const { db, actor } = gate9;
|
|
2651
3747
|
let body;
|
|
2652
3748
|
try {
|
|
2653
3749
|
body = await req.json();
|
|
@@ -2794,17 +3890,17 @@ function subAnnualCents(sub) {
|
|
|
2794
3890
|
}
|
|
2795
3891
|
|
|
2796
3892
|
// src/worker-routes-admin-dashboard.ts
|
|
2797
|
-
async function
|
|
3893
|
+
async function gate2(req, env, ctx) {
|
|
2798
3894
|
const rawDb = ctx.makeDb(env);
|
|
2799
3895
|
const u = await ctx.verifyUser(req, env);
|
|
2800
3896
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2801
3897
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2802
3898
|
return rawDb;
|
|
2803
3899
|
}
|
|
2804
|
-
var
|
|
3900
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2805
3901
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2806
3902
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2807
|
-
const got = await
|
|
3903
|
+
const got = await gate2(req, env, ctx);
|
|
2808
3904
|
if (got instanceof Response) return got;
|
|
2809
3905
|
const db = got;
|
|
2810
3906
|
const now = Date.now();
|
|
@@ -2816,7 +3912,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2816
3912
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2817
3913
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2818
3914
|
]);
|
|
2819
|
-
const apps =
|
|
3915
|
+
const apps = rows3(appsRes.applications);
|
|
2820
3916
|
const applications = {
|
|
2821
3917
|
total: apps.length,
|
|
2822
3918
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2828,7 +3924,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2828
3924
|
pipeline[s] = 0;
|
|
2829
3925
|
pipelineDelta[s] = 0;
|
|
2830
3926
|
}
|
|
2831
|
-
for (const r of
|
|
3927
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2832
3928
|
const s = r.stage;
|
|
2833
3929
|
if (s in pipeline) {
|
|
2834
3930
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2836,7 +3932,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2836
3932
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2837
3933
|
}
|
|
2838
3934
|
}
|
|
2839
|
-
const meetings =
|
|
3935
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2840
3936
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2841
3937
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2842
3938
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2861,7 +3957,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2861
3957
|
if (sk) {
|
|
2862
3958
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2863
3959
|
if (subsRes.ok) {
|
|
2864
|
-
const subs =
|
|
3960
|
+
const subs = rows3(subsRes.body.data);
|
|
2865
3961
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2866
3962
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2867
3963
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2881,7 +3977,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2881
3977
|
};
|
|
2882
3978
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2883
3979
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2884
|
-
const got = await
|
|
3980
|
+
const got = await gate2(req, env, ctx);
|
|
2885
3981
|
if (got instanceof Response) return got;
|
|
2886
3982
|
const db = got;
|
|
2887
3983
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2893,9 +3989,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2893
3989
|
]);
|
|
2894
3990
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2895
3991
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2896
|
-
const subById = new Map(
|
|
3992
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2897
3993
|
const billingRows = [];
|
|
2898
|
-
for (const a of
|
|
3994
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2899
3995
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2900
3996
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2901
3997
|
const items = sub?.items?.data ?? [];
|
|
@@ -2939,7 +4035,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2939
4035
|
|
|
2940
4036
|
// src/worker-routes-admin-lifecycle.ts
|
|
2941
4037
|
import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
|
|
2942
|
-
async function
|
|
4038
|
+
async function gate3(req, env, ctx) {
|
|
2943
4039
|
const rawDb = ctx.makeDb(env);
|
|
2944
4040
|
const u = await ctx.verifyUser(req, env);
|
|
2945
4041
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2965,7 +4061,7 @@ async function loadGroup(db, id) {
|
|
|
2965
4061
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2966
4062
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2967
4063
|
if (req.method !== "POST" || !m) return null;
|
|
2968
|
-
const got = await
|
|
4064
|
+
const got = await gate3(req, env, ctx);
|
|
2969
4065
|
if (got instanceof Response) return got;
|
|
2970
4066
|
const db = got;
|
|
2971
4067
|
const body = await readJson(req);
|
|
@@ -3006,7 +4102,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3006
4102
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3007
4103
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3008
4104
|
if (req.method !== "POST" || !m) return null;
|
|
3009
|
-
const got = await
|
|
4105
|
+
const got = await gate3(req, env, ctx);
|
|
3010
4106
|
if (got instanceof Response) return got;
|
|
3011
4107
|
const db = got;
|
|
3012
4108
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3029,43 +4125,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3029
4125
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3030
4126
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3031
4127
|
if (req.method !== "POST" || !m) return null;
|
|
3032
|
-
const got = await
|
|
4128
|
+
const got = await gate3(req, env, ctx);
|
|
3033
4129
|
if (got instanceof Response) return got;
|
|
3034
4130
|
const db = got;
|
|
3035
4131
|
const id = m[1];
|
|
3036
4132
|
const app = await loadApp(db, id);
|
|
3037
4133
|
if (!app) return json({ error: "not found" }, 404);
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3042
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3043
|
-
let rolePromoted = false;
|
|
3044
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3045
|
-
if (promoteTo !== false && sk) {
|
|
3046
|
-
let userId = app.clerkUserId || null;
|
|
3047
|
-
if (!userId) {
|
|
3048
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3049
|
-
userId = found?.id ?? null;
|
|
3050
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3051
|
-
}
|
|
3052
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3053
|
-
}
|
|
3054
|
-
let emailLogged = false;
|
|
3055
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3056
|
-
if (send !== false && group) {
|
|
3057
|
-
const res = await sendTemplated(
|
|
3058
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3059
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3060
|
-
);
|
|
3061
|
-
emailLogged = res.sent;
|
|
3062
|
-
}
|
|
3063
|
-
return json({ ok: true, status: target, rolePromoted, emailLogged });
|
|
4134
|
+
const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
|
|
4135
|
+
if (!result.ok) return json({ error: result.error }, result.status);
|
|
4136
|
+
return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
|
|
3064
4137
|
};
|
|
3065
4138
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3066
4139
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3067
4140
|
if (req.method !== "POST" || !m) return null;
|
|
3068
|
-
const got = await
|
|
4141
|
+
const got = await gate3(req, env, ctx);
|
|
3069
4142
|
if (got instanceof Response) return got;
|
|
3070
4143
|
const db = got;
|
|
3071
4144
|
const app = await loadApp(db, m[1]);
|
|
@@ -3097,7 +4170,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3097
4170
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3098
4171
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3099
4172
|
if (req.method !== "PATCH" || !m) return null;
|
|
3100
|
-
const got = await
|
|
4173
|
+
const got = await gate3(req, env, ctx);
|
|
3101
4174
|
if (got instanceof Response) return got;
|
|
3102
4175
|
const db = got;
|
|
3103
4176
|
const id = m[1];
|
|
@@ -3125,7 +4198,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3125
4198
|
};
|
|
3126
4199
|
|
|
3127
4200
|
// src/worker-routes-admin-comms.ts
|
|
3128
|
-
async function
|
|
4201
|
+
async function gate4(req, env, ctx) {
|
|
3129
4202
|
const rawDb = ctx.makeDb(env);
|
|
3130
4203
|
const u = await ctx.verifyUser(req, env);
|
|
3131
4204
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3147,7 +4220,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3147
4220
|
});
|
|
3148
4221
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3149
4222
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3150
|
-
const got = await
|
|
4223
|
+
const got = await gate4(req, env, ctx);
|
|
3151
4224
|
if (got instanceof Response) return got;
|
|
3152
4225
|
const db = got;
|
|
3153
4226
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3188,11 +4261,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3188
4261
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3189
4262
|
const t = templates[key];
|
|
3190
4263
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3191
|
-
const
|
|
3192
|
-
if (!subject || !
|
|
4264
|
+
const text3 = typeof t?.text === "string" ? t.text : "";
|
|
4265
|
+
if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
|
|
3193
4266
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3194
|
-
if (
|
|
3195
|
-
clean[key] = { subject, text:
|
|
4267
|
+
if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
|
|
4268
|
+
clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
|
|
3196
4269
|
}
|
|
3197
4270
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3198
4271
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3209,7 +4282,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3209
4282
|
};
|
|
3210
4283
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3211
4284
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3212
|
-
const got = await
|
|
4285
|
+
const got = await gate4(req, env, ctx);
|
|
3213
4286
|
if (got instanceof Response) return got;
|
|
3214
4287
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3215
4288
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3226,7 +4299,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3226
4299
|
};
|
|
3227
4300
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3228
4301
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3229
|
-
const got = await
|
|
4302
|
+
const got = await gate4(req, env, ctx);
|
|
3230
4303
|
if (got instanceof Response) return got;
|
|
3231
4304
|
const db = got;
|
|
3232
4305
|
let body;
|
|
@@ -3254,7 +4327,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3254
4327
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3255
4328
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3256
4329
|
if (req.method !== "GET" || !m) return null;
|
|
3257
|
-
const got = await
|
|
4330
|
+
const got = await gate4(req, env, ctx);
|
|
3258
4331
|
if (got instanceof Response) return got;
|
|
3259
4332
|
const db = got;
|
|
3260
4333
|
const appId = m[1];
|
|
@@ -3287,7 +4360,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3287
4360
|
|
|
3288
4361
|
// src/worker-routes-network.ts
|
|
3289
4362
|
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
3290
|
-
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as
|
|
4363
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
|
|
3291
4364
|
|
|
3292
4365
|
// src/worker-network-fetch.ts
|
|
3293
4366
|
function targetFetcher(env, target) {
|
|
@@ -3306,7 +4379,7 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3306
4379
|
// src/worker-network-push.ts
|
|
3307
4380
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
3308
4381
|
import { signFederatedRequest } from "@odla-ai/db";
|
|
3309
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4382
|
+
async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
3310
4383
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3311
4384
|
const crmDeps3 = {
|
|
3312
4385
|
crm: ctx.chapter.crm,
|
|
@@ -3317,7 +4390,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3317
4390
|
if (!secret) {
|
|
3318
4391
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3319
4392
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3320
|
-
recordId:
|
|
4393
|
+
recordId: record2.id,
|
|
3321
4394
|
targetId: target.id,
|
|
3322
4395
|
status: "failed",
|
|
3323
4396
|
payloadVersion: 2,
|
|
@@ -3327,7 +4400,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3327
4400
|
}
|
|
3328
4401
|
let payload;
|
|
3329
4402
|
try {
|
|
3330
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4403
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
|
|
3331
4404
|
} catch (err) {
|
|
3332
4405
|
return {
|
|
3333
4406
|
id: target.id,
|
|
@@ -3356,7 +4429,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3356
4429
|
if (!res.ok) {
|
|
3357
4430
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3358
4431
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3359
|
-
recordId:
|
|
4432
|
+
recordId: record2.id,
|
|
3360
4433
|
targetId: target.id,
|
|
3361
4434
|
status: "failed",
|
|
3362
4435
|
payloadVersion: 2,
|
|
@@ -3371,7 +4444,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3371
4444
|
};
|
|
3372
4445
|
}
|
|
3373
4446
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3374
|
-
recordId:
|
|
4447
|
+
recordId: record2.id,
|
|
3375
4448
|
targetId: target.id,
|
|
3376
4449
|
status: "delivered",
|
|
3377
4450
|
payloadVersion: 2,
|
|
@@ -3387,7 +4460,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3387
4460
|
} catch (err) {
|
|
3388
4461
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3389
4462
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3390
|
-
recordId:
|
|
4463
|
+
recordId: record2.id,
|
|
3391
4464
|
targetId: target.id,
|
|
3392
4465
|
status: "failed",
|
|
3393
4466
|
payloadVersion: 2,
|
|
@@ -3398,7 +4471,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3398
4471
|
}
|
|
3399
4472
|
|
|
3400
4473
|
// src/worker-routes-network.ts
|
|
3401
|
-
async function
|
|
4474
|
+
async function gate5(req, env, ctx) {
|
|
3402
4475
|
const db = ctx.makeDb(env);
|
|
3403
4476
|
const user = await ctx.verifyUser(req, env);
|
|
3404
4477
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3407,7 +4480,7 @@ async function gate4(req, env, ctx) {
|
|
|
3407
4480
|
}
|
|
3408
4481
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3409
4482
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3410
|
-
const got = await
|
|
4483
|
+
const got = await gate5(req, env, ctx);
|
|
3411
4484
|
if ("response" in got) return got.response;
|
|
3412
4485
|
return json({
|
|
3413
4486
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3424,7 +4497,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3424
4497
|
const db = ctx.makeDb(env);
|
|
3425
4498
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3426
4499
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3427
|
-
const verified = await
|
|
4500
|
+
const verified = await verifyFederatedRequest4(req, { secret });
|
|
3428
4501
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3429
4502
|
const types = await Promise.all(
|
|
3430
4503
|
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3483,7 +4556,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3483
4556
|
}
|
|
3484
4557
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3485
4558
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3486
|
-
const got = await
|
|
4559
|
+
const got = await gate5(req, env, ctx);
|
|
3487
4560
|
if ("response" in got) return got.response;
|
|
3488
4561
|
const targets = await Promise.all(
|
|
3489
4562
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3510,7 +4583,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3510
4583
|
};
|
|
3511
4584
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3512
4585
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3513
|
-
const got = await
|
|
4586
|
+
const got = await gate5(req, env, ctx);
|
|
3514
4587
|
if ("response" in got) return got.response;
|
|
3515
4588
|
let body;
|
|
3516
4589
|
try {
|
|
@@ -3525,22 +4598,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3525
4598
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3526
4599
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3527
4600
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3528
|
-
const
|
|
3529
|
-
if (!
|
|
4601
|
+
const record2 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
4602
|
+
if (!record2) return json({ error: "record not found" }, 404);
|
|
3530
4603
|
const results = await Promise.all(
|
|
3531
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
4604
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
|
|
3532
4605
|
);
|
|
3533
4606
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3534
4607
|
};
|
|
3535
4608
|
|
|
3536
4609
|
// src/worker-routes-network-records.ts
|
|
3537
4610
|
import { listRecords } from "@odla-ai/crm";
|
|
3538
|
-
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as
|
|
4611
|
+
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
3539
4612
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3540
4613
|
var MAX_PAGE = 50;
|
|
3541
4614
|
var MAX_OFFSET = 1e4;
|
|
3542
4615
|
var MAX_SEARCH = 200;
|
|
3543
|
-
async function
|
|
4616
|
+
async function gate6(req, env, ctx) {
|
|
3544
4617
|
const db = ctx.makeDb(env);
|
|
3545
4618
|
const user = await ctx.verifyUser(req, env);
|
|
3546
4619
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3553,18 +4626,18 @@ function integer(value, fallback, min, max) {
|
|
|
3553
4626
|
const parsed = Number(value);
|
|
3554
4627
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3555
4628
|
}
|
|
3556
|
-
function projectRecord(
|
|
4629
|
+
function projectRecord(record2, allowed) {
|
|
3557
4630
|
const fields = Object.fromEntries(
|
|
3558
|
-
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(
|
|
4631
|
+
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
|
|
3559
4632
|
);
|
|
3560
4633
|
return {
|
|
3561
|
-
id:
|
|
3562
|
-
type:
|
|
3563
|
-
name:
|
|
3564
|
-
...
|
|
4634
|
+
id: record2.id,
|
|
4635
|
+
type: record2.type,
|
|
4636
|
+
name: record2.name,
|
|
4637
|
+
...record2.stage ? { stage: record2.stage } : {},
|
|
3565
4638
|
fields,
|
|
3566
|
-
createdAt:
|
|
3567
|
-
updatedAt:
|
|
4639
|
+
createdAt: record2.createdAt,
|
|
4640
|
+
updatedAt: record2.updatedAt
|
|
3568
4641
|
};
|
|
3569
4642
|
}
|
|
3570
4643
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3573,7 +4646,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3573
4646
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3574
4647
|
const readers = ctx.chapter.network.readers;
|
|
3575
4648
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3576
|
-
const verified = await
|
|
4649
|
+
const verified = await verifyFederatedRequest5(req, {
|
|
3577
4650
|
secret,
|
|
3578
4651
|
senders: readers.map((reader2) => reader2.id)
|
|
3579
4652
|
});
|
|
@@ -3602,7 +4675,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3602
4675
|
version: 1,
|
|
3603
4676
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3604
4677
|
type,
|
|
3605
|
-
records: page.records.map((
|
|
4678
|
+
records: page.records.map((record2) => projectRecord(record2, allowed)),
|
|
3606
4679
|
total: page.total,
|
|
3607
4680
|
limit: page.limit,
|
|
3608
4681
|
offset: page.offset
|
|
@@ -3643,7 +4716,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3643
4716
|
}
|
|
3644
4717
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3645
4718
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3646
|
-
const got = await
|
|
4719
|
+
const got = await gate6(req, env, ctx);
|
|
3647
4720
|
if ("response" in got) return got.response;
|
|
3648
4721
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3649
4722
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3670,8 +4743,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3670
4743
|
// src/worker-routes-network-notes.ts
|
|
3671
4744
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3672
4745
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3673
|
-
var
|
|
3674
|
-
async function
|
|
4746
|
+
var MAX_NOTE2 = 4e3;
|
|
4747
|
+
async function gate7(req, env, ctx) {
|
|
3675
4748
|
const db = ctx.makeDb(env);
|
|
3676
4749
|
const user = await ctx.verifyUser(req, env);
|
|
3677
4750
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3705,7 +4778,7 @@ function asNote(row) {
|
|
|
3705
4778
|
}
|
|
3706
4779
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3707
4780
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3708
|
-
const got = await
|
|
4781
|
+
const got = await gate7(req, env, ctx);
|
|
3709
4782
|
if ("response" in got) return got.response;
|
|
3710
4783
|
const identity = noteIdentity(url, ctx);
|
|
3711
4784
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3722,7 +4795,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3722
4795
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3723
4796
|
}
|
|
3724
4797
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3725
|
-
if (contentLength >
|
|
4798
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3726
4799
|
let input;
|
|
3727
4800
|
try {
|
|
3728
4801
|
input = await req.json();
|
|
@@ -3730,7 +4803,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3730
4803
|
return json({ error: "invalid JSON body" }, 400);
|
|
3731
4804
|
}
|
|
3732
4805
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3733
|
-
if (!body || body.length >
|
|
4806
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3734
4807
|
const id = crypto.randomUUID();
|
|
3735
4808
|
const note = {
|
|
3736
4809
|
id,
|
|
@@ -3757,13 +4830,13 @@ import {
|
|
|
3757
4830
|
CRM_NS,
|
|
3758
4831
|
getRecord as getRecord2
|
|
3759
4832
|
} from "@odla-ai/crm";
|
|
3760
|
-
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as
|
|
4833
|
+
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
3761
4834
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3762
4835
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3763
4836
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3764
|
-
var
|
|
4837
|
+
var MAX_NOTE3 = 4e3;
|
|
3765
4838
|
var NETWORK_META = "chapterNetwork";
|
|
3766
|
-
async function
|
|
4839
|
+
async function gate8(req, env, ctx) {
|
|
3767
4840
|
const db = ctx.makeDb(env);
|
|
3768
4841
|
const user = await ctx.verifyUser(req, env);
|
|
3769
4842
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3809,14 +4882,14 @@ function sourceLabel(sourceId) {
|
|
|
3809
4882
|
}
|
|
3810
4883
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3811
4884
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3812
|
-
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) >
|
|
4885
|
+
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
|
|
3813
4886
|
return json({ error: "note is too large" }, 413);
|
|
3814
4887
|
}
|
|
3815
4888
|
const db = ctx.makeDb(env);
|
|
3816
4889
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3817
4890
|
const readers = ctx.chapter.network.readers;
|
|
3818
4891
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3819
|
-
const verified = await
|
|
4892
|
+
const verified = await verifyFederatedRequest6(req, {
|
|
3820
4893
|
secret,
|
|
3821
4894
|
senders: readers.map((reader2) => reader2.id)
|
|
3822
4895
|
});
|
|
@@ -3827,8 +4900,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3827
4900
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3828
4901
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3829
4902
|
}
|
|
3830
|
-
const
|
|
3831
|
-
if (!
|
|
4903
|
+
const record2 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
|
|
4904
|
+
if (!record2 || record2.type !== recordType) return json({ error: "record not found" }, 404);
|
|
3832
4905
|
if (req.method === "GET") {
|
|
3833
4906
|
return json({
|
|
3834
4907
|
version: 1,
|
|
@@ -3844,8 +4917,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3844
4917
|
}
|
|
3845
4918
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3846
4919
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3847
|
-
if (!body || body.length >
|
|
3848
|
-
return json({ error: `body must be 1\u2013${
|
|
4920
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
|
|
4921
|
+
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
3849
4922
|
}
|
|
3850
4923
|
const createdAt = Date.now();
|
|
3851
4924
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3887,7 +4960,7 @@ function targetFor2(ctx, id) {
|
|
|
3887
4960
|
}
|
|
3888
4961
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3889
4962
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3890
|
-
const got = await
|
|
4963
|
+
const got = await gate8(req, env, ctx);
|
|
3891
4964
|
if ("response" in got) return got.response;
|
|
3892
4965
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3893
4966
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3905,7 +4978,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3905
4978
|
return json({ error: "invalid JSON body" }, 400);
|
|
3906
4979
|
}
|
|
3907
4980
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3908
|
-
if (!note || note.length >
|
|
4981
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3909
4982
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3910
4983
|
}
|
|
3911
4984
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -4058,6 +5131,162 @@ async function deliverSignupControl(options) {
|
|
|
4058
5131
|
return result;
|
|
4059
5132
|
}
|
|
4060
5133
|
|
|
5134
|
+
// src/admission-grants-delivery.ts
|
|
5135
|
+
import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
|
|
5136
|
+
async function deliverAdmissionGrants(options) {
|
|
5137
|
+
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5138
|
+
const body = JSON.stringify(options.envelope);
|
|
5139
|
+
const signed = await signFederatedRequest6({
|
|
5140
|
+
secret: options.secret,
|
|
5141
|
+
sender: options.sender,
|
|
5142
|
+
method: "POST",
|
|
5143
|
+
url: destination,
|
|
5144
|
+
body
|
|
5145
|
+
});
|
|
5146
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5147
|
+
method: "POST",
|
|
5148
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5149
|
+
body
|
|
5150
|
+
});
|
|
5151
|
+
const result = await response.json();
|
|
5152
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5153
|
+
const failure = result;
|
|
5154
|
+
throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5155
|
+
}
|
|
5156
|
+
return result;
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
// src/signup-control-theme.ts
|
|
5160
|
+
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
5161
|
+
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
5162
|
+
var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
|
|
5163
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
5164
|
+
var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
|
|
5165
|
+
var MAX_TOKENS = 256;
|
|
5166
|
+
var MAX_VALUE_LENGTH = 1e3;
|
|
5167
|
+
var MAX_SERIALIZED_LENGTH = 65536;
|
|
5168
|
+
function validatedTokens(input) {
|
|
5169
|
+
const entries = Object.entries(input ?? {});
|
|
5170
|
+
if (entries.length > MAX_TOKENS) {
|
|
5171
|
+
throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
|
|
5172
|
+
}
|
|
5173
|
+
const tokens = {};
|
|
5174
|
+
let serializedLength = 0;
|
|
5175
|
+
for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
5176
|
+
if (!TOKEN_NAME.test(name)) {
|
|
5177
|
+
throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
|
|
5178
|
+
}
|
|
5179
|
+
if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
|
|
5180
|
+
throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
|
|
5181
|
+
}
|
|
5182
|
+
const value = String(raw).trim();
|
|
5183
|
+
if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
|
|
5184
|
+
throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
|
|
5185
|
+
}
|
|
5186
|
+
serializedLength += name.length + value.length + 4;
|
|
5187
|
+
if (serializedLength > MAX_SERIALIZED_LENGTH) {
|
|
5188
|
+
throw new TypeError("signup-control theme exceeds the serialized size bound");
|
|
5189
|
+
}
|
|
5190
|
+
tokens[name] = value;
|
|
5191
|
+
}
|
|
5192
|
+
return tokens;
|
|
5193
|
+
}
|
|
5194
|
+
function cssFor(tokens) {
|
|
5195
|
+
const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
|
|
5196
|
+
return declarations.length ? `:root {
|
|
5197
|
+
${declarations.join("\n")}
|
|
5198
|
+
}` : "";
|
|
5199
|
+
}
|
|
5200
|
+
function stateFor(revision, options) {
|
|
5201
|
+
if (!revision) {
|
|
5202
|
+
return {
|
|
5203
|
+
css: cssFor(validatedTokens(options.bootstrapTokens)),
|
|
5204
|
+
metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5207
|
+
if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
|
|
5208
|
+
throw new TypeError("signup-control theme revision metadata is invalid");
|
|
5209
|
+
}
|
|
5210
|
+
return {
|
|
5211
|
+
css: cssFor(validatedTokens(revision.theme?.tokens)),
|
|
5212
|
+
metadata: {
|
|
5213
|
+
source: "active",
|
|
5214
|
+
revision: revision.revision,
|
|
5215
|
+
digest: revision.digest,
|
|
5216
|
+
cacheKey: `signup-control:${revision.digest}`
|
|
5217
|
+
}
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
5220
|
+
function attributes(metadata) {
|
|
5221
|
+
return [
|
|
5222
|
+
`id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
|
|
5223
|
+
`data-signup-control-source="${metadata.source}"`,
|
|
5224
|
+
`data-signup-control-cache-key="${metadata.cacheKey}"`,
|
|
5225
|
+
...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
|
|
5226
|
+
...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
|
|
5227
|
+
].join(" ");
|
|
5228
|
+
}
|
|
5229
|
+
function renderSignupControlThemeHead(revision, options = {}) {
|
|
5230
|
+
const { css, metadata } = stateFor(revision, options);
|
|
5231
|
+
return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
|
|
5232
|
+
}
|
|
5233
|
+
function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
|
|
5234
|
+
const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
|
|
5235
|
+
const existingBlocks = html.match(existing) ?? [];
|
|
5236
|
+
if (existingBlocks.length > 1) {
|
|
5237
|
+
throw new TypeError("signup-control theme style block must appear at most once");
|
|
5238
|
+
}
|
|
5239
|
+
if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
|
|
5240
|
+
const markerIndex = html.indexOf(marker);
|
|
5241
|
+
if (markerIndex >= 0) {
|
|
5242
|
+
if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
|
|
5243
|
+
throw new TypeError("signup-control theme marker must appear exactly once");
|
|
5244
|
+
}
|
|
5245
|
+
return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
|
|
5246
|
+
}
|
|
5247
|
+
const closingHead = html.search(/<\/head\s*>/i);
|
|
5248
|
+
if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
|
|
5249
|
+
return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
// src/signup-control-theme-worker.ts
|
|
5253
|
+
async function activeTheme(options) {
|
|
5254
|
+
const active = await loadActiveSignupControl(options.db, options.chapterId, options.environment);
|
|
5255
|
+
if (!active) return null;
|
|
5256
|
+
const checked = validateSignupControlDocument(active.document);
|
|
5257
|
+
if (!checked.ok) throw new TypeError(`active signup-control document is invalid: ${checked.errors.join("; ")}`);
|
|
5258
|
+
const document = checked.value;
|
|
5259
|
+
if (document.revision !== active.revision || document.target.chapterId !== options.chapterId || document.target.environment !== options.environment || active.digest !== await signupControlDigest(document)) {
|
|
5260
|
+
throw new TypeError("active signup-control revision metadata or digest is invalid");
|
|
5261
|
+
}
|
|
5262
|
+
return { revision: active.revision, digest: active.digest, theme: document.theme };
|
|
5263
|
+
}
|
|
5264
|
+
async function renderActiveSignupControlHtml(html, options) {
|
|
5265
|
+
const theme = renderSignupControlThemeHead(await activeTheme(options), {
|
|
5266
|
+
bootstrapTokens: options.bootstrapTokens
|
|
5267
|
+
});
|
|
5268
|
+
return { html: injectSignupControlThemeHead(html, theme, options.marker), theme };
|
|
5269
|
+
}
|
|
5270
|
+
async function contentEtag(html) {
|
|
5271
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(html));
|
|
5272
|
+
const hex = Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5273
|
+
return `W/"sha256-${hex}"`;
|
|
5274
|
+
}
|
|
5275
|
+
async function withActiveSignupControlTheme(response, options) {
|
|
5276
|
+
if (!response.ok || !(response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")) {
|
|
5277
|
+
throw new TypeError("signup-control theme can only transform a successful HTML response");
|
|
5278
|
+
}
|
|
5279
|
+
const rendered = await renderActiveSignupControlHtml(await response.text(), options);
|
|
5280
|
+
const headers = new Headers(response.headers);
|
|
5281
|
+
headers.delete("content-length");
|
|
5282
|
+
headers.set("cache-control", "no-store");
|
|
5283
|
+
headers.set("etag", await contentEtag(rendered.html));
|
|
5284
|
+
headers.set("x-odla-signup-control-digest", rendered.theme.metadata.digest ?? "bootstrap");
|
|
5285
|
+
if (rendered.theme.metadata.revision === null) headers.delete("x-odla-signup-control-revision");
|
|
5286
|
+
else headers.set("x-odla-signup-control-revision", String(rendered.theme.metadata.revision));
|
|
5287
|
+
return new Response(rendered.html, { status: response.status, statusText: response.statusText, headers });
|
|
5288
|
+
}
|
|
5289
|
+
|
|
4061
5290
|
// src/worker.ts
|
|
4062
5291
|
var BUILTIN_ROUTES = [
|
|
4063
5292
|
handleHealth,
|
|
@@ -4066,11 +5295,13 @@ var BUILTIN_ROUTES = [
|
|
|
4066
5295
|
handleCrm,
|
|
4067
5296
|
handleNetworkShared,
|
|
4068
5297
|
handleSignupControl,
|
|
5298
|
+
handleAdmissionGrants,
|
|
4069
5299
|
handleNetworkSnapshot,
|
|
4070
5300
|
handleNetworkRecords,
|
|
4071
5301
|
handleNetworkSharedNotes,
|
|
4072
5302
|
handleFormation,
|
|
4073
5303
|
handleMember,
|
|
5304
|
+
handleGifts,
|
|
4074
5305
|
handleSchedule,
|
|
4075
5306
|
handlePayments,
|
|
4076
5307
|
handleAdminMeetings,
|
|
@@ -4080,6 +5311,7 @@ var BUILTIN_ROUTES = [
|
|
|
4080
5311
|
handleAdminPeopleAccess,
|
|
4081
5312
|
handleAdminPeopleRole,
|
|
4082
5313
|
handleAdminCrmSync,
|
|
5314
|
+
handleAdminAdmissionGrants,
|
|
4083
5315
|
handleAdminClerkPrivateProfileSync,
|
|
4084
5316
|
// Aggregation
|
|
4085
5317
|
handleAdminDashboard,
|
|
@@ -4124,6 +5356,9 @@ function chapterWorker(options) {
|
|
|
4124
5356
|
export {
|
|
4125
5357
|
chapterWorker,
|
|
4126
5358
|
createWorkerContext,
|
|
4127
|
-
|
|
5359
|
+
deliverAdmissionGrants,
|
|
5360
|
+
deliverSignupControl,
|
|
5361
|
+
renderActiveSignupControlHtml,
|
|
5362
|
+
withActiveSignupControlTheme
|
|
4128
5363
|
};
|
|
4129
5364
|
//# sourceMappingURL=index.js.map
|