@odla-ai/chapter 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-UJIVBGL5.js → chunk-I45VYBUV.js} +441 -239
- package/dist/chunk-I45VYBUV.js.map +1 -0
- package/dist/{chunk-JAWTIROV.js → chunk-IAWBE5PX.js} +55 -1
- package/dist/chunk-IAWBE5PX.js.map +1 -0
- package/dist/{chunk-TXE5ZDI4.js → chunk-LWM32REV.js} +166 -59
- package/dist/chunk-LWM32REV.js.map +1 -0
- package/dist/{copy-context-c1tqPXOR.d.ts → copy-context-0k0-yWHL.d.ts} +6 -2
- package/dist/index.cjs +843 -293
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +200 -3
- package/dist/index.d.ts +200 -3
- package/dist/index.js +843 -293
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +10 -3
- package/dist/ui/admin/index.js +6 -2
- package/dist/ui/index.d.ts +3 -3
- package/dist/ui/index.js +11 -3
- package/dist/ui/member/index.d.ts +86 -7
- package/dist/ui/member/index.js +6 -2
- package/dist/worker/index.cjs +1806 -710
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +102 -3
- package/dist/worker/index.d.ts +102 -3
- package/dist/worker/index.js +1824 -728
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-JAWTIROV.js.map +0 -1
- package/dist/chunk-TXE5ZDI4.js.map +0 -1
- package/dist/chunk-UJIVBGL5.js.map +0 -1
package/dist/worker/index.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
|
|
@@ -998,38 +1212,371 @@ async function sendTemplated(deps, input) {
|
|
|
998
1212
|
error = e instanceof Error ? e.message : String(e);
|
|
999
1213
|
}
|
|
1000
1214
|
}
|
|
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 } : {}
|
|
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
|
}
|
|
@@ -1751,9 +2285,9 @@ function validateSignupControlDocument(input) {
|
|
|
1751
2285
|
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1752
2286
|
const source = recordOf(root.source);
|
|
1753
2287
|
const target = recordOf(root.target);
|
|
1754
|
-
const appId =
|
|
1755
|
-
const chapterId =
|
|
1756
|
-
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);
|
|
1757
2291
|
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1758
2292
|
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1759
2293
|
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
@@ -1774,6 +2308,8 @@ function validateSignupControlDocument(input) {
|
|
|
1774
2308
|
else scheduling = root.scheduling;
|
|
1775
2309
|
}
|
|
1776
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);
|
|
1777
2313
|
if (errors.length || !page.ok) return { ok: false, errors };
|
|
1778
2314
|
return { ok: true, value: {
|
|
1779
2315
|
schemaVersion: 1,
|
|
@@ -1786,7 +2322,8 @@ function validateSignupControlDocument(input) {
|
|
|
1786
2322
|
...application ? { application } : {},
|
|
1787
2323
|
...policy ? { policy } : {},
|
|
1788
2324
|
...scheduling ? { scheduling } : {},
|
|
1789
|
-
...theme ? { theme } : {}
|
|
2325
|
+
...theme ? { theme } : {},
|
|
2326
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
1790
2327
|
} };
|
|
1791
2328
|
}
|
|
1792
2329
|
|
|
@@ -1847,92 +2384,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1847
2384
|
});
|
|
1848
2385
|
};
|
|
1849
2386
|
|
|
1850
|
-
// src/
|
|
1851
|
-
import {
|
|
1852
|
-
var
|
|
1853
|
-
var
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
});
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
hubRecordId: str(app.id)
|
|
1868
|
-
});
|
|
1869
|
-
for (const f of chapter.application.crmFields) {
|
|
1870
|
-
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"]);
|
|
1871
2404
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
if (
|
|
1882
|
-
if (
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
});
|
|
1893
|
-
const existing = crm_record?.[0] ?? null;
|
|
1894
|
-
const stage = opts.stage || void 0;
|
|
1895
|
-
let recordId;
|
|
1896
|
-
if (existing && typeof existing.id === "string") {
|
|
1897
|
-
recordId = existing.id;
|
|
1898
|
-
await updateRecord2(recordDeps, { id: recordId, input });
|
|
1899
|
-
} else {
|
|
1900
|
-
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1901
|
-
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
|
+
});
|
|
1902
2425
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
2426
|
+
const receivedAt = Date.now();
|
|
2427
|
+
try {
|
|
2428
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2429
|
+
} catch {
|
|
2430
|
+
return reject2("materialization_failed", 503);
|
|
1905
2431
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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;
|
|
2466
|
+
try {
|
|
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)) {
|
|
1922
2510
|
try {
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
2511
|
+
revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
2512
|
+
} catch {
|
|
2513
|
+
revokeId = "";
|
|
1927
2514
|
}
|
|
1928
|
-
};
|
|
1929
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1930
|
-
for (const u of usersRes.$users ?? []) {
|
|
1931
|
-
if (u.deleted === true) continue;
|
|
1932
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1933
2515
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
+
};
|
|
1936
2537
|
|
|
1937
2538
|
// src/worker-routes-schedule.ts
|
|
1938
2539
|
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
@@ -1948,8 +2549,8 @@ function makeCalendar(env) {
|
|
|
1948
2549
|
}
|
|
1949
2550
|
async function firstRow(db, ns, q) {
|
|
1950
2551
|
const res = await db.query({ [ns]: { $: q } });
|
|
1951
|
-
const
|
|
1952
|
-
return Array.isArray(
|
|
2552
|
+
const rows4 = res[ns];
|
|
2553
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1953
2554
|
}
|
|
1954
2555
|
async function computeSlots(cal, cfg) {
|
|
1955
2556
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2086,8 +2687,26 @@ function findApplicationRef(obj) {
|
|
|
2086
2687
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2087
2688
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2088
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
|
+
}
|
|
2089
2699
|
function normalizeWebhookEvent(event) {
|
|
2090
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
|
+
}
|
|
2091
2710
|
const ref = findApplicationRef(obj);
|
|
2092
2711
|
switch (event.type) {
|
|
2093
2712
|
case "invoice.paid": {
|
|
@@ -2188,9 +2807,9 @@ function createStripeProvider(config) {
|
|
|
2188
2807
|
`sub:${input.applicationId}`
|
|
2189
2808
|
);
|
|
2190
2809
|
if (!sub.ok) fail("subscription create", sub);
|
|
2191
|
-
const
|
|
2192
|
-
if (!
|
|
2193
|
-
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 };
|
|
2194
2813
|
},
|
|
2195
2814
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2196
2815
|
if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2203,8 +2822,8 @@ function createStripeProvider(config) {
|
|
|
2203
2822
|
const sk = requireSecret(secretKey);
|
|
2204
2823
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2205
2824
|
if (!charges.ok) fail("charges list", charges);
|
|
2206
|
-
const
|
|
2207
|
-
const paid =
|
|
2825
|
+
const rows4 = charges.body.data ?? [];
|
|
2826
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2208
2827
|
const charge = paid[paid.length - 1];
|
|
2209
2828
|
if (!charge) {
|
|
2210
2829
|
const err = new Error("no paid charge to refund");
|
|
@@ -2220,12 +2839,337 @@ function createStripeProvider(config) {
|
|
|
2220
2839
|
};
|
|
2221
2840
|
}
|
|
2222
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
|
+
|
|
2223
3167
|
// src/worker-routes-payments.ts
|
|
2224
3168
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2225
3169
|
async function firstRow2(db, ns, q) {
|
|
2226
3170
|
const res = await db.query({ [ns]: { $: q } });
|
|
2227
|
-
const
|
|
2228
|
-
return Array.isArray(
|
|
3171
|
+
const rows4 = res[ns];
|
|
3172
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2229
3173
|
}
|
|
2230
3174
|
function lineItems(group) {
|
|
2231
3175
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2310,6 +3254,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2310
3254
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2311
3255
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2312
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
|
+
}
|
|
2313
3287
|
const tier = findTier(
|
|
2314
3288
|
await loadTiers(db, group),
|
|
2315
3289
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2351,17 +3325,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2351
3325
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2352
3326
|
const { eventId, event } = ingest;
|
|
2353
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
|
+
}
|
|
2354
3354
|
const app = await findApplication(db, event);
|
|
2355
3355
|
if (!app) return json({ ok: true, matched: false });
|
|
2356
3356
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3357
|
+
let next = app;
|
|
2357
3358
|
if (Object.keys(patch).length) {
|
|
2358
3359
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2359
|
-
|
|
3360
|
+
next = { ...app, ...patch };
|
|
2360
3361
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2361
3362
|
}
|
|
2362
3363
|
if (event.kind === "first_payment") {
|
|
2363
3364
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2364
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
|
+
}
|
|
2365
3376
|
}
|
|
2366
3377
|
return json({ ok: true });
|
|
2367
3378
|
}
|
|
@@ -2372,6 +3383,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2372
3383
|
return null;
|
|
2373
3384
|
};
|
|
2374
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
|
+
|
|
2375
3524
|
// src/worker-routes-admin.ts
|
|
2376
3525
|
import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
|
|
2377
3526
|
|
|
@@ -2443,8 +3592,8 @@ async function upcomingEvents(env) {
|
|
|
2443
3592
|
const res = await cal.availability.upcoming();
|
|
2444
3593
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2445
3594
|
}
|
|
2446
|
-
function toReconcile(
|
|
2447
|
-
return
|
|
3595
|
+
function toReconcile(rows4) {
|
|
3596
|
+
return rows4.map((m) => ({
|
|
2448
3597
|
id: String(m.id),
|
|
2449
3598
|
applicationId: String(m.applicationId),
|
|
2450
3599
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2466,12 +3615,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2466
3615
|
const query = await db.query({
|
|
2467
3616
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2468
3617
|
});
|
|
2469
|
-
let
|
|
2470
|
-
if (Number.isFinite(from))
|
|
2471
|
-
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);
|
|
2472
3621
|
let decisions = [];
|
|
2473
3622
|
try {
|
|
2474
|
-
decisions = reconcileMeetings(toReconcile(
|
|
3623
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2475
3624
|
} catch {
|
|
2476
3625
|
}
|
|
2477
3626
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2498,69 +3647,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2498
3647
|
};
|
|
2499
3648
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2500
3649
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2501
|
-
const meetings =
|
|
3650
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2502
3651
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2503
3652
|
};
|
|
2504
3653
|
|
|
2505
|
-
// src/clerk-roles.ts
|
|
2506
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2507
|
-
var DEFAULT_ROLE = "provisional";
|
|
2508
|
-
var PAGE = 100;
|
|
2509
|
-
function toRecord(u) {
|
|
2510
|
-
if (typeof u.id !== "string") return null;
|
|
2511
|
-
const pm = u.public_metadata ?? {};
|
|
2512
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2513
|
-
const rawProfile = privateMetadata.profile;
|
|
2514
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2515
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2516
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2517
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2518
|
-
return {
|
|
2519
|
-
id: u.id,
|
|
2520
|
-
email: typeof email === "string" ? email : void 0,
|
|
2521
|
-
role,
|
|
2522
|
-
publicMetadata: pm,
|
|
2523
|
-
privateMetadata,
|
|
2524
|
-
profile,
|
|
2525
|
-
...applicationId ? { applicationId } : {}
|
|
2526
|
-
};
|
|
2527
|
-
}
|
|
2528
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2529
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2530
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2531
|
-
return res.json();
|
|
2532
|
-
}
|
|
2533
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2534
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2535
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2536
|
-
return user ? toRecord(user) : null;
|
|
2537
|
-
}
|
|
2538
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2539
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2540
|
-
return data ? toRecord(data) : null;
|
|
2541
|
-
}
|
|
2542
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2543
|
-
const out = [];
|
|
2544
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2545
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2546
|
-
const page = Array.isArray(data) ? data : [];
|
|
2547
|
-
for (const u of page) {
|
|
2548
|
-
const record = toRecord(u);
|
|
2549
|
-
if (record) out.push(record);
|
|
2550
|
-
}
|
|
2551
|
-
if (page.length < PAGE) break;
|
|
2552
|
-
}
|
|
2553
|
-
return out;
|
|
2554
|
-
}
|
|
2555
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2556
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2557
|
-
method: "PATCH",
|
|
2558
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2559
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2560
|
-
});
|
|
2561
|
-
return res.ok;
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
3654
|
// src/worker-routes-admin-people.ts
|
|
2565
3655
|
async function adminGate(req, env, ctx) {
|
|
2566
3656
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2578,16 +3668,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2578
3668
|
});
|
|
2579
3669
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2580
3670
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2581
|
-
const
|
|
2582
|
-
if (
|
|
2583
|
-
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));
|
|
2584
3674
|
return json({ ok: true, ...result });
|
|
2585
3675
|
};
|
|
2586
3676
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2587
3677
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2588
|
-
const
|
|
2589
|
-
if (
|
|
2590
|
-
const { db } =
|
|
3678
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3679
|
+
if (gate9 instanceof Response) return gate9;
|
|
3680
|
+
const { db } = gate9;
|
|
2591
3681
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2592
3682
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2593
3683
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2631,16 +3721,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2631
3721
|
});
|
|
2632
3722
|
}
|
|
2633
3723
|
}
|
|
2634
|
-
const
|
|
3724
|
+
const rows4 = [...people.values()].sort(
|
|
2635
3725
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2636
3726
|
);
|
|
2637
|
-
return json({ people:
|
|
3727
|
+
return json({ people: rows4 });
|
|
2638
3728
|
};
|
|
2639
3729
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2640
3730
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2641
|
-
const
|
|
2642
|
-
if (
|
|
2643
|
-
const { db } =
|
|
3731
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3732
|
+
if (gate9 instanceof Response) return gate9;
|
|
3733
|
+
const { db } = gate9;
|
|
2644
3734
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2645
3735
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2646
3736
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2651,9 +3741,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2651
3741
|
};
|
|
2652
3742
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2653
3743
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2654
|
-
const
|
|
2655
|
-
if (
|
|
2656
|
-
const { db, actor } =
|
|
3744
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
3745
|
+
if (gate9 instanceof Response) return gate9;
|
|
3746
|
+
const { db, actor } = gate9;
|
|
2657
3747
|
let body;
|
|
2658
3748
|
try {
|
|
2659
3749
|
body = await req.json();
|
|
@@ -2800,17 +3890,17 @@ function subAnnualCents(sub) {
|
|
|
2800
3890
|
}
|
|
2801
3891
|
|
|
2802
3892
|
// src/worker-routes-admin-dashboard.ts
|
|
2803
|
-
async function
|
|
3893
|
+
async function gate2(req, env, ctx) {
|
|
2804
3894
|
const rawDb = ctx.makeDb(env);
|
|
2805
3895
|
const u = await ctx.verifyUser(req, env);
|
|
2806
3896
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2807
3897
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2808
3898
|
return rawDb;
|
|
2809
3899
|
}
|
|
2810
|
-
var
|
|
3900
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2811
3901
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2812
3902
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2813
|
-
const got = await
|
|
3903
|
+
const got = await gate2(req, env, ctx);
|
|
2814
3904
|
if (got instanceof Response) return got;
|
|
2815
3905
|
const db = got;
|
|
2816
3906
|
const now = Date.now();
|
|
@@ -2822,7 +3912,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2822
3912
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2823
3913
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2824
3914
|
]);
|
|
2825
|
-
const apps =
|
|
3915
|
+
const apps = rows3(appsRes.applications);
|
|
2826
3916
|
const applications = {
|
|
2827
3917
|
total: apps.length,
|
|
2828
3918
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2834,7 +3924,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2834
3924
|
pipeline[s] = 0;
|
|
2835
3925
|
pipelineDelta[s] = 0;
|
|
2836
3926
|
}
|
|
2837
|
-
for (const r of
|
|
3927
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2838
3928
|
const s = r.stage;
|
|
2839
3929
|
if (s in pipeline) {
|
|
2840
3930
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2842,7 +3932,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2842
3932
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2843
3933
|
}
|
|
2844
3934
|
}
|
|
2845
|
-
const meetings =
|
|
3935
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2846
3936
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2847
3937
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2848
3938
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2867,7 +3957,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2867
3957
|
if (sk) {
|
|
2868
3958
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2869
3959
|
if (subsRes.ok) {
|
|
2870
|
-
const subs =
|
|
3960
|
+
const subs = rows3(subsRes.body.data);
|
|
2871
3961
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2872
3962
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2873
3963
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2887,7 +3977,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2887
3977
|
};
|
|
2888
3978
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2889
3979
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2890
|
-
const got = await
|
|
3980
|
+
const got = await gate2(req, env, ctx);
|
|
2891
3981
|
if (got instanceof Response) return got;
|
|
2892
3982
|
const db = got;
|
|
2893
3983
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2899,9 +3989,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2899
3989
|
]);
|
|
2900
3990
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2901
3991
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2902
|
-
const subById = new Map(
|
|
3992
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2903
3993
|
const billingRows = [];
|
|
2904
|
-
for (const a of
|
|
3994
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2905
3995
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2906
3996
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2907
3997
|
const items = sub?.items?.data ?? [];
|
|
@@ -2945,7 +4035,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2945
4035
|
|
|
2946
4036
|
// src/worker-routes-admin-lifecycle.ts
|
|
2947
4037
|
import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
|
|
2948
|
-
async function
|
|
4038
|
+
async function gate3(req, env, ctx) {
|
|
2949
4039
|
const rawDb = ctx.makeDb(env);
|
|
2950
4040
|
const u = await ctx.verifyUser(req, env);
|
|
2951
4041
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2971,7 +4061,7 @@ async function loadGroup(db, id) {
|
|
|
2971
4061
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2972
4062
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2973
4063
|
if (req.method !== "POST" || !m) return null;
|
|
2974
|
-
const got = await
|
|
4064
|
+
const got = await gate3(req, env, ctx);
|
|
2975
4065
|
if (got instanceof Response) return got;
|
|
2976
4066
|
const db = got;
|
|
2977
4067
|
const body = await readJson(req);
|
|
@@ -3012,7 +4102,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3012
4102
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3013
4103
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3014
4104
|
if (req.method !== "POST" || !m) return null;
|
|
3015
|
-
const got = await
|
|
4105
|
+
const got = await gate3(req, env, ctx);
|
|
3016
4106
|
if (got instanceof Response) return got;
|
|
3017
4107
|
const db = got;
|
|
3018
4108
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3035,43 +4125,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3035
4125
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3036
4126
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3037
4127
|
if (req.method !== "POST" || !m) return null;
|
|
3038
|
-
const got = await
|
|
4128
|
+
const got = await gate3(req, env, ctx);
|
|
3039
4129
|
if (got instanceof Response) return got;
|
|
3040
4130
|
const db = got;
|
|
3041
4131
|
const id = m[1];
|
|
3042
4132
|
const app = await loadApp(db, id);
|
|
3043
4133
|
if (!app) return json({ error: "not found" }, 404);
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3048
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3049
|
-
let rolePromoted = false;
|
|
3050
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3051
|
-
if (promoteTo !== false && sk) {
|
|
3052
|
-
let userId = app.clerkUserId || null;
|
|
3053
|
-
if (!userId) {
|
|
3054
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3055
|
-
userId = found?.id ?? null;
|
|
3056
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3057
|
-
}
|
|
3058
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3059
|
-
}
|
|
3060
|
-
let emailLogged = false;
|
|
3061
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3062
|
-
if (send !== false && group) {
|
|
3063
|
-
const res = await sendTemplated(
|
|
3064
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3065
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3066
|
-
);
|
|
3067
|
-
emailLogged = res.sent;
|
|
3068
|
-
}
|
|
3069
|
-
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 });
|
|
3070
4137
|
};
|
|
3071
4138
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3072
4139
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3073
4140
|
if (req.method !== "POST" || !m) return null;
|
|
3074
|
-
const got = await
|
|
4141
|
+
const got = await gate3(req, env, ctx);
|
|
3075
4142
|
if (got instanceof Response) return got;
|
|
3076
4143
|
const db = got;
|
|
3077
4144
|
const app = await loadApp(db, m[1]);
|
|
@@ -3103,7 +4170,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3103
4170
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3104
4171
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3105
4172
|
if (req.method !== "PATCH" || !m) return null;
|
|
3106
|
-
const got = await
|
|
4173
|
+
const got = await gate3(req, env, ctx);
|
|
3107
4174
|
if (got instanceof Response) return got;
|
|
3108
4175
|
const db = got;
|
|
3109
4176
|
const id = m[1];
|
|
@@ -3131,7 +4198,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3131
4198
|
};
|
|
3132
4199
|
|
|
3133
4200
|
// src/worker-routes-admin-comms.ts
|
|
3134
|
-
async function
|
|
4201
|
+
async function gate4(req, env, ctx) {
|
|
3135
4202
|
const rawDb = ctx.makeDb(env);
|
|
3136
4203
|
const u = await ctx.verifyUser(req, env);
|
|
3137
4204
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3153,7 +4220,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3153
4220
|
});
|
|
3154
4221
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3155
4222
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3156
|
-
const got = await
|
|
4223
|
+
const got = await gate4(req, env, ctx);
|
|
3157
4224
|
if (got instanceof Response) return got;
|
|
3158
4225
|
const db = got;
|
|
3159
4226
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3194,11 +4261,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3194
4261
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3195
4262
|
const t = templates[key];
|
|
3196
4263
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3197
|
-
const
|
|
3198
|
-
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);
|
|
3199
4266
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3200
|
-
if (
|
|
3201
|
-
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 };
|
|
3202
4269
|
}
|
|
3203
4270
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3204
4271
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3215,7 +4282,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3215
4282
|
};
|
|
3216
4283
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3217
4284
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3218
|
-
const got = await
|
|
4285
|
+
const got = await gate4(req, env, ctx);
|
|
3219
4286
|
if (got instanceof Response) return got;
|
|
3220
4287
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3221
4288
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3232,7 +4299,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3232
4299
|
};
|
|
3233
4300
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3234
4301
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3235
|
-
const got = await
|
|
4302
|
+
const got = await gate4(req, env, ctx);
|
|
3236
4303
|
if (got instanceof Response) return got;
|
|
3237
4304
|
const db = got;
|
|
3238
4305
|
let body;
|
|
@@ -3260,7 +4327,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3260
4327
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3261
4328
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3262
4329
|
if (req.method !== "GET" || !m) return null;
|
|
3263
|
-
const got = await
|
|
4330
|
+
const got = await gate4(req, env, ctx);
|
|
3264
4331
|
if (got instanceof Response) return got;
|
|
3265
4332
|
const db = got;
|
|
3266
4333
|
const appId = m[1];
|
|
@@ -3293,7 +4360,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3293
4360
|
|
|
3294
4361
|
// src/worker-routes-network.ts
|
|
3295
4362
|
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
3296
|
-
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as
|
|
4363
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
|
|
3297
4364
|
|
|
3298
4365
|
// src/worker-network-fetch.ts
|
|
3299
4366
|
function targetFetcher(env, target) {
|
|
@@ -3312,7 +4379,7 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3312
4379
|
// src/worker-network-push.ts
|
|
3313
4380
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
3314
4381
|
import { signFederatedRequest } from "@odla-ai/db";
|
|
3315
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4382
|
+
async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
3316
4383
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3317
4384
|
const crmDeps3 = {
|
|
3318
4385
|
crm: ctx.chapter.crm,
|
|
@@ -3323,7 +4390,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3323
4390
|
if (!secret) {
|
|
3324
4391
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3325
4392
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3326
|
-
recordId:
|
|
4393
|
+
recordId: record2.id,
|
|
3327
4394
|
targetId: target.id,
|
|
3328
4395
|
status: "failed",
|
|
3329
4396
|
payloadVersion: 2,
|
|
@@ -3333,7 +4400,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3333
4400
|
}
|
|
3334
4401
|
let payload;
|
|
3335
4402
|
try {
|
|
3336
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4403
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
|
|
3337
4404
|
} catch (err) {
|
|
3338
4405
|
return {
|
|
3339
4406
|
id: target.id,
|
|
@@ -3362,7 +4429,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3362
4429
|
if (!res.ok) {
|
|
3363
4430
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3364
4431
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3365
|
-
recordId:
|
|
4432
|
+
recordId: record2.id,
|
|
3366
4433
|
targetId: target.id,
|
|
3367
4434
|
status: "failed",
|
|
3368
4435
|
payloadVersion: 2,
|
|
@@ -3377,7 +4444,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3377
4444
|
};
|
|
3378
4445
|
}
|
|
3379
4446
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3380
|
-
recordId:
|
|
4447
|
+
recordId: record2.id,
|
|
3381
4448
|
targetId: target.id,
|
|
3382
4449
|
status: "delivered",
|
|
3383
4450
|
payloadVersion: 2,
|
|
@@ -3393,7 +4460,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3393
4460
|
} catch (err) {
|
|
3394
4461
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3395
4462
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3396
|
-
recordId:
|
|
4463
|
+
recordId: record2.id,
|
|
3397
4464
|
targetId: target.id,
|
|
3398
4465
|
status: "failed",
|
|
3399
4466
|
payloadVersion: 2,
|
|
@@ -3404,7 +4471,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3404
4471
|
}
|
|
3405
4472
|
|
|
3406
4473
|
// src/worker-routes-network.ts
|
|
3407
|
-
async function
|
|
4474
|
+
async function gate5(req, env, ctx) {
|
|
3408
4475
|
const db = ctx.makeDb(env);
|
|
3409
4476
|
const user = await ctx.verifyUser(req, env);
|
|
3410
4477
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3413,7 +4480,7 @@ async function gate4(req, env, ctx) {
|
|
|
3413
4480
|
}
|
|
3414
4481
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3415
4482
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3416
|
-
const got = await
|
|
4483
|
+
const got = await gate5(req, env, ctx);
|
|
3417
4484
|
if ("response" in got) return got.response;
|
|
3418
4485
|
return json({
|
|
3419
4486
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3430,7 +4497,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3430
4497
|
const db = ctx.makeDb(env);
|
|
3431
4498
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3432
4499
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3433
|
-
const verified = await
|
|
4500
|
+
const verified = await verifyFederatedRequest4(req, { secret });
|
|
3434
4501
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3435
4502
|
const types = await Promise.all(
|
|
3436
4503
|
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3489,7 +4556,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3489
4556
|
}
|
|
3490
4557
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3491
4558
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3492
|
-
const got = await
|
|
4559
|
+
const got = await gate5(req, env, ctx);
|
|
3493
4560
|
if ("response" in got) return got.response;
|
|
3494
4561
|
const targets = await Promise.all(
|
|
3495
4562
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3516,7 +4583,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3516
4583
|
};
|
|
3517
4584
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3518
4585
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3519
|
-
const got = await
|
|
4586
|
+
const got = await gate5(req, env, ctx);
|
|
3520
4587
|
if ("response" in got) return got.response;
|
|
3521
4588
|
let body;
|
|
3522
4589
|
try {
|
|
@@ -3531,22 +4598,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3531
4598
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3532
4599
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3533
4600
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3534
|
-
const
|
|
3535
|
-
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);
|
|
3536
4603
|
const results = await Promise.all(
|
|
3537
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
4604
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
|
|
3538
4605
|
);
|
|
3539
4606
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3540
4607
|
};
|
|
3541
4608
|
|
|
3542
4609
|
// src/worker-routes-network-records.ts
|
|
3543
4610
|
import { listRecords } from "@odla-ai/crm";
|
|
3544
|
-
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as
|
|
4611
|
+
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
3545
4612
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3546
4613
|
var MAX_PAGE = 50;
|
|
3547
4614
|
var MAX_OFFSET = 1e4;
|
|
3548
4615
|
var MAX_SEARCH = 200;
|
|
3549
|
-
async function
|
|
4616
|
+
async function gate6(req, env, ctx) {
|
|
3550
4617
|
const db = ctx.makeDb(env);
|
|
3551
4618
|
const user = await ctx.verifyUser(req, env);
|
|
3552
4619
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3559,18 +4626,18 @@ function integer(value, fallback, min, max) {
|
|
|
3559
4626
|
const parsed = Number(value);
|
|
3560
4627
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3561
4628
|
}
|
|
3562
|
-
function projectRecord(
|
|
4629
|
+
function projectRecord(record2, allowed) {
|
|
3563
4630
|
const fields = Object.fromEntries(
|
|
3564
|
-
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(
|
|
4631
|
+
allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
|
|
3565
4632
|
);
|
|
3566
4633
|
return {
|
|
3567
|
-
id:
|
|
3568
|
-
type:
|
|
3569
|
-
name:
|
|
3570
|
-
...
|
|
4634
|
+
id: record2.id,
|
|
4635
|
+
type: record2.type,
|
|
4636
|
+
name: record2.name,
|
|
4637
|
+
...record2.stage ? { stage: record2.stage } : {},
|
|
3571
4638
|
fields,
|
|
3572
|
-
createdAt:
|
|
3573
|
-
updatedAt:
|
|
4639
|
+
createdAt: record2.createdAt,
|
|
4640
|
+
updatedAt: record2.updatedAt
|
|
3574
4641
|
};
|
|
3575
4642
|
}
|
|
3576
4643
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3579,7 +4646,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3579
4646
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3580
4647
|
const readers = ctx.chapter.network.readers;
|
|
3581
4648
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3582
|
-
const verified = await
|
|
4649
|
+
const verified = await verifyFederatedRequest5(req, {
|
|
3583
4650
|
secret,
|
|
3584
4651
|
senders: readers.map((reader2) => reader2.id)
|
|
3585
4652
|
});
|
|
@@ -3608,7 +4675,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3608
4675
|
version: 1,
|
|
3609
4676
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3610
4677
|
type,
|
|
3611
|
-
records: page.records.map((
|
|
4678
|
+
records: page.records.map((record2) => projectRecord(record2, allowed)),
|
|
3612
4679
|
total: page.total,
|
|
3613
4680
|
limit: page.limit,
|
|
3614
4681
|
offset: page.offset
|
|
@@ -3649,7 +4716,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3649
4716
|
}
|
|
3650
4717
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3651
4718
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3652
|
-
const got = await
|
|
4719
|
+
const got = await gate6(req, env, ctx);
|
|
3653
4720
|
if ("response" in got) return got.response;
|
|
3654
4721
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3655
4722
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3676,8 +4743,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3676
4743
|
// src/worker-routes-network-notes.ts
|
|
3677
4744
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3678
4745
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3679
|
-
var
|
|
3680
|
-
async function
|
|
4746
|
+
var MAX_NOTE2 = 4e3;
|
|
4747
|
+
async function gate7(req, env, ctx) {
|
|
3681
4748
|
const db = ctx.makeDb(env);
|
|
3682
4749
|
const user = await ctx.verifyUser(req, env);
|
|
3683
4750
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3711,7 +4778,7 @@ function asNote(row) {
|
|
|
3711
4778
|
}
|
|
3712
4779
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3713
4780
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3714
|
-
const got = await
|
|
4781
|
+
const got = await gate7(req, env, ctx);
|
|
3715
4782
|
if ("response" in got) return got.response;
|
|
3716
4783
|
const identity = noteIdentity(url, ctx);
|
|
3717
4784
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3728,7 +4795,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3728
4795
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3729
4796
|
}
|
|
3730
4797
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3731
|
-
if (contentLength >
|
|
4798
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3732
4799
|
let input;
|
|
3733
4800
|
try {
|
|
3734
4801
|
input = await req.json();
|
|
@@ -3736,7 +4803,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3736
4803
|
return json({ error: "invalid JSON body" }, 400);
|
|
3737
4804
|
}
|
|
3738
4805
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3739
|
-
if (!body || body.length >
|
|
4806
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3740
4807
|
const id = crypto.randomUUID();
|
|
3741
4808
|
const note = {
|
|
3742
4809
|
id,
|
|
@@ -3763,13 +4830,13 @@ import {
|
|
|
3763
4830
|
CRM_NS,
|
|
3764
4831
|
getRecord as getRecord2
|
|
3765
4832
|
} from "@odla-ai/crm";
|
|
3766
|
-
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as
|
|
4833
|
+
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
3767
4834
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3768
4835
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3769
4836
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3770
|
-
var
|
|
4837
|
+
var MAX_NOTE3 = 4e3;
|
|
3771
4838
|
var NETWORK_META = "chapterNetwork";
|
|
3772
|
-
async function
|
|
4839
|
+
async function gate8(req, env, ctx) {
|
|
3773
4840
|
const db = ctx.makeDb(env);
|
|
3774
4841
|
const user = await ctx.verifyUser(req, env);
|
|
3775
4842
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3815,14 +4882,14 @@ function sourceLabel(sourceId) {
|
|
|
3815
4882
|
}
|
|
3816
4883
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3817
4884
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3818
|
-
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) {
|
|
3819
4886
|
return json({ error: "note is too large" }, 413);
|
|
3820
4887
|
}
|
|
3821
4888
|
const db = ctx.makeDb(env);
|
|
3822
4889
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3823
4890
|
const readers = ctx.chapter.network.readers;
|
|
3824
4891
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3825
|
-
const verified = await
|
|
4892
|
+
const verified = await verifyFederatedRequest6(req, {
|
|
3826
4893
|
secret,
|
|
3827
4894
|
senders: readers.map((reader2) => reader2.id)
|
|
3828
4895
|
});
|
|
@@ -3833,8 +4900,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3833
4900
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3834
4901
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3835
4902
|
}
|
|
3836
|
-
const
|
|
3837
|
-
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);
|
|
3838
4905
|
if (req.method === "GET") {
|
|
3839
4906
|
return json({
|
|
3840
4907
|
version: 1,
|
|
@@ -3850,8 +4917,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3850
4917
|
}
|
|
3851
4918
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3852
4919
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3853
|
-
if (!body || body.length >
|
|
3854
|
-
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);
|
|
3855
4922
|
}
|
|
3856
4923
|
const createdAt = Date.now();
|
|
3857
4924
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3893,7 +4960,7 @@ function targetFor2(ctx, id) {
|
|
|
3893
4960
|
}
|
|
3894
4961
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3895
4962
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3896
|
-
const got = await
|
|
4963
|
+
const got = await gate8(req, env, ctx);
|
|
3897
4964
|
if ("response" in got) return got.response;
|
|
3898
4965
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3899
4966
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3911,7 +4978,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3911
4978
|
return json({ error: "invalid JSON body" }, 400);
|
|
3912
4979
|
}
|
|
3913
4980
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3914
|
-
if (!note || note.length >
|
|
4981
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3915
4982
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3916
4983
|
}
|
|
3917
4984
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -4064,6 +5131,31 @@ async function deliverSignupControl(options) {
|
|
|
4064
5131
|
return result;
|
|
4065
5132
|
}
|
|
4066
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
|
+
|
|
4067
5159
|
// src/signup-control-theme.ts
|
|
4068
5160
|
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
4069
5161
|
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
@@ -4203,11 +5295,13 @@ var BUILTIN_ROUTES = [
|
|
|
4203
5295
|
handleCrm,
|
|
4204
5296
|
handleNetworkShared,
|
|
4205
5297
|
handleSignupControl,
|
|
5298
|
+
handleAdmissionGrants,
|
|
4206
5299
|
handleNetworkSnapshot,
|
|
4207
5300
|
handleNetworkRecords,
|
|
4208
5301
|
handleNetworkSharedNotes,
|
|
4209
5302
|
handleFormation,
|
|
4210
5303
|
handleMember,
|
|
5304
|
+
handleGifts,
|
|
4211
5305
|
handleSchedule,
|
|
4212
5306
|
handlePayments,
|
|
4213
5307
|
handleAdminMeetings,
|
|
@@ -4217,6 +5311,7 @@ var BUILTIN_ROUTES = [
|
|
|
4217
5311
|
handleAdminPeopleAccess,
|
|
4218
5312
|
handleAdminPeopleRole,
|
|
4219
5313
|
handleAdminCrmSync,
|
|
5314
|
+
handleAdminAdmissionGrants,
|
|
4220
5315
|
handleAdminClerkPrivateProfileSync,
|
|
4221
5316
|
// Aggregation
|
|
4222
5317
|
handleAdminDashboard,
|
|
@@ -4261,6 +5356,7 @@ function chapterWorker(options) {
|
|
|
4261
5356
|
export {
|
|
4262
5357
|
chapterWorker,
|
|
4263
5358
|
createWorkerContext,
|
|
5359
|
+
deliverAdmissionGrants,
|
|
4264
5360
|
deliverSignupControl,
|
|
4265
5361
|
renderActiveSignupControlHtml,
|
|
4266
5362
|
withActiveSignupControlTheme
|