@odla-ai/chapter 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/{chunk-UJIVBGL5.js → chunk-BIYWPPH6.js} +535 -240
- package/dist/chunk-BIYWPPH6.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-SI3JHSEX.js} +319 -65
- package/dist/chunk-SI3JHSEX.js.map +1 -0
- package/dist/{copy-context-c1tqPXOR.d.ts → copy-context-Pbs3keLx.d.ts} +179 -29
- package/dist/index.cjs +1262 -326
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +439 -30
- package/dist/index.d.ts +439 -30
- package/dist/index.js +1262 -326
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +34 -3
- package/dist/ui/admin/index.js +10 -2
- package/dist/ui/index.d.ts +3 -3
- package/dist/ui/index.js +17 -3
- package/dist/ui/member/index.d.ts +111 -7
- package/dist/ui/member/index.js +8 -2
- package/dist/worker/index.cjs +2623 -917
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +251 -29
- package/dist/worker/index.d.ts +251 -29
- package/dist/worker/index.js +2657 -951
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/runbooks/greenfield.md +27 -0
- package/dist/chunk-JAWTIROV.js.map +0 -1
- package/dist/chunk-TXE5ZDI4.js.map +0 -1
- package/dist/chunk-UJIVBGL5.js.map +0 -1
package/dist/worker/index.cjs
CHANGED
|
@@ -22,6 +22,8 @@ var worker_exports = {};
|
|
|
22
22
|
__export(worker_exports, {
|
|
23
23
|
chapterWorker: () => chapterWorker,
|
|
24
24
|
createWorkerContext: () => createWorkerContext,
|
|
25
|
+
deliverAdmissionGrants: () => deliverAdmissionGrants,
|
|
26
|
+
deliverMemberContent: () => deliverMemberContent,
|
|
25
27
|
deliverSignupControl: () => deliverSignupControl,
|
|
26
28
|
renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
|
|
27
29
|
withActiveSignupControlTheme: () => withActiveSignupControlTheme
|
|
@@ -139,7 +141,7 @@ function createWorkerContext(options) {
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
// src/worker-routes.ts
|
|
142
|
-
var
|
|
144
|
+
var import_crm4 = require("@odla-ai/crm");
|
|
143
145
|
|
|
144
146
|
// src/discussion-reference-security.ts
|
|
145
147
|
async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
|
|
@@ -197,6 +199,205 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
197
199
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
// src/admission-grants-contract.ts
|
|
203
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
204
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
205
|
+
|
|
206
|
+
// src/signup-control-canonical.ts
|
|
207
|
+
var encoder = new TextEncoder();
|
|
208
|
+
function canonicalValue(value) {
|
|
209
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
210
|
+
return JSON.stringify(value);
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
213
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
214
|
+
const record3 = value;
|
|
215
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
216
|
+
const item = record3[key];
|
|
217
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
218
|
+
});
|
|
219
|
+
return `{${entries.join(",")}}`;
|
|
220
|
+
}
|
|
221
|
+
function canonicalSignupControl(value) {
|
|
222
|
+
return canonicalValue(value);
|
|
223
|
+
}
|
|
224
|
+
async function signupControlDigest(document) {
|
|
225
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
226
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/admission-grants.ts
|
|
230
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
231
|
+
var MAX_GRANTS = 2e3;
|
|
232
|
+
var MAX_NOTE = 2e3;
|
|
233
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
234
|
+
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
235
|
+
function normalizeAdmissionEmail(email) {
|
|
236
|
+
return email.trim().toLowerCase();
|
|
237
|
+
}
|
|
238
|
+
function validateAdmissionGrantDocument(input) {
|
|
239
|
+
const errors = [];
|
|
240
|
+
const root = input && typeof input === "object" ? input : {};
|
|
241
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
242
|
+
const revision = Number(root.revision);
|
|
243
|
+
const generatedAt = Number(root.generatedAt);
|
|
244
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
245
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
246
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
247
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
248
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
249
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
250
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
251
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
252
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
253
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
254
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
255
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
256
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
257
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
258
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
259
|
+
const grants = [];
|
|
260
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
261
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
262
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
263
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
264
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
265
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
266
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
267
|
+
const decision = grant.decision;
|
|
268
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
269
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
270
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
271
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
272
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
273
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
274
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
275
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
276
|
+
}
|
|
277
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
278
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
279
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
280
|
+
}
|
|
281
|
+
seenIds.add(id);
|
|
282
|
+
seenEmails.add(normalized);
|
|
283
|
+
grants.push({
|
|
284
|
+
id,
|
|
285
|
+
email,
|
|
286
|
+
decision,
|
|
287
|
+
...note ? { note } : {},
|
|
288
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (errors.length) return { ok: false, errors };
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
value: {
|
|
295
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
296
|
+
revision,
|
|
297
|
+
generatedAt,
|
|
298
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
299
|
+
target: { chapterId, environment },
|
|
300
|
+
grants
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
305
|
+
function asGrant(row) {
|
|
306
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
307
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
308
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
309
|
+
return row;
|
|
310
|
+
}
|
|
311
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
312
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
313
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
314
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
315
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
316
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
317
|
+
)).sort((a, b) => {
|
|
318
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
319
|
+
return scope || b.createdAt - a.createdAt;
|
|
320
|
+
});
|
|
321
|
+
return active[0] ?? null;
|
|
322
|
+
}
|
|
323
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
324
|
+
const where = { sourceId, targetId, environment };
|
|
325
|
+
if (db.aggregate) {
|
|
326
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
327
|
+
}
|
|
328
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
329
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
330
|
+
}
|
|
331
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
332
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
333
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
334
|
+
where: { sourceId, targetId, environment },
|
|
335
|
+
order: { revision: "desc" },
|
|
336
|
+
limit: 1
|
|
337
|
+
} } });
|
|
338
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
339
|
+
return row ? row : null;
|
|
340
|
+
}
|
|
341
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
342
|
+
const { document, digest } = envelope;
|
|
343
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
344
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
345
|
+
limit: MAX_GRANTS
|
|
346
|
+
} } })).admissionGrants);
|
|
347
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
348
|
+
const ops = [];
|
|
349
|
+
for (const row of current) {
|
|
350
|
+
const id = String(row.id ?? "");
|
|
351
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
352
|
+
active: false,
|
|
353
|
+
revokedAt: receivedAt,
|
|
354
|
+
revokedBy: document.source.actor,
|
|
355
|
+
updatedAt: receivedAt,
|
|
356
|
+
updatedBy: document.source.actor
|
|
357
|
+
} });
|
|
358
|
+
}
|
|
359
|
+
for (const grant of document.grants) {
|
|
360
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
361
|
+
const existing = current.find((row) => row.id === id);
|
|
362
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
363
|
+
id,
|
|
364
|
+
sourceGrantId: grant.id,
|
|
365
|
+
sourceScope: "parent",
|
|
366
|
+
sourceId: document.source.appId,
|
|
367
|
+
targetId: document.target.chapterId,
|
|
368
|
+
email: grant.email,
|
|
369
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
370
|
+
decision: grant.decision,
|
|
371
|
+
...grant.note ? { note: grant.note } : {},
|
|
372
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
373
|
+
active: true,
|
|
374
|
+
sourceRevision: document.revision,
|
|
375
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
376
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
377
|
+
updatedAt: receivedAt,
|
|
378
|
+
updatedBy: document.source.actor
|
|
379
|
+
} });
|
|
380
|
+
const retract = [
|
|
381
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
382
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
383
|
+
];
|
|
384
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
385
|
+
}
|
|
386
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
387
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
388
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
389
|
+
id: revisionId,
|
|
390
|
+
sourceId: document.source.appId,
|
|
391
|
+
targetId: document.target.chapterId,
|
|
392
|
+
environment: document.target.environment,
|
|
393
|
+
revision: document.revision,
|
|
394
|
+
digest,
|
|
395
|
+
active: true,
|
|
396
|
+
receivedAt
|
|
397
|
+
} });
|
|
398
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
399
|
+
}
|
|
400
|
+
|
|
200
401
|
// src/member.ts
|
|
201
402
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
202
403
|
var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
|
|
@@ -248,9 +449,9 @@ function resolveApplication(a) {
|
|
|
248
449
|
validateEmail: a?.validateEmail ?? true
|
|
249
450
|
};
|
|
250
451
|
}
|
|
251
|
-
var
|
|
452
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
252
453
|
function isValidEmail(value) {
|
|
253
|
-
return typeof value === "string" &&
|
|
454
|
+
return typeof value === "string" && EMAIL_RE2.test(value);
|
|
254
455
|
}
|
|
255
456
|
function clampArray(value, max) {
|
|
256
457
|
if (!Array.isArray(value)) return value;
|
|
@@ -262,14 +463,14 @@ function hasDisclaimerAck(fields) {
|
|
|
262
463
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
263
464
|
function applicantProfile(chapter, fields) {
|
|
264
465
|
const app = chapter.application;
|
|
265
|
-
const
|
|
466
|
+
const allowed2 = (f) => app.profileFields.includes(f);
|
|
266
467
|
const profile = {};
|
|
267
468
|
for (const f of [...app.required, ...app.optional]) {
|
|
268
|
-
if (IDENTITY_FIELDS.has(f) || !
|
|
469
|
+
if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
|
|
269
470
|
const v = fields[f];
|
|
270
471
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
271
472
|
}
|
|
272
|
-
if (fields.focus !== void 0 &&
|
|
473
|
+
if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
273
474
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
274
475
|
}
|
|
275
476
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -300,6 +501,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
300
501
|
}
|
|
301
502
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
302
503
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
504
|
+
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
505
|
+
row.emailNormalized = normalizeAdmissionEmail(fields.email);
|
|
506
|
+
}
|
|
507
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
508
|
+
row.admissionGrantId = opts.admissionGrant.id;
|
|
509
|
+
row.admissionSource = opts.admissionGrant.sourceScope;
|
|
510
|
+
row.interviewWaivedAt = opts.now;
|
|
511
|
+
}
|
|
512
|
+
if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
|
|
303
513
|
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
304
514
|
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
305
515
|
return { ok: false, error: "tierId is not an offered tier" };
|
|
@@ -307,8 +517,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
307
517
|
row.tierId = fields.tierId;
|
|
308
518
|
}
|
|
309
519
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
520
|
+
const ops = [
|
|
521
|
+
{ t: "update", ns: "applications", id, attrs: row }
|
|
522
|
+
];
|
|
523
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
524
|
+
ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
|
|
525
|
+
consumedAt: opts.now,
|
|
526
|
+
consumedApplicationId: id
|
|
527
|
+
} });
|
|
528
|
+
}
|
|
310
529
|
const { duplicate } = await db.transact(
|
|
311
|
-
|
|
530
|
+
ops,
|
|
312
531
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
313
532
|
);
|
|
314
533
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
@@ -342,8 +561,8 @@ function tierPayable(tier, group, hasSecretKey) {
|
|
|
342
561
|
function byOrder(a, b) {
|
|
343
562
|
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
344
563
|
}
|
|
345
|
-
function resolveTiers(group,
|
|
346
|
-
const stored =
|
|
564
|
+
function resolveTiers(group, rows4 = []) {
|
|
565
|
+
const stored = rows4.filter((tier) => tier.active);
|
|
347
566
|
if (stored.length) return [...stored].sort(byOrder);
|
|
348
567
|
return [{
|
|
349
568
|
id: "standard",
|
|
@@ -389,15 +608,15 @@ function rowToTier(row, groupId) {
|
|
|
389
608
|
}
|
|
390
609
|
async function loadTiers(db, group) {
|
|
391
610
|
const groupId = String(group.id ?? "");
|
|
392
|
-
let
|
|
611
|
+
let rows4;
|
|
393
612
|
try {
|
|
394
|
-
|
|
613
|
+
rows4 = (await db.query({
|
|
395
614
|
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
396
615
|
})).tiers;
|
|
397
616
|
} catch {
|
|
398
|
-
|
|
617
|
+
rows4 = void 0;
|
|
399
618
|
}
|
|
400
|
-
const list = Array.isArray(
|
|
619
|
+
const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
|
|
401
620
|
return resolveTiers(group, list);
|
|
402
621
|
}
|
|
403
622
|
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
@@ -511,341 +730,252 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
511
730
|
};
|
|
512
731
|
}
|
|
513
732
|
|
|
514
|
-
// src/
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
733
|
+
// src/gift-offers.ts
|
|
734
|
+
var GIFT_OFFER_CATALOG_VERSION = 1;
|
|
735
|
+
var MAX_GIFT_OFFERS = 20;
|
|
736
|
+
var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
|
|
737
|
+
var PRICE = /^price_[A-Za-z0-9]{12,}$/;
|
|
738
|
+
var CURRENCY = /^[a-z]{3}$/;
|
|
739
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
740
|
+
var text = (value, path, errors, max) => {
|
|
741
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
742
|
+
errors.push(`${path} is required`);
|
|
743
|
+
return "";
|
|
744
|
+
}
|
|
745
|
+
if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
|
|
746
|
+
return value.trim();
|
|
747
|
+
};
|
|
748
|
+
function validateGiftOfferCatalog(input, tierIds) {
|
|
749
|
+
const errors = [];
|
|
750
|
+
const root = record(input);
|
|
751
|
+
if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
|
|
752
|
+
for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
|
|
753
|
+
if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
|
|
754
|
+
if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
|
|
755
|
+
const rawOffers = Array.isArray(root.offers) ? root.offers : [];
|
|
756
|
+
if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
|
|
757
|
+
const ids = /* @__PURE__ */ new Set();
|
|
758
|
+
const offers = [];
|
|
759
|
+
for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
|
|
760
|
+
const path = `document.giftOffers.offers[${index}]`;
|
|
761
|
+
const item = record(raw);
|
|
762
|
+
if (!item) {
|
|
763
|
+
errors.push(`${path} must be an object`);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
767
|
+
for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
768
|
+
const id = text(item.id, `${path}.id`, errors, 80);
|
|
769
|
+
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
770
|
+
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
771
|
+
ids.add(id);
|
|
772
|
+
const targetRaw = record(item.target);
|
|
773
|
+
let target = { type: "entitlement", entitlementKey: "invalid" };
|
|
774
|
+
if (targetRaw?.type === "tier") {
|
|
775
|
+
const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
|
|
776
|
+
if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
|
|
777
|
+
target = { type: "tier", tierId };
|
|
778
|
+
} else if (targetRaw?.type === "entitlement") {
|
|
779
|
+
const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
|
|
780
|
+
if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
|
|
781
|
+
target = { type: "entitlement", entitlementKey };
|
|
782
|
+
} else errors.push(`${path}.target.type must be tier or entitlement`);
|
|
783
|
+
if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
|
|
784
|
+
const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
|
|
785
|
+
if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
786
|
+
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`);
|
|
787
|
+
const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
|
|
788
|
+
if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
|
|
789
|
+
const lifetime = Number(item.redemptionLifetimeHours);
|
|
790
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
|
|
791
|
+
const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
|
|
792
|
+
const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
|
|
793
|
+
if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
|
|
794
|
+
if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
|
|
795
|
+
if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
|
|
796
|
+
offers.push({
|
|
797
|
+
id,
|
|
798
|
+
target,
|
|
799
|
+
billingMode: item.billingMode,
|
|
800
|
+
stripePriceId,
|
|
801
|
+
priceCents: Number(item.priceCents),
|
|
802
|
+
currency,
|
|
803
|
+
redemptionLifetimeHours: lifetime,
|
|
804
|
+
...activeFrom !== void 0 ? { activeFrom } : {},
|
|
805
|
+
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
806
|
+
title: text(item.title, `${path}.title`, errors, 160),
|
|
807
|
+
description: text(item.description, `${path}.description`, errors, 2e3),
|
|
808
|
+
ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
|
|
519
812
|
}
|
|
520
|
-
|
|
521
|
-
|
|
813
|
+
var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
|
|
814
|
+
function publicGiftOffers(catalog, now) {
|
|
815
|
+
return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
|
|
522
816
|
}
|
|
523
|
-
function
|
|
524
|
-
return
|
|
817
|
+
function findGiftOffer(catalog, offerId, now) {
|
|
818
|
+
return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
|
|
525
819
|
}
|
|
526
820
|
|
|
527
|
-
// src/
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
821
|
+
// src/clerk-roles.ts
|
|
822
|
+
var CLERK_API = "https://api.clerk.com";
|
|
823
|
+
var DEFAULT_ROLE = "provisional";
|
|
824
|
+
var PAGE = 100;
|
|
825
|
+
function toRecord(u) {
|
|
826
|
+
if (typeof u.id !== "string") return null;
|
|
827
|
+
const pm = u.public_metadata ?? {};
|
|
828
|
+
const privateMetadata = u.private_metadata ?? {};
|
|
829
|
+
const rawProfile = privateMetadata.profile;
|
|
830
|
+
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
831
|
+
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
832
|
+
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
833
|
+
const email = u.email_addresses?.[0]?.email_address;
|
|
834
|
+
return {
|
|
835
|
+
id: u.id,
|
|
836
|
+
email: typeof email === "string" ? email : void 0,
|
|
837
|
+
role,
|
|
838
|
+
publicMetadata: pm,
|
|
839
|
+
privateMetadata,
|
|
840
|
+
profile,
|
|
841
|
+
...applicationId ? { applicationId } : {}
|
|
842
|
+
};
|
|
531
843
|
}
|
|
532
|
-
async function
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
|
|
844
|
+
async function clerkGet(path, secretKey, fetchImpl) {
|
|
845
|
+
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
846
|
+
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
847
|
+
return res.json();
|
|
848
|
+
}
|
|
849
|
+
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
850
|
+
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
851
|
+
const user = Array.isArray(data) ? data[0] : void 0;
|
|
852
|
+
return user ? toRecord(user) : null;
|
|
853
|
+
}
|
|
854
|
+
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
855
|
+
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
856
|
+
return data ? toRecord(data) : null;
|
|
857
|
+
}
|
|
858
|
+
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
859
|
+
const out = [];
|
|
860
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
861
|
+
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
862
|
+
const page = Array.isArray(data) ? data : [];
|
|
863
|
+
for (const u of page) {
|
|
864
|
+
const record3 = toRecord(u);
|
|
865
|
+
if (record3) out.push(record3);
|
|
866
|
+
}
|
|
867
|
+
if (page.length < PAGE) break;
|
|
536
868
|
}
|
|
537
|
-
|
|
538
|
-
return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
869
|
+
return out;
|
|
539
870
|
}
|
|
540
|
-
async function
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
548
|
-
return asStored(latest);
|
|
871
|
+
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
872
|
+
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
873
|
+
method: "PATCH",
|
|
874
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
875
|
+
body: JSON.stringify({ public_metadata: { role } })
|
|
876
|
+
});
|
|
877
|
+
return res.ok;
|
|
549
878
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
879
|
+
|
|
880
|
+
// src/crm-sync.ts
|
|
881
|
+
var import_crm3 = require("@odla-ai/crm");
|
|
882
|
+
|
|
883
|
+
// src/network.ts
|
|
884
|
+
var import_crm2 = require("@odla-ai/crm");
|
|
885
|
+
|
|
886
|
+
// src/network-contract.ts
|
|
887
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
888
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
889
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
// src/network.ts
|
|
893
|
+
function sharedPersonInput(person) {
|
|
894
|
+
const email = person.email.toLowerCase();
|
|
895
|
+
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
896
|
+
const input = { name: person.name ?? fullName ?? email, email };
|
|
897
|
+
if (input.name === "") input.name = email;
|
|
898
|
+
if (person.firstName) input.firstName = person.firstName;
|
|
899
|
+
if (person.lastName) input.lastName = person.lastName;
|
|
900
|
+
if (person.phone) input.phone = person.phone;
|
|
901
|
+
if (person.linkedin) input.linkedin = person.linkedin;
|
|
902
|
+
return input;
|
|
559
903
|
}
|
|
560
|
-
function
|
|
561
|
-
|
|
562
|
-
|
|
904
|
+
function shortHash(value) {
|
|
905
|
+
let a = 2166136261;
|
|
906
|
+
let b = 2654435769;
|
|
907
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
908
|
+
const n = value.charCodeAt(i);
|
|
909
|
+
a = Math.imul(a ^ n, 16777619);
|
|
910
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
911
|
+
}
|
|
912
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
563
913
|
}
|
|
564
|
-
function
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
914
|
+
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
915
|
+
const typeKey = type.toLowerCase();
|
|
916
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
917
|
+
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
918
|
+
const raw = `${prefix}:${hubRecordId}`;
|
|
919
|
+
if (readable && raw.length <= 64) return raw;
|
|
920
|
+
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
568
921
|
}
|
|
569
|
-
function
|
|
570
|
-
|
|
571
|
-
|
|
922
|
+
function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
|
|
923
|
+
if ("input" in record3 && record3.version === 2) {
|
|
924
|
+
return {
|
|
925
|
+
version: 2,
|
|
926
|
+
sourceId: record3.source.siteId,
|
|
927
|
+
sourceRecordId: record3.source.recordId,
|
|
928
|
+
type: record3.type,
|
|
929
|
+
input: record3.input
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
if ("input" in record3) {
|
|
933
|
+
return {
|
|
934
|
+
version: 1,
|
|
935
|
+
sourceId: fallbackSourceId,
|
|
936
|
+
sourceRecordId: record3.hubRecordId,
|
|
937
|
+
type: record3.type,
|
|
938
|
+
input: record3.input
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if ("type" in record3 && record3.type === "company") {
|
|
942
|
+
const input = { name: record3.name };
|
|
943
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
944
|
+
if (record3[key]) input[key] = record3[key];
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
version: 1,
|
|
948
|
+
sourceId: fallbackSourceId,
|
|
949
|
+
sourceRecordId: record3.hubRecordId,
|
|
950
|
+
type: "company",
|
|
951
|
+
input
|
|
952
|
+
};
|
|
953
|
+
}
|
|
572
954
|
return {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
955
|
+
version: 1,
|
|
956
|
+
sourceId: fallbackSourceId,
|
|
957
|
+
sourceRecordId: record3.hubRecordId,
|
|
958
|
+
type: "person",
|
|
959
|
+
input: sharedPersonInput(record3)
|
|
578
960
|
};
|
|
579
961
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
value
|
|
584
|
-
});
|
|
585
|
-
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
586
|
-
const { document, digest } = envelope;
|
|
587
|
-
const currentRows = (await db.query({ tiers: { $: {
|
|
588
|
-
where: { groupId: document.target.chapterId },
|
|
589
|
-
limit: 100
|
|
590
|
-
} } })).tiers ?? [];
|
|
591
|
-
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
592
|
-
const ops = [];
|
|
593
|
-
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
594
|
-
for (const row of currentRows) {
|
|
595
|
-
const id2 = String(row.id ?? "");
|
|
596
|
-
if (id2 && !incoming.has(id2)) {
|
|
597
|
-
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
for (const tier of document.tiers) {
|
|
601
|
-
const tierRef = byPublicId("tiers", tier.id);
|
|
602
|
-
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
603
|
-
id: tier.id,
|
|
604
|
-
groupId: document.target.chapterId,
|
|
605
|
-
name: tier.name,
|
|
606
|
-
priceCents: tier.priceCents,
|
|
607
|
-
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
608
|
-
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
609
|
-
sortOrder: tier.sortOrder,
|
|
610
|
-
active: tier.available,
|
|
611
|
-
createdAt: receivedAt
|
|
612
|
-
} });
|
|
613
|
-
const retract = [
|
|
614
|
-
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
615
|
-
...!tier.blurb ? ["blurb"] : []
|
|
616
|
-
];
|
|
617
|
-
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
618
|
-
}
|
|
619
|
-
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
620
|
-
const attrs = groupAttrs(document);
|
|
621
|
-
ops.push({
|
|
622
|
-
t: "update",
|
|
623
|
-
ns: "groups",
|
|
624
|
-
id: groupRef,
|
|
625
|
-
attrs
|
|
626
|
-
});
|
|
627
|
-
if (!("stripePriceId" in attrs)) {
|
|
628
|
-
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
629
|
-
}
|
|
630
|
-
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
631
|
-
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
632
|
-
id,
|
|
633
|
-
sourceId: document.source.appId,
|
|
634
|
-
targetId: document.target.chapterId,
|
|
635
|
-
environment: document.target.environment,
|
|
636
|
-
revision: document.revision,
|
|
637
|
-
digest,
|
|
638
|
-
document,
|
|
639
|
-
active: true,
|
|
640
|
-
receivedAt
|
|
641
|
-
} });
|
|
642
|
-
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// src/session.ts
|
|
646
|
-
function applicationSummary(app) {
|
|
647
|
-
return {
|
|
648
|
-
id: app.id,
|
|
649
|
-
firstName: app.firstName ?? null,
|
|
650
|
-
lastName: app.lastName ?? null,
|
|
651
|
-
email: app.email ?? null,
|
|
652
|
-
status: app.status,
|
|
653
|
-
createdAt: app.createdAt ?? null,
|
|
654
|
-
meetingLink: app.meetingLink ?? null,
|
|
655
|
-
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
656
|
-
renewalAt: app.renewalAt ?? null,
|
|
657
|
-
canceled: app.canceled === true
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
function memberApplication(app, meeting, defaultTimezone) {
|
|
661
|
-
const summary = applicationSummary(app);
|
|
662
|
-
let meetingAt = app.meetingAt ?? null;
|
|
663
|
-
let meetUrl = null;
|
|
664
|
-
let timezone = defaultTimezone;
|
|
665
|
-
if (meeting) {
|
|
666
|
-
timezone = meeting.timezone ?? timezone;
|
|
667
|
-
if (meeting.status === "scheduled") {
|
|
668
|
-
meetingAt = meeting.startAt ?? null;
|
|
669
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
670
|
-
} else {
|
|
671
|
-
meetingAt = null;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
return { ...summary, meetingAt, meetUrl, timezone };
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// src/clerk.ts
|
|
678
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
679
|
-
function clerkInviteRequest(input) {
|
|
680
|
-
return {
|
|
681
|
-
path: "/v1/invitations",
|
|
682
|
-
body: {
|
|
683
|
-
email_address: input.email,
|
|
684
|
-
notify: true,
|
|
685
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
686
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
687
|
-
}
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
691
|
-
const { path, body } = clerkInviteRequest(input);
|
|
692
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
693
|
-
method: "POST",
|
|
694
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
695
|
-
body: JSON.stringify(body)
|
|
696
|
-
});
|
|
697
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
698
|
-
}
|
|
699
|
-
function clerkUserRequest(input) {
|
|
700
|
-
return {
|
|
701
|
-
path: "/v1/users",
|
|
702
|
-
body: {
|
|
703
|
-
email_address: [input.email],
|
|
704
|
-
skip_password_requirement: true,
|
|
705
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
706
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
707
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
708
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
713
|
-
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
714
|
-
method: "PATCH",
|
|
715
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
716
|
-
body: JSON.stringify({
|
|
717
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
718
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
719
|
-
})
|
|
720
|
-
});
|
|
721
|
-
return res.ok;
|
|
722
|
-
}
|
|
723
|
-
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
724
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
725
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
726
|
-
if (!found.ok) return false;
|
|
727
|
-
const users = await found.json().catch(() => null);
|
|
728
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
729
|
-
if (!id) return false;
|
|
730
|
-
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
731
|
-
}
|
|
732
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
733
|
-
const { path, body } = clerkUserRequest(input);
|
|
734
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
735
|
-
method: "POST",
|
|
736
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
737
|
-
body: JSON.stringify(body)
|
|
738
|
-
});
|
|
739
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
740
|
-
const healed = heal(res.status);
|
|
741
|
-
const repair = input.repairMetadata ?? {
|
|
742
|
-
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
743
|
-
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
744
|
-
};
|
|
745
|
-
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
746
|
-
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
747
|
-
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
748
|
-
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
749
|
-
}, fetchImpl).catch(() => false);
|
|
750
|
-
return { ...healed, refreshed };
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// src/network.ts
|
|
754
|
-
var import_crm2 = require("@odla-ai/crm");
|
|
755
|
-
|
|
756
|
-
// src/network-contract.ts
|
|
757
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
758
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
759
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
// src/network.ts
|
|
763
|
-
function sharedPersonInput(person) {
|
|
764
|
-
const email = person.email.toLowerCase();
|
|
765
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
766
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
767
|
-
if (input.name === "") input.name = email;
|
|
768
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
769
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
770
|
-
if (person.phone) input.phone = person.phone;
|
|
771
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
772
|
-
return input;
|
|
773
|
-
}
|
|
774
|
-
function shortHash(value) {
|
|
775
|
-
let a = 2166136261;
|
|
776
|
-
let b = 2654435769;
|
|
777
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
778
|
-
const n = value.charCodeAt(i);
|
|
779
|
-
a = Math.imul(a ^ n, 16777619);
|
|
780
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
781
|
-
}
|
|
782
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
783
|
-
}
|
|
784
|
-
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
785
|
-
const typeKey = type.toLowerCase();
|
|
786
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
787
|
-
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
788
|
-
const raw = `${prefix}:${hubRecordId}`;
|
|
789
|
-
if (readable && raw.length <= 64) return raw;
|
|
790
|
-
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
791
|
-
}
|
|
792
|
-
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
793
|
-
if ("input" in record && record.version === 2) {
|
|
794
|
-
return {
|
|
795
|
-
version: 2,
|
|
796
|
-
sourceId: record.source.siteId,
|
|
797
|
-
sourceRecordId: record.source.recordId,
|
|
798
|
-
type: record.type,
|
|
799
|
-
input: record.input
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
if ("input" in record) {
|
|
803
|
-
return {
|
|
804
|
-
version: 1,
|
|
805
|
-
sourceId: fallbackSourceId,
|
|
806
|
-
sourceRecordId: record.hubRecordId,
|
|
807
|
-
type: record.type,
|
|
808
|
-
input: record.input
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
if ("type" in record && record.type === "company") {
|
|
812
|
-
const input = { name: record.name };
|
|
813
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
814
|
-
if (record[key]) input[key] = record[key];
|
|
815
|
-
}
|
|
816
|
-
return {
|
|
817
|
-
version: 1,
|
|
818
|
-
sourceId: fallbackSourceId,
|
|
819
|
-
sourceRecordId: record.hubRecordId,
|
|
820
|
-
type: "company",
|
|
821
|
-
input
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
return {
|
|
825
|
-
version: 1,
|
|
826
|
-
sourceId: fallbackSourceId,
|
|
827
|
-
sourceRecordId: record.hubRecordId,
|
|
828
|
-
type: "person",
|
|
829
|
-
input: sharedPersonInput(record)
|
|
830
|
-
};
|
|
831
|
-
}
|
|
832
|
-
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
833
|
-
if (target.fields && !target.fields[record.type]) {
|
|
834
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
962
|
+
function sharedRecordFromCrm(crm, record3, target, sourceId) {
|
|
963
|
+
if (target.fields && !target.fields[record3.type]) {
|
|
964
|
+
throw new Error(`${target.name} does not accept "${record3.type}" records`);
|
|
835
965
|
}
|
|
836
|
-
const def = crm.type(
|
|
837
|
-
const fields = target.fields?.[
|
|
966
|
+
const def = crm.type(record3.type);
|
|
967
|
+
const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
|
|
838
968
|
if (!fields) {
|
|
839
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
969
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
|
|
840
970
|
}
|
|
841
971
|
const nameField = def.nameField ?? "name";
|
|
842
972
|
const input = {};
|
|
843
973
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
844
|
-
const value =
|
|
974
|
+
const value = record3.fields?.[field];
|
|
845
975
|
if (value !== void 0) input[field] = value;
|
|
846
976
|
}
|
|
847
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
848
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
977
|
+
if (input[nameField] === void 0) input[nameField] = record3.name;
|
|
978
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
|
|
849
979
|
}
|
|
850
980
|
async function upsertPerson(deps, opts) {
|
|
851
981
|
const email = opts.email.toLowerCase();
|
|
@@ -859,11 +989,11 @@ async function upsertPerson(deps, opts) {
|
|
|
859
989
|
const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
860
990
|
return { recordId: created.id };
|
|
861
991
|
}
|
|
862
|
-
async function findSharedRecord(deps,
|
|
992
|
+
async function findSharedRecord(deps, record3, tag) {
|
|
863
993
|
const structured = await (0, import_crm2.getRecordByOrigin)(
|
|
864
994
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
865
|
-
|
|
866
|
-
|
|
995
|
+
record3.sourceId,
|
|
996
|
+
record3.sourceRecordId
|
|
867
997
|
);
|
|
868
998
|
if (structured) return structured;
|
|
869
999
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -872,189 +1002,609 @@ async function findSharedRecord(deps, record, tag) {
|
|
|
872
1002
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
873
1003
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
874
1004
|
}
|
|
875
|
-
const def = deps.crm.type(
|
|
1005
|
+
const def = deps.crm.type(record3.type);
|
|
876
1006
|
const emailField = def.emailField;
|
|
877
|
-
if (emailField && typeof
|
|
878
|
-
const primaryEmail =
|
|
1007
|
+
if (emailField && typeof record3.input[emailField] === "string") {
|
|
1008
|
+
const primaryEmail = record3.input[emailField].toLowerCase();
|
|
879
1009
|
const found = await deps.db.query({
|
|
880
|
-
crm_record: { $: { where: { type:
|
|
1010
|
+
crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
|
|
881
1011
|
});
|
|
882
1012
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
883
1013
|
}
|
|
884
|
-
const domain =
|
|
1014
|
+
const domain = record3.input.domain;
|
|
885
1015
|
const domainSlot = def.fields.domain?.slot;
|
|
886
1016
|
if (typeof domain === "string" && domainSlot) {
|
|
887
1017
|
const found = await deps.db.query({
|
|
888
|
-
crm_record: { $: { where: { type:
|
|
1018
|
+
crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
|
|
889
1019
|
});
|
|
890
1020
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
891
1021
|
}
|
|
892
1022
|
const nameField = def.nameField ?? "name";
|
|
893
|
-
const name =
|
|
894
|
-
if (
|
|
1023
|
+
const name = record3.input[nameField];
|
|
1024
|
+
if (record3.type === "company" && typeof name === "string" && name.trim()) {
|
|
895
1025
|
const found = await deps.db.query({
|
|
896
|
-
crm_record: { $: { where: { type:
|
|
1026
|
+
crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
|
|
897
1027
|
});
|
|
898
1028
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
899
1029
|
}
|
|
900
1030
|
return void 0;
|
|
901
1031
|
}
|
|
902
1032
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
903
|
-
const
|
|
904
|
-
if (!
|
|
1033
|
+
const record3 = normalizeSharedRecord(shared, options.sourceId);
|
|
1034
|
+
if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
|
|
905
1035
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
906
1036
|
}
|
|
907
|
-
if (options.sourceId && options.sourceId !==
|
|
1037
|
+
if (options.sourceId && options.sourceId !== record3.sourceId) {
|
|
908
1038
|
throw new Error("signed sender does not match payload source");
|
|
909
1039
|
}
|
|
910
|
-
const tag = networkSourceTag(
|
|
1040
|
+
const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
|
|
911
1041
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
912
|
-
const existing = await findSharedRecord(deps,
|
|
1042
|
+
const existing = await findSharedRecord(deps, record3, tag);
|
|
913
1043
|
let recordId;
|
|
914
1044
|
if (existing && typeof existing.id === "string") {
|
|
915
|
-
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input:
|
|
1045
|
+
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record3.input });
|
|
916
1046
|
recordId = existing.id;
|
|
917
1047
|
} else {
|
|
918
|
-
recordId = `network_${shortHash(`${
|
|
1048
|
+
recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
|
|
919
1049
|
await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
|
|
920
|
-
type:
|
|
921
|
-
input:
|
|
1050
|
+
type: record3.type,
|
|
1051
|
+
input: record3.input,
|
|
922
1052
|
mutationId: `share-create:${tag}`
|
|
923
1053
|
});
|
|
924
1054
|
}
|
|
925
|
-
await deps.db.transact(
|
|
926
|
-
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
927
|
-
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
928
|
-
);
|
|
929
|
-
await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
|
|
930
|
-
recordId,
|
|
931
|
-
sourceId:
|
|
932
|
-
sourceRecordId:
|
|
933
|
-
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
934
|
-
payloadVersion:
|
|
935
|
-
});
|
|
936
|
-
return { recordId };
|
|
937
|
-
}
|
|
938
|
-
async function projectApplicant(deps, applicant) {
|
|
939
|
-
const base = sharedPersonInput({
|
|
940
|
-
email: applicant.email,
|
|
941
|
-
firstName: applicant.firstName,
|
|
942
|
-
lastName: applicant.lastName,
|
|
943
|
-
phone: applicant.phone,
|
|
944
|
-
linkedin: applicant.linkedin,
|
|
945
|
-
hubRecordId: applicant.applicationId
|
|
1055
|
+
await deps.db.transact(
|
|
1056
|
+
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
1057
|
+
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
1058
|
+
);
|
|
1059
|
+
await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
|
|
1060
|
+
recordId,
|
|
1061
|
+
sourceId: record3.sourceId,
|
|
1062
|
+
sourceRecordId: record3.sourceRecordId,
|
|
1063
|
+
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
1064
|
+
payloadVersion: record3.version
|
|
1065
|
+
});
|
|
1066
|
+
return { recordId };
|
|
1067
|
+
}
|
|
1068
|
+
async function projectApplicant(deps, applicant) {
|
|
1069
|
+
const base = sharedPersonInput({
|
|
1070
|
+
email: applicant.email,
|
|
1071
|
+
firstName: applicant.firstName,
|
|
1072
|
+
lastName: applicant.lastName,
|
|
1073
|
+
phone: applicant.phone,
|
|
1074
|
+
linkedin: applicant.linkedin,
|
|
1075
|
+
hubRecordId: applicant.applicationId
|
|
1076
|
+
});
|
|
1077
|
+
const mutationId = `apply:${applicant.applicationId}`;
|
|
1078
|
+
const extra = applicant.extra ?? {};
|
|
1079
|
+
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
1080
|
+
try {
|
|
1081
|
+
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
1082
|
+
} catch {
|
|
1083
|
+
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/crm-sync.ts
|
|
1088
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1089
|
+
var crmDeps = (db, ctx) => ({
|
|
1090
|
+
crm: ctx.chapter.crm,
|
|
1091
|
+
db,
|
|
1092
|
+
now: () => Date.now(),
|
|
1093
|
+
newId: () => crypto.randomUUID(),
|
|
1094
|
+
chapter: ctx.chapter
|
|
1095
|
+
});
|
|
1096
|
+
function personInputFromApp(chapter, app) {
|
|
1097
|
+
const input = sharedPersonInput({
|
|
1098
|
+
email: str(app.email),
|
|
1099
|
+
firstName: str(app.firstName) || void 0,
|
|
1100
|
+
lastName: str(app.lastName) || void 0,
|
|
1101
|
+
phone: str(app.phone) || void 0,
|
|
1102
|
+
linkedin: str(app.linkedin) || void 0,
|
|
1103
|
+
hubRecordId: str(app.id)
|
|
1104
|
+
});
|
|
1105
|
+
for (const f of chapter.application.crmFields) {
|
|
1106
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
1107
|
+
}
|
|
1108
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1109
|
+
return input;
|
|
1110
|
+
}
|
|
1111
|
+
function billingColumns(app) {
|
|
1112
|
+
const status = str(app.status);
|
|
1113
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1114
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1115
|
+
const cols = { billingStatus };
|
|
1116
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1117
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1118
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1119
|
+
return cols;
|
|
1120
|
+
}
|
|
1121
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
1122
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
1123
|
+
if (!emailKey) return null;
|
|
1124
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1125
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1126
|
+
const { crm_record } = await deps.db.query({
|
|
1127
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1128
|
+
});
|
|
1129
|
+
const existing = crm_record?.[0] ?? null;
|
|
1130
|
+
const stage = opts.stage || void 0;
|
|
1131
|
+
let recordId;
|
|
1132
|
+
if (existing && typeof existing.id === "string") {
|
|
1133
|
+
recordId = existing.id;
|
|
1134
|
+
await (0, import_crm3.updateRecord)(recordDeps, { id: recordId, input });
|
|
1135
|
+
} else {
|
|
1136
|
+
const created = await (0, import_crm3.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1137
|
+
recordId = created.id;
|
|
1138
|
+
}
|
|
1139
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1140
|
+
await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1141
|
+
}
|
|
1142
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1143
|
+
await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1144
|
+
return recordId;
|
|
1145
|
+
}
|
|
1146
|
+
async function backfillCrm(deps) {
|
|
1147
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1148
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1149
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1150
|
+
]);
|
|
1151
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1152
|
+
let synced = 0;
|
|
1153
|
+
const errors = [];
|
|
1154
|
+
const run = async (app, stage) => {
|
|
1155
|
+
const key = str(app.email).toLowerCase();
|
|
1156
|
+
if (!key || seen.has(key)) return;
|
|
1157
|
+
seen.add(key);
|
|
1158
|
+
try {
|
|
1159
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1160
|
+
synced += 1;
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1166
|
+
for (const u of usersRes.$users ?? []) {
|
|
1167
|
+
if (u.deleted === true) continue;
|
|
1168
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1169
|
+
}
|
|
1170
|
+
return { synced, errors };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/email.ts
|
|
1174
|
+
function render(template, vars) {
|
|
1175
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
1176
|
+
}
|
|
1177
|
+
function groupVars(group, vars) {
|
|
1178
|
+
return {
|
|
1179
|
+
...vars,
|
|
1180
|
+
refundPolicyText: group.refundPolicyText ?? "",
|
|
1181
|
+
commitmentText: group.commitmentText ?? "",
|
|
1182
|
+
normsText: group.normsText ?? ""
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
function renderTemplateBody(group, template, vars) {
|
|
1186
|
+
const tpl = group.emailTemplates?.[template];
|
|
1187
|
+
if (!tpl) return null;
|
|
1188
|
+
return render(tpl.text, groupVars(group, vars));
|
|
1189
|
+
}
|
|
1190
|
+
function isAlreadySent(priorRows) {
|
|
1191
|
+
return priorRows.some((row) => !row.error);
|
|
1192
|
+
}
|
|
1193
|
+
function planDelivery(input) {
|
|
1194
|
+
const tpl = input.group.emailTemplates?.[input.template];
|
|
1195
|
+
if (!tpl) return { deliver: false, reason: "template-missing" };
|
|
1196
|
+
if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
|
|
1197
|
+
const vars = groupVars(input.group, input.vars);
|
|
1198
|
+
const isProd = input.envName === "prod";
|
|
1199
|
+
const redirect = !isProd && !!input.group.debugEmail;
|
|
1200
|
+
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
1201
|
+
const to = redirect ? input.group.debugEmail : input.to;
|
|
1202
|
+
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
1203
|
+
const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
1204
|
+
|
|
1205
|
+
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
1206
|
+
return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/notify.ts
|
|
1210
|
+
var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
|
|
1211
|
+
async function sendTemplated(deps, input) {
|
|
1212
|
+
const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
|
|
1213
|
+
const prior = Array.isArray(emailLog) ? emailLog : [];
|
|
1214
|
+
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
1215
|
+
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
1216
|
+
const decision = planDelivery({
|
|
1217
|
+
envName: deps.envName,
|
|
1218
|
+
group: input.group,
|
|
1219
|
+
template: input.template,
|
|
1220
|
+
to: input.to,
|
|
1221
|
+
vars: input.vars,
|
|
1222
|
+
cloudflareReady,
|
|
1223
|
+
force: input.force
|
|
1224
|
+
});
|
|
1225
|
+
if (!decision.deliver) return { sent: false, reason: decision.reason };
|
|
1226
|
+
let error;
|
|
1227
|
+
let messageId;
|
|
1228
|
+
if (decision.transport === "cloudflare" && deps.sender && deps.from) {
|
|
1229
|
+
try {
|
|
1230
|
+
const res = await deps.sender.send({
|
|
1231
|
+
from: deps.from,
|
|
1232
|
+
to: [decision.to],
|
|
1233
|
+
subject: decision.subject,
|
|
1234
|
+
text: decision.text,
|
|
1235
|
+
replyTo: input.group.replyTo
|
|
1236
|
+
});
|
|
1237
|
+
messageId = res.messageId;
|
|
1238
|
+
} catch (e) {
|
|
1239
|
+
error = e instanceof Error ? e.message : String(e);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const id = deps.newId();
|
|
1243
|
+
const row = {
|
|
1244
|
+
id,
|
|
1245
|
+
groupId: input.group.id,
|
|
1246
|
+
to: decision.to,
|
|
1247
|
+
template: input.template,
|
|
1248
|
+
subject: decision.subject,
|
|
1249
|
+
body: decision.text,
|
|
1250
|
+
transport: decision.transport,
|
|
1251
|
+
redirected: decision.redirected,
|
|
1252
|
+
dedupeKey: input.dedupeKey,
|
|
1253
|
+
sentAt: deps.now(),
|
|
1254
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1255
|
+
...messageId ? { messageId } : {},
|
|
1256
|
+
...error ? { error } : {}
|
|
1257
|
+
};
|
|
1258
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1259
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
1260
|
+
}
|
|
1261
|
+
function emailGroupFrom(row) {
|
|
1262
|
+
const str3 = (v) => typeof v === "string" ? v : void 0;
|
|
1263
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
1264
|
+
return {
|
|
1265
|
+
id: String(row.id),
|
|
1266
|
+
name: String(row.name ?? ""),
|
|
1267
|
+
replyTo: str3(row.replyTo) ?? "",
|
|
1268
|
+
debugEmail: str3(row.debugEmail),
|
|
1269
|
+
refundPolicyText: str3(row.refundPolicyText),
|
|
1270
|
+
commitmentText: str3(row.commitmentText),
|
|
1271
|
+
normsText: str3(row.normsText),
|
|
1272
|
+
emailTemplates: templates
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// src/pipeline.ts
|
|
1277
|
+
function canTransition(from, to, p) {
|
|
1278
|
+
const fi = p.stages.indexOf(from);
|
|
1279
|
+
const ti = p.stages.indexOf(to);
|
|
1280
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
1281
|
+
}
|
|
1282
|
+
function canBook(status, p) {
|
|
1283
|
+
return p.bookableFrom.includes(status);
|
|
1284
|
+
}
|
|
1285
|
+
function canApprove(status, p) {
|
|
1286
|
+
return p.approvableFrom.includes(status);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/approve-application.ts
|
|
1290
|
+
async function approveApplication(input) {
|
|
1291
|
+
const { db, env, ctx, app } = input;
|
|
1292
|
+
const id = String(app.id ?? "");
|
|
1293
|
+
const status = String(app.status ?? "");
|
|
1294
|
+
if (!id) return { ok: false, status: 404, error: "not found" };
|
|
1295
|
+
if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
|
|
1296
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1297
|
+
}
|
|
1298
|
+
if (input.reason === "interview_waiver") {
|
|
1299
|
+
if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
|
|
1300
|
+
if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
|
|
1301
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const write = await db.transact(
|
|
1305
|
+
[{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
|
|
1306
|
+
{ mutationId: `approve:${id}` }
|
|
1307
|
+
);
|
|
1308
|
+
const approved = { ...app, status: "approved" };
|
|
1309
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
|
|
1310
|
+
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
1311
|
+
let rolePromoted = false;
|
|
1312
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1313
|
+
if (promoteTo !== false && secret) {
|
|
1314
|
+
let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
|
|
1315
|
+
if (!userId && typeof app.email === "string") {
|
|
1316
|
+
const found = await clerkGetUserByEmail(secret, app.email);
|
|
1317
|
+
userId = found?.id ?? null;
|
|
1318
|
+
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
1319
|
+
}
|
|
1320
|
+
if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
|
|
1321
|
+
}
|
|
1322
|
+
let emailLogged = false;
|
|
1323
|
+
const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
|
|
1324
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1325
|
+
if (send !== false && group && typeof app.email === "string") {
|
|
1326
|
+
const result = await sendTemplated(
|
|
1327
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1328
|
+
{
|
|
1329
|
+
group: emailGroupFrom(group),
|
|
1330
|
+
template: send,
|
|
1331
|
+
to: app.email,
|
|
1332
|
+
vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
|
|
1333
|
+
applicationId: id,
|
|
1334
|
+
dedupeKey: `approve:${id}`
|
|
1335
|
+
}
|
|
1336
|
+
);
|
|
1337
|
+
emailLogged = result.sent;
|
|
1338
|
+
}
|
|
1339
|
+
return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/join-continuation.ts
|
|
1343
|
+
function resolveJoinContinuation(input) {
|
|
1344
|
+
if (input.status === "approved") return "accepted";
|
|
1345
|
+
if (input.interviewWaived) {
|
|
1346
|
+
if (!input.payable) return "approve";
|
|
1347
|
+
if (input.status === input.initialStatus) {
|
|
1348
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1349
|
+
}
|
|
1350
|
+
return "approve";
|
|
1351
|
+
}
|
|
1352
|
+
if (!input.payable) return "booking";
|
|
1353
|
+
if (input.status === input.initialStatus) {
|
|
1354
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1355
|
+
}
|
|
1356
|
+
if (canBook(input.status, input.pipeline)) return "booking";
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// src/join-continuation-server.ts
|
|
1361
|
+
async function continueJoin(db, ctx, env, origin, app, group) {
|
|
1362
|
+
if (typeof app.giftOfferId === "string") {
|
|
1363
|
+
if (typeof app.giftClaimId === "string") return "gift";
|
|
1364
|
+
return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
|
|
1365
|
+
}
|
|
1366
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1367
|
+
const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
|
|
1368
|
+
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1369
|
+
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1370
|
+
const state = resolveJoinContinuation({
|
|
1371
|
+
status: String(app.status ?? ""),
|
|
1372
|
+
initialStatus: ctx.chapter.pipeline.initial,
|
|
1373
|
+
pipeline: ctx.chapter.pipeline,
|
|
1374
|
+
payable,
|
|
1375
|
+
subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
|
|
1376
|
+
interviewWaived: typeof app.interviewWaivedAt === "number"
|
|
1377
|
+
});
|
|
1378
|
+
if (state !== "approve") return state;
|
|
1379
|
+
const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
|
|
1380
|
+
return approved.ok ? "accepted" : null;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// src/signup-control-store.ts
|
|
1384
|
+
function asStored(row) {
|
|
1385
|
+
if (!row || typeof row.document !== "object" || row.document === null) return null;
|
|
1386
|
+
return row;
|
|
1387
|
+
}
|
|
1388
|
+
async function hasSignupControl(db, targetId, environment, active) {
|
|
1389
|
+
const where = { targetId, environment, ...active ? { active: true } : {} };
|
|
1390
|
+
if (db.aggregate) {
|
|
1391
|
+
return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
|
|
1392
|
+
}
|
|
1393
|
+
const rows4 = (await db.query({ signupControls: {} })).signupControls;
|
|
1394
|
+
return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
1395
|
+
}
|
|
1396
|
+
async function loadLatestSignupControl(db, targetId, environment) {
|
|
1397
|
+
if (!await hasSignupControl(db, targetId, environment)) return null;
|
|
1398
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1399
|
+
where: { targetId, environment },
|
|
1400
|
+
order: { revision: "desc" },
|
|
1401
|
+
limit: 1
|
|
1402
|
+
} } })).signupControls;
|
|
1403
|
+
const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1404
|
+
return asStored(latest);
|
|
1405
|
+
}
|
|
1406
|
+
async function loadActiveSignupControl(db, targetId, environment) {
|
|
1407
|
+
if (!await hasSignupControl(db, targetId, environment, true)) return null;
|
|
1408
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1409
|
+
where: { targetId, environment, active: true },
|
|
1410
|
+
order: { revision: "desc" },
|
|
1411
|
+
limit: 1
|
|
1412
|
+
} } })).signupControls;
|
|
1413
|
+
const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1414
|
+
return asStored(active);
|
|
1415
|
+
}
|
|
1416
|
+
function controlledApplication(base, document) {
|
|
1417
|
+
const delegated = document?.application;
|
|
1418
|
+
return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
|
|
1419
|
+
}
|
|
1420
|
+
function validateControlledApplication(chapter, document) {
|
|
1421
|
+
if (!document.application) return [];
|
|
1422
|
+
const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
|
|
1423
|
+
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`);
|
|
1424
|
+
}
|
|
1425
|
+
function groupAttrs(document) {
|
|
1426
|
+
const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
1427
|
+
const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
|
|
1428
|
+
return {
|
|
1429
|
+
...first4 ? { standardPriceCents: first4.priceCents } : {},
|
|
1430
|
+
foundingDiscountCents: 0,
|
|
1431
|
+
...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
|
|
1432
|
+
...document.policy ?? {},
|
|
1433
|
+
...document.scheduling ? { schedulingJson: document.scheduling } : {}
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
var byPublicId = (ns, value) => ({
|
|
1437
|
+
ns,
|
|
1438
|
+
attr: "id",
|
|
1439
|
+
value
|
|
1440
|
+
});
|
|
1441
|
+
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
1442
|
+
const { document, digest } = envelope;
|
|
1443
|
+
const currentRows = (await db.query({ tiers: { $: {
|
|
1444
|
+
where: { groupId: document.target.chapterId },
|
|
1445
|
+
limit: 100
|
|
1446
|
+
} } })).tiers ?? [];
|
|
1447
|
+
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
1448
|
+
const ops = [];
|
|
1449
|
+
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
1450
|
+
for (const row of currentRows) {
|
|
1451
|
+
const id2 = String(row.id ?? "");
|
|
1452
|
+
if (id2 && !incoming.has(id2)) {
|
|
1453
|
+
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
for (const tier of document.tiers) {
|
|
1457
|
+
const tierRef = byPublicId("tiers", tier.id);
|
|
1458
|
+
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
1459
|
+
id: tier.id,
|
|
1460
|
+
groupId: document.target.chapterId,
|
|
1461
|
+
name: tier.name,
|
|
1462
|
+
priceCents: tier.priceCents,
|
|
1463
|
+
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
1464
|
+
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
1465
|
+
sortOrder: tier.sortOrder,
|
|
1466
|
+
active: tier.available,
|
|
1467
|
+
createdAt: receivedAt
|
|
1468
|
+
} });
|
|
1469
|
+
const retract = [
|
|
1470
|
+
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
1471
|
+
...!tier.blurb ? ["blurb"] : []
|
|
1472
|
+
];
|
|
1473
|
+
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
1474
|
+
}
|
|
1475
|
+
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
1476
|
+
const attrs = groupAttrs(document);
|
|
1477
|
+
ops.push({
|
|
1478
|
+
t: "update",
|
|
1479
|
+
ns: "groups",
|
|
1480
|
+
id: groupRef,
|
|
1481
|
+
attrs
|
|
946
1482
|
});
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
950
|
-
try {
|
|
951
|
-
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
952
|
-
} catch {
|
|
953
|
-
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
1483
|
+
if (!("stripePriceId" in attrs)) {
|
|
1484
|
+
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
954
1485
|
}
|
|
1486
|
+
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
1487
|
+
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
1488
|
+
id,
|
|
1489
|
+
sourceId: document.source.appId,
|
|
1490
|
+
targetId: document.target.chapterId,
|
|
1491
|
+
environment: document.target.environment,
|
|
1492
|
+
revision: document.revision,
|
|
1493
|
+
digest,
|
|
1494
|
+
document,
|
|
1495
|
+
active: true,
|
|
1496
|
+
receivedAt
|
|
1497
|
+
} });
|
|
1498
|
+
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
955
1499
|
}
|
|
956
1500
|
|
|
957
|
-
// src/
|
|
958
|
-
function
|
|
959
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
960
|
-
}
|
|
961
|
-
function groupVars(group, vars) {
|
|
1501
|
+
// src/session.ts
|
|
1502
|
+
function applicationSummary(app) {
|
|
962
1503
|
return {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1504
|
+
id: app.id,
|
|
1505
|
+
firstName: app.firstName ?? null,
|
|
1506
|
+
lastName: app.lastName ?? null,
|
|
1507
|
+
email: app.email ?? null,
|
|
1508
|
+
status: app.status,
|
|
1509
|
+
createdAt: app.createdAt ?? null,
|
|
1510
|
+
meetingLink: app.meetingLink ?? null,
|
|
1511
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1512
|
+
renewalAt: app.renewalAt ?? null,
|
|
1513
|
+
canceled: app.canceled === true,
|
|
1514
|
+
tierId: app.tierId ?? null
|
|
967
1515
|
};
|
|
968
1516
|
}
|
|
969
|
-
function
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1517
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
1518
|
+
const summary = applicationSummary(app);
|
|
1519
|
+
let meetingAt = app.meetingAt ?? null;
|
|
1520
|
+
let meetUrl = null;
|
|
1521
|
+
let timezone = defaultTimezone;
|
|
1522
|
+
if (meeting) {
|
|
1523
|
+
timezone = meeting.timezone ?? timezone;
|
|
1524
|
+
if (meeting.status === "scheduled") {
|
|
1525
|
+
meetingAt = meeting.startAt ?? null;
|
|
1526
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
1527
|
+
} else {
|
|
1528
|
+
meetingAt = null;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
976
1532
|
}
|
|
977
|
-
function planDelivery(input) {
|
|
978
|
-
const tpl = input.group.emailTemplates?.[input.template];
|
|
979
|
-
if (!tpl) return { deliver: false, reason: "template-missing" };
|
|
980
|
-
if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
|
|
981
|
-
const vars = groupVars(input.group, input.vars);
|
|
982
|
-
const isProd = input.envName === "prod";
|
|
983
|
-
const redirect = !isProd && !!input.group.debugEmail;
|
|
984
|
-
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
985
|
-
const to = redirect ? input.group.debugEmail : input.to;
|
|
986
|
-
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
987
|
-
const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
988
1533
|
|
|
989
|
-
|
|
990
|
-
|
|
1534
|
+
// src/clerk.ts
|
|
1535
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
1536
|
+
function clerkInviteRequest(input) {
|
|
1537
|
+
return {
|
|
1538
|
+
path: "/v1/invitations",
|
|
1539
|
+
body: {
|
|
1540
|
+
email_address: input.email,
|
|
1541
|
+
notify: true,
|
|
1542
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
1543
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
991
1546
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
999
|
-
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
1000
|
-
const decision = planDelivery({
|
|
1001
|
-
envName: deps.envName,
|
|
1002
|
-
group: input.group,
|
|
1003
|
-
template: input.template,
|
|
1004
|
-
to: input.to,
|
|
1005
|
-
vars: input.vars,
|
|
1006
|
-
cloudflareReady,
|
|
1007
|
-
force: input.force
|
|
1547
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
1548
|
+
const { path, body } = clerkInviteRequest(input);
|
|
1549
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1552
|
+
body: JSON.stringify(body)
|
|
1008
1553
|
});
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
}
|
|
1021
|
-
messageId = res.messageId;
|
|
1022
|
-
} catch (e) {
|
|
1023
|
-
error = e instanceof Error ? e.message : String(e);
|
|
1554
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
1555
|
+
}
|
|
1556
|
+
function clerkUserRequest(input) {
|
|
1557
|
+
return {
|
|
1558
|
+
path: "/v1/users",
|
|
1559
|
+
body: {
|
|
1560
|
+
email_address: [input.email],
|
|
1561
|
+
skip_password_requirement: true,
|
|
1562
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
1563
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
1564
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1565
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1024
1566
|
}
|
|
1025
|
-
}
|
|
1026
|
-
const id = deps.newId();
|
|
1027
|
-
const row = {
|
|
1028
|
-
id,
|
|
1029
|
-
groupId: input.group.id,
|
|
1030
|
-
to: decision.to,
|
|
1031
|
-
template: input.template,
|
|
1032
|
-
subject: decision.subject,
|
|
1033
|
-
body: decision.text,
|
|
1034
|
-
transport: decision.transport,
|
|
1035
|
-
redirected: decision.redirected,
|
|
1036
|
-
dedupeKey: input.dedupeKey,
|
|
1037
|
-
sentAt: deps.now(),
|
|
1038
|
-
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
1039
|
-
...messageId ? { messageId } : {},
|
|
1040
|
-
...error ? { error } : {}
|
|
1041
1567
|
};
|
|
1042
|
-
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
1043
|
-
return error ? { sent: false, reason: error } : { sent: true };
|
|
1044
1568
|
}
|
|
1045
|
-
function
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1569
|
+
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
1570
|
+
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
1571
|
+
method: "PATCH",
|
|
1572
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1573
|
+
body: JSON.stringify({
|
|
1574
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1575
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1576
|
+
})
|
|
1577
|
+
});
|
|
1578
|
+
return res.ok;
|
|
1579
|
+
}
|
|
1580
|
+
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
1581
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
1582
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
1583
|
+
if (!found.ok) return false;
|
|
1584
|
+
const users = await found.json().catch(() => null);
|
|
1585
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
1586
|
+
if (!id) return false;
|
|
1587
|
+
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
1588
|
+
}
|
|
1589
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
1590
|
+
const { path, body } = clerkUserRequest(input);
|
|
1591
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1592
|
+
method: "POST",
|
|
1593
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1594
|
+
body: JSON.stringify(body)
|
|
1595
|
+
});
|
|
1596
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
1597
|
+
const healed = heal(res.status);
|
|
1598
|
+
const repair = input.repairMetadata ?? {
|
|
1599
|
+
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
1600
|
+
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
1057
1601
|
};
|
|
1602
|
+
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
1603
|
+
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
1604
|
+
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
1605
|
+
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
1606
|
+
}, fetchImpl).catch(() => false);
|
|
1607
|
+
return { ...healed, refreshed };
|
|
1058
1608
|
}
|
|
1059
1609
|
|
|
1060
1610
|
// src/worker-provisioning.ts
|
|
@@ -1184,8 +1734,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1184
1734
|
|
|
1185
1735
|
// src/worker-routes.ts
|
|
1186
1736
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1737
|
+
let apps = (await db.query({ applications: { $: {
|
|
1738
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
1739
|
+
limit: 20
|
|
1740
|
+
} } })).applications;
|
|
1741
|
+
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
1742
|
+
const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
1189
1743
|
if (!app) return null;
|
|
1190
1744
|
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
1191
1745
|
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
@@ -1241,7 +1795,7 @@ var handleCrm = async (req, url, env, ctx) => {
|
|
|
1241
1795
|
const crmBase = ctx.crmBase;
|
|
1242
1796
|
if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
|
|
1243
1797
|
const db = ctx.makeDb(env);
|
|
1244
|
-
const routes = (0,
|
|
1798
|
+
const routes = (0, import_crm4.createCrmRoutes)({
|
|
1245
1799
|
crm: ctx.chapter.crm,
|
|
1246
1800
|
db,
|
|
1247
1801
|
authorize: async (r) => {
|
|
@@ -1287,7 +1841,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1287
1841
|
membershipPage: activeControl.document.membershipPage,
|
|
1288
1842
|
application: activeControl.document.application,
|
|
1289
1843
|
theme: activeControl.document.theme
|
|
1290
|
-
} } : {}
|
|
1844
|
+
} } : {},
|
|
1845
|
+
giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
|
|
1291
1846
|
});
|
|
1292
1847
|
}
|
|
1293
1848
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
@@ -1326,18 +1881,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1326
1881
|
})).groups;
|
|
1327
1882
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1328
1883
|
if (!group) return json({ error: "not found" }, 404);
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1333
|
-
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1334
|
-
if (!payable) return json({ state: "booking", applicationId });
|
|
1335
|
-
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1336
|
-
return json({ state: "paymentPending", applicationId });
|
|
1337
|
-
}
|
|
1338
|
-
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
1339
|
-
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
1340
|
-
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
1884
|
+
const state = await continueJoin(db, ctx, env, url.origin, app, group);
|
|
1885
|
+
if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
|
|
1886
|
+
return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
|
|
1341
1887
|
}
|
|
1342
1888
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
1343
1889
|
const raw = await req.text();
|
|
@@ -1353,12 +1899,19 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1353
1899
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
1354
1900
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
1355
1901
|
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
1902
|
+
const now = Date.now();
|
|
1903
|
+
const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
|
|
1904
|
+
const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
|
|
1905
|
+
if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
|
|
1906
|
+
const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
|
|
1356
1907
|
const result = await submitApplication(db, effectiveChapter, parsed, {
|
|
1357
1908
|
submissionId,
|
|
1358
1909
|
groupId: chapter.id,
|
|
1359
|
-
now
|
|
1910
|
+
now,
|
|
1360
1911
|
newId: () => crypto.randomUUID(),
|
|
1361
|
-
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1912
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
1913
|
+
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
1914
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
1362
1915
|
});
|
|
1363
1916
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1364
1917
|
if (!result.duplicate) {
|
|
@@ -1367,10 +1920,16 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1367
1920
|
}
|
|
1368
1921
|
await provisionApplicant(db, effectiveChapter, result.id, parsed);
|
|
1369
1922
|
}
|
|
1923
|
+
const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
|
|
1924
|
+
const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
|
|
1925
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
1926
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1927
|
+
const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
|
|
1370
1928
|
return json({
|
|
1371
1929
|
id: result.id,
|
|
1372
1930
|
duplicate: result.duplicate,
|
|
1373
1931
|
status: result.status,
|
|
1932
|
+
...state ? { state } : {},
|
|
1374
1933
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
1375
1934
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
1376
1935
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -1426,8 +1985,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1426
1985
|
payload,
|
|
1427
1986
|
sourceId ? { sourceId } : {}
|
|
1428
1987
|
);
|
|
1429
|
-
const
|
|
1430
|
-
return json({ recordId, type:
|
|
1988
|
+
const record3 = normalizeSharedRecord(payload, sourceId);
|
|
1989
|
+
return json({ recordId, type: record3.type });
|
|
1431
1990
|
} catch (error) {
|
|
1432
1991
|
return json({
|
|
1433
1992
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1435,31 +1994,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1435
1994
|
}
|
|
1436
1995
|
};
|
|
1437
1996
|
|
|
1438
|
-
// src/worker-routes-signup-control.ts
|
|
1439
|
-
var import_db3 = require("@odla-ai/db");
|
|
1440
|
-
|
|
1441
|
-
// src/signup-control-canonical.ts
|
|
1442
|
-
var encoder = new TextEncoder();
|
|
1443
|
-
function canonicalValue(value) {
|
|
1444
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
1445
|
-
return JSON.stringify(value);
|
|
1446
|
-
}
|
|
1447
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
1448
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
1449
|
-
const record = value;
|
|
1450
|
-
const entries = Object.keys(record).sort().flatMap((key) => {
|
|
1451
|
-
const item = record[key];
|
|
1452
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
1453
|
-
});
|
|
1454
|
-
return `{${entries.join(",")}}`;
|
|
1455
|
-
}
|
|
1456
|
-
function canonicalSignupControl(value) {
|
|
1457
|
-
return canonicalValue(value);
|
|
1458
|
-
}
|
|
1459
|
-
async function signupControlDigest(document) {
|
|
1460
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
1461
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1462
|
-
}
|
|
1997
|
+
// src/worker-routes-signup-control.ts
|
|
1998
|
+
var import_db3 = require("@odla-ai/db");
|
|
1463
1999
|
|
|
1464
2000
|
// src/signup-control-contract.ts
|
|
1465
2001
|
var SIGNUP_CONTROL_PATH = "/api/signup-control";
|
|
@@ -1490,9 +2026,9 @@ var MAX_BENEFITS = 24;
|
|
|
1490
2026
|
var MAX_MARKDOWN = 65536;
|
|
1491
2027
|
var MAX_LABEL = 160;
|
|
1492
2028
|
var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1493
|
-
function rejectUnknownKeys(value,
|
|
2029
|
+
function rejectUnknownKeys(value, allowed2, path, errors) {
|
|
1494
2030
|
for (const key of Object.keys(value)) {
|
|
1495
|
-
if (!
|
|
2031
|
+
if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1496
2032
|
}
|
|
1497
2033
|
}
|
|
1498
2034
|
function boundedString(value, path, errors, max, required = false) {
|
|
@@ -1504,10 +2040,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
1504
2040
|
errors.push(`${path} must be a string`);
|
|
1505
2041
|
return void 0;
|
|
1506
2042
|
}
|
|
1507
|
-
const
|
|
1508
|
-
if (required && !
|
|
1509
|
-
if (
|
|
1510
|
-
return
|
|
2043
|
+
const text3 = value.trim();
|
|
2044
|
+
if (required && !text3) errors.push(`${path} is required`);
|
|
2045
|
+
if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2046
|
+
return text3;
|
|
1511
2047
|
}
|
|
1512
2048
|
function validateMembershipPageDocument(input, options = {}) {
|
|
1513
2049
|
const errors = [];
|
|
@@ -1556,8 +2092,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1556
2092
|
else {
|
|
1557
2093
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
1558
2094
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
1559
|
-
const
|
|
1560
|
-
return
|
|
2095
|
+
const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
2096
|
+
return text3 ? [text3] : [];
|
|
1561
2097
|
});
|
|
1562
2098
|
}
|
|
1563
2099
|
}
|
|
@@ -1631,7 +2167,7 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1631
2167
|
var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1632
2168
|
var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
1633
2169
|
var TIER = /^[a-z][a-z0-9-]{0,79}$/;
|
|
1634
|
-
var
|
|
2170
|
+
var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
|
|
1635
2171
|
var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
1636
2172
|
"schemaVersion",
|
|
1637
2173
|
"revision",
|
|
@@ -1643,14 +2179,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
1643
2179
|
"application",
|
|
1644
2180
|
"policy",
|
|
1645
2181
|
"scheduling",
|
|
1646
|
-
"theme"
|
|
2182
|
+
"theme",
|
|
2183
|
+
"giftOffers"
|
|
1647
2184
|
]);
|
|
1648
2185
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
1649
2186
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1650
|
-
function unknownKeys(value,
|
|
1651
|
-
for (const key of Object.keys(value)) if (!
|
|
2187
|
+
function unknownKeys(value, allowed2, path, errors) {
|
|
2188
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1652
2189
|
}
|
|
1653
|
-
function
|
|
2190
|
+
function text2(value, path, errors, max = 65536) {
|
|
1654
2191
|
if (typeof value !== "string" || !value.trim()) {
|
|
1655
2192
|
errors.push(`${path} is required`);
|
|
1656
2193
|
return "";
|
|
@@ -1673,23 +2210,23 @@ function tiersOf(value, errors) {
|
|
|
1673
2210
|
return [];
|
|
1674
2211
|
}
|
|
1675
2212
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
1676
|
-
const id =
|
|
2213
|
+
const id = text2(item.id, `${path}.id`, errors, 80);
|
|
1677
2214
|
if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
1678
2215
|
if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
1679
2216
|
seen.add(id);
|
|
1680
|
-
const name =
|
|
2217
|
+
const name = text2(item.name, `${path}.name`, errors, 160);
|
|
1681
2218
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
1682
2219
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
1683
2220
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
1684
2221
|
errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
|
|
1685
2222
|
}
|
|
1686
|
-
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !
|
|
2223
|
+
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
|
|
1687
2224
|
errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
1688
2225
|
}
|
|
1689
2226
|
if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
|
|
1690
2227
|
errors.push(`${path}.stripePriceId is required for an available paid tier`);
|
|
1691
2228
|
}
|
|
1692
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
2229
|
+
const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
1693
2230
|
return [{
|
|
1694
2231
|
id,
|
|
1695
2232
|
name,
|
|
@@ -1715,138 +2252,776 @@ function applicationOf(value, errors) {
|
|
|
1715
2252
|
if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
|
|
1716
2253
|
errors.push("document.application.copy must contain only string values");
|
|
1717
2254
|
}
|
|
1718
|
-
return {
|
|
1719
|
-
required: resolved.required,
|
|
1720
|
-
optional: resolved.optional,
|
|
1721
|
-
...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
|
|
1722
|
-
...copy ? { copy } : {}
|
|
1723
|
-
};
|
|
1724
|
-
} catch (error) {
|
|
1725
|
-
errors.push(error instanceof Error ? error.message : "document.application is invalid");
|
|
1726
|
-
return void 0;
|
|
2255
|
+
return {
|
|
2256
|
+
required: resolved.required,
|
|
2257
|
+
optional: resolved.optional,
|
|
2258
|
+
...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
|
|
2259
|
+
...copy ? { copy } : {}
|
|
2260
|
+
};
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
errors.push(error instanceof Error ? error.message : "document.application is invalid");
|
|
2263
|
+
return void 0;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
function policyOf(value, errors) {
|
|
2267
|
+
if (value === void 0) return void 0;
|
|
2268
|
+
const input = recordOf(value);
|
|
2269
|
+
if (!input) {
|
|
2270
|
+
errors.push("document.policy must be an object");
|
|
2271
|
+
return void 0;
|
|
2272
|
+
}
|
|
2273
|
+
const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
|
|
2274
|
+
unknownKeys(input, allowed2, "document.policy", errors);
|
|
2275
|
+
const result = {};
|
|
2276
|
+
for (const key of allowed2) {
|
|
2277
|
+
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
2278
|
+
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
2279
|
+
}
|
|
2280
|
+
return result;
|
|
2281
|
+
}
|
|
2282
|
+
function themeOf(value, errors) {
|
|
2283
|
+
if (value === void 0) return void 0;
|
|
2284
|
+
const input = recordOf(value);
|
|
2285
|
+
const tokens = recordOf(input?.tokens);
|
|
2286
|
+
if (!input || !tokens) {
|
|
2287
|
+
errors.push("document.theme.tokens must be an object");
|
|
2288
|
+
return void 0;
|
|
2289
|
+
}
|
|
2290
|
+
unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
|
|
2291
|
+
if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
|
|
2292
|
+
let serializedLength = 0;
|
|
2293
|
+
for (const [key, token] of Object.entries(tokens)) {
|
|
2294
|
+
if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
|
|
2295
|
+
errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
|
|
2296
|
+
}
|
|
2297
|
+
const rendered = String(token).trim();
|
|
2298
|
+
serializedLength += key.length + rendered.length + 4;
|
|
2299
|
+
if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
|
|
2300
|
+
errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
|
|
2304
|
+
return { tokens };
|
|
2305
|
+
}
|
|
2306
|
+
function validateSignupControlDocument(input) {
|
|
2307
|
+
const errors = [];
|
|
2308
|
+
const root = recordOf(input);
|
|
2309
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2310
|
+
unknownKeys(root, ROOT_KEYS2, "document", errors);
|
|
2311
|
+
if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
|
|
2312
|
+
if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2313
|
+
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
2314
|
+
const source = recordOf(root.source);
|
|
2315
|
+
const target = recordOf(root.target);
|
|
2316
|
+
const appId = text2(source?.appId, "document.source.appId", errors, 63);
|
|
2317
|
+
const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
2318
|
+
const environment = text2(target?.environment, "document.target.environment", errors, 63);
|
|
2319
|
+
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2320
|
+
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
2321
|
+
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
2322
|
+
if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
|
|
2323
|
+
const tiers = tiersOf(root.tiers, errors);
|
|
2324
|
+
const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
2325
|
+
const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
|
|
2326
|
+
if (!page.ok) errors.push(...page.errors);
|
|
2327
|
+
else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
|
|
2328
|
+
errors.push("document.membershipPage.tiers must follow the available tier display order");
|
|
2329
|
+
}
|
|
2330
|
+
const application = applicationOf(root.application, errors);
|
|
2331
|
+
const policy = policyOf(root.policy, errors);
|
|
2332
|
+
let scheduling;
|
|
2333
|
+
if (root.scheduling !== void 0) {
|
|
2334
|
+
const checked = validateScheduling(root.scheduling);
|
|
2335
|
+
if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
|
|
2336
|
+
else scheduling = root.scheduling;
|
|
2337
|
+
}
|
|
2338
|
+
const theme = themeOf(root.theme, errors);
|
|
2339
|
+
const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
|
|
2340
|
+
if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
|
|
2341
|
+
if (errors.length || !page.ok) return { ok: false, errors };
|
|
2342
|
+
return { ok: true, value: {
|
|
2343
|
+
schemaVersion: 1,
|
|
2344
|
+
revision: Number(root.revision),
|
|
2345
|
+
generatedAt: Number(root.generatedAt),
|
|
2346
|
+
source: { appId },
|
|
2347
|
+
target: { chapterId, environment, stripeMode: target?.stripeMode },
|
|
2348
|
+
tiers,
|
|
2349
|
+
membershipPage: page.value,
|
|
2350
|
+
...application ? { application } : {},
|
|
2351
|
+
...policy ? { policy } : {},
|
|
2352
|
+
...scheduling ? { scheduling } : {},
|
|
2353
|
+
...theme ? { theme } : {},
|
|
2354
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
2355
|
+
} };
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/worker-routes-signup-control.ts
|
|
2359
|
+
var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2360
|
+
var handleSignupControl = async (req, url, env, ctx) => {
|
|
2361
|
+
if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
|
|
2362
|
+
const authority = ctx.chapter.signupControl;
|
|
2363
|
+
if (!authority) return json({ error: "not found" }, 404);
|
|
2364
|
+
const db = ctx.makeDb(env);
|
|
2365
|
+
const secret = await getVaultSecret(db, authority.secretName);
|
|
2366
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2367
|
+
const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
|
|
2368
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2369
|
+
let input;
|
|
2370
|
+
try {
|
|
2371
|
+
input = JSON.parse(verified.body);
|
|
2372
|
+
} catch {
|
|
2373
|
+
return reject("invalid_document", 400, ["request body must be JSON"]);
|
|
2374
|
+
}
|
|
2375
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2376
|
+
const checked = validateSignupControlDocument(input.document);
|
|
2377
|
+
if (!checked.ok) return reject("invalid_document", 400, checked.errors);
|
|
2378
|
+
const document = checked.value;
|
|
2379
|
+
if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
|
|
2380
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
|
|
2381
|
+
if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
|
|
2382
|
+
if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
|
|
2383
|
+
const applicationErrors = validateControlledApplication(ctx.chapter, document);
|
|
2384
|
+
if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
|
|
2385
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
|
|
2386
|
+
const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2387
|
+
if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
|
|
2388
|
+
if (latest && document.revision === latest.revision) {
|
|
2389
|
+
if (latest.digest !== digest) return reject("conflicting_revision", 409);
|
|
2390
|
+
return json({
|
|
2391
|
+
ok: true,
|
|
2392
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2393
|
+
revision: latest.revision,
|
|
2394
|
+
digest: latest.digest,
|
|
2395
|
+
status: "replayed",
|
|
2396
|
+
acknowledgedAt: latest.receivedAt
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
const receivedAt = Date.now();
|
|
2400
|
+
try {
|
|
2401
|
+
await materializeSignupControl(db, { digest, document }, latest, receivedAt);
|
|
2402
|
+
} catch {
|
|
2403
|
+
return reject("materialization_failed", 503);
|
|
2404
|
+
}
|
|
2405
|
+
return json({
|
|
2406
|
+
ok: true,
|
|
2407
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2408
|
+
revision: document.revision,
|
|
2409
|
+
digest,
|
|
2410
|
+
status: "accepted",
|
|
2411
|
+
acknowledgedAt: receivedAt
|
|
2412
|
+
});
|
|
2413
|
+
};
|
|
2414
|
+
|
|
2415
|
+
// src/worker-routes-member-content.ts
|
|
2416
|
+
var import_db4 = require("@odla-ai/db");
|
|
2417
|
+
|
|
2418
|
+
// src/member-content-canonical.ts
|
|
2419
|
+
var encoder2 = new TextEncoder();
|
|
2420
|
+
function canonicalValue2(value) {
|
|
2421
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2422
|
+
return JSON.stringify(value);
|
|
2423
|
+
}
|
|
2424
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
|
|
2425
|
+
if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
|
|
2426
|
+
const record3 = value;
|
|
2427
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
2428
|
+
const item = record3[key];
|
|
2429
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
|
|
2430
|
+
});
|
|
2431
|
+
return `{${entries.join(",")}}`;
|
|
2432
|
+
}
|
|
2433
|
+
function canonicalMemberContent(value) {
|
|
2434
|
+
return canonicalValue2(value);
|
|
2435
|
+
}
|
|
2436
|
+
async function memberContentDigest(document) {
|
|
2437
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
|
|
2438
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/member-content-validation-helpers.ts
|
|
2442
|
+
var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
|
|
2443
|
+
var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2444
|
+
var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
|
|
2445
|
+
var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
|
|
2446
|
+
var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
|
|
2447
|
+
var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
|
|
2448
|
+
var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
|
|
2449
|
+
var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
|
|
2450
|
+
var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
|
|
2451
|
+
var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
|
|
2452
|
+
var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
|
|
2453
|
+
var MAX_PAGES = 100;
|
|
2454
|
+
var MAX_MARKDOWN2 = 65536;
|
|
2455
|
+
var MAX_TEXT = 240;
|
|
2456
|
+
var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2457
|
+
function unknownKeys2(value, allowed2, path, errors) {
|
|
2458
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2459
|
+
}
|
|
2460
|
+
function textValue(value, path, errors, max = MAX_TEXT, required = false) {
|
|
2461
|
+
if (value === void 0) {
|
|
2462
|
+
if (required) errors.push(`${path} is required`);
|
|
2463
|
+
return void 0;
|
|
2464
|
+
}
|
|
2465
|
+
if (typeof value !== "string") {
|
|
2466
|
+
errors.push(`${path} must be a string`);
|
|
2467
|
+
return void 0;
|
|
2468
|
+
}
|
|
2469
|
+
const valueText = value.trim();
|
|
2470
|
+
if (required && !valueText) errors.push(`${path} is required`);
|
|
2471
|
+
if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2472
|
+
return valueText;
|
|
2473
|
+
}
|
|
2474
|
+
function offerValue(value, path, errors, tiers, audience) {
|
|
2475
|
+
const raw = record2(value);
|
|
2476
|
+
if (!raw) {
|
|
2477
|
+
errors.push(`${path} must be an object`);
|
|
2478
|
+
return void 0;
|
|
2479
|
+
}
|
|
2480
|
+
unknownKeys2(raw, OFFER_KEYS, path, errors);
|
|
2481
|
+
const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
|
|
2482
|
+
if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
|
|
2483
|
+
if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
|
|
2484
|
+
if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
|
|
2485
|
+
errors.push(`${path}.tierId must grant access to this page`);
|
|
2486
|
+
}
|
|
2487
|
+
const label = textValue(raw.label, `${path}.label`, errors);
|
|
2488
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
|
|
2489
|
+
const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
|
|
2490
|
+
return {
|
|
2491
|
+
tierId,
|
|
2492
|
+
...label ? { label } : {},
|
|
2493
|
+
...bodyMarkdown ? { bodyMarkdown } : {},
|
|
2494
|
+
...ctaLabel ? { ctaLabel } : {}
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// src/member-content-validation.ts
|
|
2499
|
+
function validateMemberContentDocument(input, options = {}) {
|
|
2500
|
+
const errors = [];
|
|
2501
|
+
const root = record2(input);
|
|
2502
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2503
|
+
unknownKeys2(root, ROOT_KEYS3, "document", errors);
|
|
2504
|
+
if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
2505
|
+
if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2506
|
+
if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
|
|
2507
|
+
const sourceRaw = record2(root.source);
|
|
2508
|
+
let source = { type: "chapter", appId: "invalid" };
|
|
2509
|
+
if (!sourceRaw) errors.push("document.source must be an object");
|
|
2510
|
+
else {
|
|
2511
|
+
unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
|
|
2512
|
+
const type = sourceRaw.type;
|
|
2513
|
+
const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
|
|
2514
|
+
if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
|
|
2515
|
+
if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2516
|
+
if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
2517
|
+
if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
2518
|
+
if (type === "parent" || type === "chapter") source = { type, appId };
|
|
2519
|
+
}
|
|
2520
|
+
let target;
|
|
2521
|
+
if (root.target !== void 0) {
|
|
2522
|
+
const targetRaw = record2(root.target);
|
|
2523
|
+
if (!targetRaw) errors.push("document.target must be an object");
|
|
2524
|
+
else {
|
|
2525
|
+
unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
|
|
2526
|
+
const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
|
|
2527
|
+
const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
|
|
2528
|
+
target = { chapterId, environment };
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
|
|
2532
|
+
if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
|
|
2533
|
+
if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
|
|
2534
|
+
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
|
2535
|
+
if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
|
|
2536
|
+
const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
|
|
2537
|
+
const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
|
|
2538
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2539
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2540
|
+
const pages = [];
|
|
2541
|
+
for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
|
|
2542
|
+
const path = `document.pages[${index}]`;
|
|
2543
|
+
const raw = record2(value);
|
|
2544
|
+
if (!raw) {
|
|
2545
|
+
errors.push(`${path} must be an object`);
|
|
2546
|
+
continue;
|
|
2547
|
+
}
|
|
2548
|
+
unknownKeys2(raw, PAGE_KEYS, path, errors);
|
|
2549
|
+
const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
|
|
2550
|
+
const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
|
|
2551
|
+
const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
|
|
2552
|
+
if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
2553
|
+
if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
|
|
2554
|
+
if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
|
|
2555
|
+
if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
|
|
2556
|
+
ids.add(id);
|
|
2557
|
+
slugs.add(slug);
|
|
2558
|
+
const navRaw = record2(raw.navigation);
|
|
2559
|
+
let navigation = { section: "General", order: index };
|
|
2560
|
+
if (!navRaw) errors.push(`${path}.navigation must be an object`);
|
|
2561
|
+
else {
|
|
2562
|
+
unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
|
|
2563
|
+
const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
|
|
2564
|
+
const order = navRaw.order;
|
|
2565
|
+
if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
|
|
2566
|
+
errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
|
|
2567
|
+
}
|
|
2568
|
+
const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
|
|
2569
|
+
navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
|
|
2570
|
+
}
|
|
2571
|
+
const audienceRaw = record2(raw.audience);
|
|
2572
|
+
let audience = { kind: "all-members" };
|
|
2573
|
+
if (!audienceRaw) errors.push(`${path}.audience must be an object`);
|
|
2574
|
+
else {
|
|
2575
|
+
unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
|
|
2576
|
+
if (audienceRaw.kind === "all-members") {
|
|
2577
|
+
if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
|
|
2578
|
+
} else if (audienceRaw.kind === "tiers") {
|
|
2579
|
+
if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
|
|
2580
|
+
errors.push(`${path}.audience.tierIds must be a non-empty array`);
|
|
2581
|
+
}
|
|
2582
|
+
const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
|
|
2583
|
+
const normalized = [];
|
|
2584
|
+
for (const [tierIndex, value2] of values.entries()) {
|
|
2585
|
+
const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
|
|
2586
|
+
if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
|
|
2587
|
+
if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
|
|
2588
|
+
normalized.push(tier);
|
|
2589
|
+
}
|
|
2590
|
+
audience = { kind: "tiers", tierIds: normalized };
|
|
2591
|
+
} else errors.push(`${path}.audience.kind must be all-members or tiers`);
|
|
2592
|
+
}
|
|
2593
|
+
const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
|
|
2594
|
+
const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
|
|
2595
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
|
|
2596
|
+
let upsell;
|
|
2597
|
+
if (raw.upsell !== void 0) {
|
|
2598
|
+
const upsellRaw = record2(raw.upsell);
|
|
2599
|
+
if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
|
|
2600
|
+
else {
|
|
2601
|
+
unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
|
|
2602
|
+
const owner = upsellRaw.owner;
|
|
2603
|
+
if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
|
|
2604
|
+
if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
|
|
2605
|
+
if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
|
|
2606
|
+
if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
|
|
2607
|
+
const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
|
|
2608
|
+
if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
|
|
2609
|
+
else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
|
|
2613
|
+
}
|
|
2614
|
+
const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
|
|
2615
|
+
for (const [index, page] of pages.entries()) {
|
|
2616
|
+
const parentId = page.navigation.parentId;
|
|
2617
|
+
if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
|
|
2618
|
+
if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
|
|
2619
|
+
}
|
|
2620
|
+
for (const page of pages) {
|
|
2621
|
+
const seen = /* @__PURE__ */ new Set([page.id]);
|
|
2622
|
+
let parentId = page.navigation.parentId;
|
|
2623
|
+
while (parentId) {
|
|
2624
|
+
if (seen.has(parentId)) {
|
|
2625
|
+
errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
|
|
2626
|
+
break;
|
|
2627
|
+
}
|
|
2628
|
+
seen.add(parentId);
|
|
2629
|
+
parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
let upsellOverrides;
|
|
2633
|
+
if (root.upsellOverrides !== void 0) {
|
|
2634
|
+
if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
|
|
2635
|
+
if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
|
|
2636
|
+
const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
|
|
2637
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2638
|
+
upsellOverrides = [];
|
|
2639
|
+
for (const [index, value] of values.entries()) {
|
|
2640
|
+
const path = `document.upsellOverrides[${index}]`;
|
|
2641
|
+
const raw = record2(value);
|
|
2642
|
+
if (!raw) {
|
|
2643
|
+
errors.push(`${path} must be an object`);
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
|
|
2647
|
+
const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
|
|
2648
|
+
if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
|
|
2649
|
+
seen.add(pageId);
|
|
2650
|
+
const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
|
|
2651
|
+
if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
|
|
2652
|
+
else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
|
|
2653
|
+
const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
|
|
2654
|
+
if (offer) upsellOverrides.push({ pageId, offer });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
if (errors.length) return { ok: false, errors };
|
|
2658
|
+
return { ok: true, value: {
|
|
2659
|
+
schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
|
|
2660
|
+
revision: Number(root.revision),
|
|
2661
|
+
generatedAt: Number(root.generatedAt),
|
|
2662
|
+
source,
|
|
2663
|
+
...target ? { target } : {},
|
|
2664
|
+
pages,
|
|
2665
|
+
...upsellOverrides ? { upsellOverrides } : {}
|
|
2666
|
+
} };
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// src/member-content.ts
|
|
2670
|
+
var MEMBER_CONTENT_SCHEMA_VERSION = 1;
|
|
2671
|
+
var MEMBER_CONTENT_PATH = "/api/member-content";
|
|
2672
|
+
var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
|
|
2673
|
+
var pageOrder = (a, b) => a.navigation.section.localeCompare(b.navigation.section) || a.navigation.order - b.navigation.order || a.title.localeCompare(b.title) || a.id.localeCompare(b.id);
|
|
2674
|
+
function composeMemberContent(parent, chapter) {
|
|
2675
|
+
const pages = [];
|
|
2676
|
+
const parentPages = /* @__PURE__ */ new Map();
|
|
2677
|
+
for (const page of parent?.pages ?? []) {
|
|
2678
|
+
const effective = { ...page, source: parent.source };
|
|
2679
|
+
parentPages.set(page.id, effective);
|
|
2680
|
+
pages.push(effective);
|
|
2681
|
+
}
|
|
2682
|
+
for (const override of chapter?.upsellOverrides ?? []) {
|
|
2683
|
+
const page = parentPages.get(override.pageId);
|
|
2684
|
+
if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
|
|
2685
|
+
}
|
|
2686
|
+
for (const page of chapter?.pages ?? []) {
|
|
2687
|
+
if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
|
|
2688
|
+
throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
|
|
2689
|
+
}
|
|
2690
|
+
pages.push({ ...page, source: chapter.source });
|
|
2691
|
+
}
|
|
2692
|
+
return {
|
|
2693
|
+
pages: pages.sort(pageOrder),
|
|
2694
|
+
parentRevision: parent?.revision ?? null,
|
|
2695
|
+
chapterRevision: chapter?.revision ?? null
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
|
|
2699
|
+
function projectMemberContent(content, viewer, tiers) {
|
|
2700
|
+
const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
|
|
2701
|
+
return {
|
|
2702
|
+
viewer: { ...viewer },
|
|
2703
|
+
revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
|
|
2704
|
+
pages: content.pages.map((page) => {
|
|
2705
|
+
const canRead = allowed(page, viewer);
|
|
2706
|
+
const offer = !canRead ? page.upsell?.offer : void 0;
|
|
2707
|
+
const tier = offer ? authority.get(offer.tierId) : void 0;
|
|
2708
|
+
const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
|
|
2709
|
+
return {
|
|
2710
|
+
id: page.id,
|
|
2711
|
+
slug: page.slug,
|
|
2712
|
+
title: page.title,
|
|
2713
|
+
...page.summary ? { summary: page.summary } : {},
|
|
2714
|
+
navigation: page.navigation,
|
|
2715
|
+
audience: page.audience,
|
|
2716
|
+
source: page.source,
|
|
2717
|
+
access: canRead ? "allowed" : "locked",
|
|
2718
|
+
...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
|
|
2719
|
+
...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
|
|
2720
|
+
...upsell ? { upsell } : {}
|
|
2721
|
+
};
|
|
2722
|
+
})
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// src/member-content-store.ts
|
|
2727
|
+
var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2728
|
+
var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2729
|
+
async function exists(db, ns, where) {
|
|
2730
|
+
if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
|
|
2731
|
+
const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
|
|
2732
|
+
return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
|
|
2733
|
+
}
|
|
2734
|
+
async function loadActiveMemberContentControl(db, targetId, environment) {
|
|
2735
|
+
const where = { targetId, environment, active: true };
|
|
2736
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2737
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2738
|
+
where,
|
|
2739
|
+
order: { revision: "desc" },
|
|
2740
|
+
limit: 1
|
|
2741
|
+
} } })).memberContentControls;
|
|
2742
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2743
|
+
}
|
|
2744
|
+
async function loadLatestMemberContentControl(db, targetId, environment) {
|
|
2745
|
+
const where = { targetId, environment };
|
|
2746
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2747
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2748
|
+
where,
|
|
2749
|
+
order: { revision: "desc" },
|
|
2750
|
+
limit: 1
|
|
2751
|
+
} } })).memberContentControls;
|
|
2752
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2753
|
+
}
|
|
2754
|
+
async function loadLocalMemberContent(db, chapterId, environment) {
|
|
2755
|
+
const id = `member-content-local:${environment}`;
|
|
2756
|
+
const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
|
|
2757
|
+
return local(Array.isArray(rows4) ? rows4[0] : void 0);
|
|
2758
|
+
}
|
|
2759
|
+
async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
|
|
2760
|
+
const { document, digest } = envelope;
|
|
2761
|
+
const target = document.target;
|
|
2762
|
+
const id = `member-content-control:${target.environment}:${document.revision}`;
|
|
2763
|
+
const ops = [];
|
|
2764
|
+
if (previous?.active) {
|
|
2765
|
+
ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
|
|
2766
|
+
}
|
|
2767
|
+
ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
|
|
2768
|
+
id,
|
|
2769
|
+
sourceId: document.source.appId,
|
|
2770
|
+
targetId: target.chapterId,
|
|
2771
|
+
environment: target.environment,
|
|
2772
|
+
revision: document.revision,
|
|
2773
|
+
digest,
|
|
2774
|
+
document,
|
|
2775
|
+
active: true,
|
|
2776
|
+
receivedAt
|
|
2777
|
+
} });
|
|
2778
|
+
await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
|
|
2779
|
+
}
|
|
2780
|
+
async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
|
|
2781
|
+
const id = `member-content-local:${environment}`;
|
|
2782
|
+
const current = await loadLocalMemberContent(db, document.source.appId, environment);
|
|
2783
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
|
|
2784
|
+
const row = {
|
|
2785
|
+
id,
|
|
2786
|
+
chapterId: document.source.appId,
|
|
2787
|
+
environment,
|
|
2788
|
+
revision: document.revision,
|
|
2789
|
+
document,
|
|
2790
|
+
updatedAt,
|
|
2791
|
+
...updatedBy ? { updatedBy } : {}
|
|
2792
|
+
};
|
|
2793
|
+
await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
|
|
2794
|
+
mutationId: `member-content-local:${environment}:${document.revision}`,
|
|
2795
|
+
guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
|
|
2796
|
+
});
|
|
2797
|
+
return row;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// src/worker-routes-member-content.ts
|
|
2801
|
+
var ADMIN_PATH = "/api/admin/member-content";
|
|
2802
|
+
var BODY_CAP = 1e6;
|
|
2803
|
+
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2804
|
+
async function groupAndTiers(db, chapterId) {
|
|
2805
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
2806
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
2807
|
+
if (!group) return { group: null, tiers: [] };
|
|
2808
|
+
return { group, tiers: await loadTiers(db, group) };
|
|
2809
|
+
}
|
|
2810
|
+
async function memberTierId(db, chapterId, email) {
|
|
2811
|
+
if (!email) return null;
|
|
2812
|
+
let rows4 = (await db.query({ applications: { $: {
|
|
2813
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
2814
|
+
limit: 20
|
|
2815
|
+
} } })).applications;
|
|
2816
|
+
if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
2817
|
+
const latest = Array.isArray(rows4) ? [...rows4].filter((row) => String(row.groupId ?? chapterId) === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
2818
|
+
return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
|
|
2819
|
+
}
|
|
2820
|
+
async function activeDocuments(db, chapterId, environment) {
|
|
2821
|
+
const [parent, chapter] = await Promise.all([
|
|
2822
|
+
loadActiveMemberContentControl(db, chapterId, environment),
|
|
2823
|
+
loadLocalMemberContent(db, chapterId, environment)
|
|
2824
|
+
]);
|
|
2825
|
+
return { parent, chapter };
|
|
2826
|
+
}
|
|
2827
|
+
var handleMemberContentControl = async (req, url, env, ctx) => {
|
|
2828
|
+
if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
|
|
2829
|
+
const settings = ctx.chapter.memberContent?.parent;
|
|
2830
|
+
if (!settings) return json({ error: "not found" }, 404);
|
|
2831
|
+
const db = ctx.makeDb(env);
|
|
2832
|
+
const secret = await getVaultSecret(db, settings.secretName);
|
|
2833
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2834
|
+
const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret, senders: [settings.sourceId] });
|
|
2835
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2836
|
+
let input;
|
|
2837
|
+
try {
|
|
2838
|
+
input = JSON.parse(verified.body);
|
|
2839
|
+
} catch {
|
|
2840
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
2841
|
+
}
|
|
2842
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2843
|
+
const initial = validateMemberContentDocument(input.document, {
|
|
2844
|
+
sourceType: "parent"
|
|
2845
|
+
});
|
|
2846
|
+
if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
|
|
2847
|
+
const document = initial.value;
|
|
2848
|
+
if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
|
|
2849
|
+
if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2850
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2851
|
+
const { tiers } = await groupAndTiers(db, ctx.chapter.id);
|
|
2852
|
+
const checked = validateMemberContentDocument(document, {
|
|
2853
|
+
sourceType: "parent",
|
|
2854
|
+
sourceAppId: settings.sourceId,
|
|
2855
|
+
tiers
|
|
2856
|
+
});
|
|
2857
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2858
|
+
const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2859
|
+
if (local2) {
|
|
2860
|
+
const localCheck = validateMemberContentDocument(local2.document, {
|
|
2861
|
+
sourceType: "chapter",
|
|
2862
|
+
sourceAppId: ctx.chapter.id,
|
|
2863
|
+
tiers,
|
|
2864
|
+
parentPages: document.pages
|
|
2865
|
+
});
|
|
2866
|
+
if (!localCheck.ok) {
|
|
2867
|
+
return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
|
|
2868
|
+
}
|
|
1727
2869
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
if (
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2870
|
+
if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2871
|
+
const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2872
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2873
|
+
if (latest && document.revision === latest.revision) {
|
|
2874
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2875
|
+
return json({
|
|
2876
|
+
ok: true,
|
|
2877
|
+
target: document.target,
|
|
2878
|
+
revision: latest.revision,
|
|
2879
|
+
digest: latest.digest,
|
|
2880
|
+
status: "replayed",
|
|
2881
|
+
acknowledgedAt: latest.receivedAt
|
|
2882
|
+
});
|
|
1735
2883
|
}
|
|
1736
|
-
const
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
result[key] = text(input[key], `document.policy.${key}`, errors);
|
|
2884
|
+
const receivedAt = Date.now();
|
|
2885
|
+
try {
|
|
2886
|
+
await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
|
|
2887
|
+
} catch {
|
|
2888
|
+
return reject2("materialization_failed", 503);
|
|
1742
2889
|
}
|
|
1743
|
-
return
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
2890
|
+
return json({
|
|
2891
|
+
ok: true,
|
|
2892
|
+
target: document.target,
|
|
2893
|
+
revision: document.revision,
|
|
2894
|
+
digest,
|
|
2895
|
+
status: "accepted",
|
|
2896
|
+
acknowledgedAt: receivedAt
|
|
2897
|
+
});
|
|
2898
|
+
};
|
|
2899
|
+
var handleMemberContent = async (req, url, env, ctx) => {
|
|
2900
|
+
if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
|
|
2901
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2902
|
+
const user = await ctx.verifyUser(req, env);
|
|
2903
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2904
|
+
const db = ctx.makeDb(env);
|
|
2905
|
+
const role = await ctx.roleFor(db, user);
|
|
2906
|
+
const admin = await ctx.isAdmin(db, user);
|
|
2907
|
+
const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
|
|
2908
|
+
const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
|
|
2909
|
+
if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
|
|
2910
|
+
const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
|
|
2911
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2912
|
+
groupAndTiers(db, ctx.chapter.id),
|
|
2913
|
+
memberTierId(db, ctx.chapter.id, user.email)
|
|
2914
|
+
]);
|
|
2915
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2916
|
+
let offerable = tiers;
|
|
2917
|
+
if (group) {
|
|
2918
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
2919
|
+
offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
|
|
1752
2920
|
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
2921
|
+
return json(projectMemberContent(effective, { tierId, admin }, offerable));
|
|
2922
|
+
};
|
|
2923
|
+
var handleAdminMemberContent = async (req, url, env, ctx) => {
|
|
2924
|
+
if (url.pathname !== ADMIN_PATH) return null;
|
|
2925
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2926
|
+
const user = await ctx.verifyUser(req, env);
|
|
2927
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2928
|
+
const db = ctx.makeDb(env);
|
|
2929
|
+
if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
|
|
2930
|
+
const [{ parent, chapter }, { tiers }] = await Promise.all([
|
|
2931
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2932
|
+
groupAndTiers(db, ctx.chapter.id)
|
|
2933
|
+
]);
|
|
2934
|
+
if (req.method === "GET") {
|
|
2935
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2936
|
+
return json({
|
|
2937
|
+
parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
|
|
2938
|
+
chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
|
|
2939
|
+
effective,
|
|
2940
|
+
tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
|
|
2941
|
+
localAuthoring: ctx.chapter.memberContent.localAuthoring
|
|
2942
|
+
});
|
|
1765
2943
|
}
|
|
1766
|
-
if (
|
|
1767
|
-
return {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
1776
|
-
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1777
|
-
const source = recordOf(root.source);
|
|
1778
|
-
const target = recordOf(root.target);
|
|
1779
|
-
const appId = text(source?.appId, "document.source.appId", errors, 63);
|
|
1780
|
-
const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
1781
|
-
const environment = text(target?.environment, "document.target.environment", errors, 63);
|
|
1782
|
-
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1783
|
-
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1784
|
-
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
1785
|
-
if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
|
|
1786
|
-
const tiers = tiersOf(root.tiers, errors);
|
|
1787
|
-
const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
1788
|
-
const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
|
|
1789
|
-
if (!page.ok) errors.push(...page.errors);
|
|
1790
|
-
else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
|
|
1791
|
-
errors.push("document.membershipPage.tiers must follow the available tier display order");
|
|
2944
|
+
if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
|
|
2945
|
+
if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
|
|
2946
|
+
const raw = await req.text();
|
|
2947
|
+
if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
|
|
2948
|
+
let input;
|
|
2949
|
+
try {
|
|
2950
|
+
input = JSON.parse(raw);
|
|
2951
|
+
} catch {
|
|
2952
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1792
2953
|
}
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
if (root.scheduling !== void 0) {
|
|
1797
|
-
const checked = validateScheduling(root.scheduling);
|
|
1798
|
-
if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
|
|
1799
|
-
else scheduling = root.scheduling;
|
|
2954
|
+
const expectedRevision = Number(input.expectedRevision);
|
|
2955
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
2956
|
+
return json({ error: "expectedRevision must be a non-negative integer" }, 400);
|
|
1800
2957
|
}
|
|
1801
|
-
const
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
generatedAt: Number(root.generatedAt),
|
|
1807
|
-
source: { appId },
|
|
1808
|
-
target: { chapterId, environment, stripeMode: target?.stripeMode },
|
|
2958
|
+
const now = Date.now();
|
|
2959
|
+
const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
|
|
2960
|
+
const checked = validateMemberContentDocument(documentInput, {
|
|
2961
|
+
sourceType: "chapter",
|
|
2962
|
+
sourceAppId: ctx.chapter.id,
|
|
1809
2963
|
tiers,
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
2964
|
+
parentPages: parent?.document.pages ?? []
|
|
2965
|
+
});
|
|
2966
|
+
if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
|
|
2967
|
+
try {
|
|
2968
|
+
const saved = await saveLocalMemberContent(
|
|
2969
|
+
db,
|
|
2970
|
+
checked.value,
|
|
2971
|
+
env.ODLA_ENV,
|
|
2972
|
+
expectedRevision,
|
|
2973
|
+
now,
|
|
2974
|
+
user.email ?? user.userId
|
|
2975
|
+
);
|
|
2976
|
+
return json({ ok: true, revision: saved.revision, document: saved.document });
|
|
2977
|
+
} catch (error) {
|
|
2978
|
+
if (error instanceof Error && error.message === "member_content_revision_conflict") {
|
|
2979
|
+
return json({ error: "member content changed; reload before saving" }, 409);
|
|
2980
|
+
}
|
|
2981
|
+
return json({ error: "member content could not be saved" }, 503);
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
function withLocalAuthority(value, revision, generatedAt, appId) {
|
|
2985
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
2986
|
+
return {
|
|
2987
|
+
...value,
|
|
2988
|
+
schemaVersion: 1,
|
|
2989
|
+
revision,
|
|
2990
|
+
generatedAt,
|
|
2991
|
+
source: { type: "chapter", appId }
|
|
2992
|
+
};
|
|
1816
2993
|
}
|
|
1817
2994
|
|
|
1818
|
-
// src/worker-routes-
|
|
1819
|
-
var
|
|
1820
|
-
var
|
|
1821
|
-
|
|
2995
|
+
// src/worker-routes-admission-grants.ts
|
|
2996
|
+
var import_db5 = require("@odla-ai/db");
|
|
2997
|
+
var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2998
|
+
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2999
|
+
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
1822
3000
|
const authority = ctx.chapter.signupControl;
|
|
1823
3001
|
if (!authority) return json({ error: "not found" }, 404);
|
|
1824
3002
|
const db = ctx.makeDb(env);
|
|
1825
3003
|
const secret = await getVaultSecret(db, authority.secretName);
|
|
1826
3004
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
1827
|
-
const verified = await (0,
|
|
3005
|
+
const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
|
|
1828
3006
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
1829
3007
|
let input;
|
|
1830
3008
|
try {
|
|
1831
3009
|
input = JSON.parse(verified.body);
|
|
1832
3010
|
} catch {
|
|
1833
|
-
return
|
|
3011
|
+
return reject3("invalid_document", 400, ["request body must be JSON"]);
|
|
1834
3012
|
}
|
|
1835
3013
|
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
1836
|
-
const checked =
|
|
1837
|
-
if (!checked.ok) return
|
|
3014
|
+
const checked = validateAdmissionGrantDocument(input.document);
|
|
3015
|
+
if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
|
|
1838
3016
|
const document = checked.value;
|
|
1839
|
-
if (document.source.appId !== authority.sourceId) return
|
|
1840
|
-
if (document.target.chapterId !== ctx.chapter.id) return
|
|
1841
|
-
if (document.target.environment !== env.ODLA_ENV) return
|
|
1842
|
-
if (document
|
|
1843
|
-
const
|
|
1844
|
-
if (
|
|
1845
|
-
if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
|
|
1846
|
-
const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
1847
|
-
if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
|
|
3017
|
+
if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
|
|
3018
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
|
|
3019
|
+
if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
|
|
3020
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
|
|
3021
|
+
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
3022
|
+
if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
|
|
1848
3023
|
if (latest && document.revision === latest.revision) {
|
|
1849
|
-
if (latest.digest !== digest) return
|
|
3024
|
+
if (latest.digest !== digest) return reject3("conflicting_revision", 409);
|
|
1850
3025
|
return json({
|
|
1851
3026
|
ok: true,
|
|
1852
3027
|
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
@@ -1858,9 +3033,9 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1858
3033
|
}
|
|
1859
3034
|
const receivedAt = Date.now();
|
|
1860
3035
|
try {
|
|
1861
|
-
await
|
|
3036
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
1862
3037
|
} catch {
|
|
1863
|
-
return
|
|
3038
|
+
return reject3("materialization_failed", 503);
|
|
1864
3039
|
}
|
|
1865
3040
|
return json({
|
|
1866
3041
|
ok: true,
|
|
@@ -1872,92 +3047,101 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1872
3047
|
});
|
|
1873
3048
|
};
|
|
1874
3049
|
|
|
1875
|
-
// src/
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
}
|
|
1900
|
-
function billingColumns(app) {
|
|
1901
|
-
const status = str(app.status);
|
|
1902
|
-
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1903
|
-
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1904
|
-
const cols = { billingStatus };
|
|
1905
|
-
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1906
|
-
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1907
|
-
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1908
|
-
return cols;
|
|
1909
|
-
}
|
|
1910
|
-
async function syncApplicationToCrm(deps, opts) {
|
|
1911
|
-
const emailKey = str(opts.app.email).toLowerCase();
|
|
1912
|
-
if (!emailKey) return null;
|
|
1913
|
-
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1914
|
-
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1915
|
-
const { crm_record } = await deps.db.query({
|
|
1916
|
-
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1917
|
-
});
|
|
1918
|
-
const existing = crm_record?.[0] ?? null;
|
|
1919
|
-
const stage = opts.stage || void 0;
|
|
1920
|
-
let recordId;
|
|
1921
|
-
if (existing && typeof existing.id === "string") {
|
|
1922
|
-
recordId = existing.id;
|
|
1923
|
-
await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
|
|
1924
|
-
} else {
|
|
1925
|
-
const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1926
|
-
recordId = created.id;
|
|
1927
|
-
}
|
|
1928
|
-
if (existing && stage && existing.stage !== stage) {
|
|
1929
|
-
await (0, import_crm4.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1930
|
-
}
|
|
1931
|
-
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1932
|
-
await (0, import_crm4.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1933
|
-
return recordId;
|
|
1934
|
-
}
|
|
1935
|
-
async function backfillCrm(deps) {
|
|
1936
|
-
const [appsRes, usersRes] = await Promise.all([
|
|
1937
|
-
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1938
|
-
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1939
|
-
]);
|
|
1940
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1941
|
-
let synced = 0;
|
|
1942
|
-
const errors = [];
|
|
1943
|
-
const run = async (app, stage) => {
|
|
1944
|
-
const key = str(app.email).toLowerCase();
|
|
1945
|
-
if (!key || seen.has(key)) return;
|
|
1946
|
-
seen.add(key);
|
|
3050
|
+
// src/worker-routes-admin-admission.ts
|
|
3051
|
+
async function gate(req, env, ctx) {
|
|
3052
|
+
const rawDb = ctx.makeDb(env);
|
|
3053
|
+
const user = await ctx.verifyUser(req, env);
|
|
3054
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3055
|
+
if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
|
|
3056
|
+
return { db: rawDb, user };
|
|
3057
|
+
}
|
|
3058
|
+
var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
3059
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
|
|
3060
|
+
const auth = await gate(req, env, ctx);
|
|
3061
|
+
if (auth instanceof Response) return auth;
|
|
3062
|
+
const result = await auth.db.query({ admissionGrants: { $: {
|
|
3063
|
+
where: { targetId: ctx.chapter.id },
|
|
3064
|
+
order: { createdAt: "desc" },
|
|
3065
|
+
limit: 500
|
|
3066
|
+
} } });
|
|
3067
|
+
const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
|
|
3068
|
+
return json({ grants });
|
|
3069
|
+
}
|
|
3070
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
|
|
3071
|
+
const auth = await gate(req, env, ctx);
|
|
3072
|
+
if (auth instanceof Response) return auth;
|
|
3073
|
+
let body;
|
|
1947
3074
|
try {
|
|
1948
|
-
await
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
3075
|
+
body = JSON.parse(await req.text());
|
|
3076
|
+
} catch {
|
|
3077
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3078
|
+
}
|
|
3079
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
3080
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
3081
|
+
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
3082
|
+
const decision = body.decision ?? "waive_interview";
|
|
3083
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
3084
|
+
return json({ error: "decision must be waive_interview or require_interview" }, 400);
|
|
3085
|
+
}
|
|
3086
|
+
const note = typeof body.note === "string" ? body.note.trim() : "";
|
|
3087
|
+
if (note.length > 2e3) return json({ error: "note is too long" }, 400);
|
|
3088
|
+
const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
|
|
3089
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
|
|
3090
|
+
return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
|
|
3091
|
+
}
|
|
3092
|
+
const sourceGrantId = crypto.randomUUID();
|
|
3093
|
+
const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
|
|
3094
|
+
const createdAt = Date.now();
|
|
3095
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
3096
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
3097
|
+
id,
|
|
3098
|
+
sourceGrantId,
|
|
3099
|
+
sourceScope: "local",
|
|
3100
|
+
sourceId: ctx.chapter.id,
|
|
3101
|
+
targetId: ctx.chapter.id,
|
|
3102
|
+
email,
|
|
3103
|
+
emailNormalized,
|
|
3104
|
+
decision,
|
|
3105
|
+
...note ? { note } : {},
|
|
3106
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
3107
|
+
active: true,
|
|
3108
|
+
createdAt,
|
|
3109
|
+
createdBy: actor,
|
|
3110
|
+
updatedAt: createdAt,
|
|
3111
|
+
updatedBy: actor
|
|
3112
|
+
} }], { mutationId: `admission-local-create:${sourceGrantId}` });
|
|
3113
|
+
return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
|
|
3114
|
+
}
|
|
3115
|
+
const prefix = "/api/admin/admission-grants/";
|
|
3116
|
+
let revokeId = "";
|
|
3117
|
+
if (url.pathname.startsWith(prefix)) {
|
|
3118
|
+
try {
|
|
3119
|
+
revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
3120
|
+
} catch {
|
|
3121
|
+
revokeId = "";
|
|
1952
3122
|
}
|
|
1953
|
-
};
|
|
1954
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1955
|
-
for (const u of usersRes.$users ?? []) {
|
|
1956
|
-
if (u.deleted === true) continue;
|
|
1957
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1958
3123
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
3124
|
+
if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
|
|
3125
|
+
const auth = await gate(req, env, ctx);
|
|
3126
|
+
if (auth instanceof Response) return auth;
|
|
3127
|
+
const id = revokeId;
|
|
3128
|
+
const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
|
|
3129
|
+
const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
|
|
3130
|
+
if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
|
|
3131
|
+
if (row.active !== true) return json({ ok: true, replayed: true });
|
|
3132
|
+
const revokedAt = Date.now();
|
|
3133
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
3134
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
3135
|
+
active: false,
|
|
3136
|
+
revokedAt,
|
|
3137
|
+
revokedBy: actor,
|
|
3138
|
+
updatedAt: revokedAt,
|
|
3139
|
+
updatedBy: actor
|
|
3140
|
+
} }], { mutationId: `admission-local-revoke:${id}` });
|
|
3141
|
+
return json({ ok: true, replayed: false });
|
|
3142
|
+
}
|
|
3143
|
+
return null;
|
|
3144
|
+
};
|
|
1961
3145
|
|
|
1962
3146
|
// src/worker-routes-schedule.ts
|
|
1963
3147
|
var import_calendar = require("@odla-ai/calendar");
|
|
@@ -1973,8 +3157,8 @@ function makeCalendar(env) {
|
|
|
1973
3157
|
}
|
|
1974
3158
|
async function firstRow(db, ns, q) {
|
|
1975
3159
|
const res = await db.query({ [ns]: { $: q } });
|
|
1976
|
-
const
|
|
1977
|
-
return Array.isArray(
|
|
3160
|
+
const rows4 = res[ns];
|
|
3161
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1978
3162
|
}
|
|
1979
3163
|
async function computeSlots(cal, cfg) {
|
|
1980
3164
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2111,8 +3295,26 @@ function findApplicationRef(obj) {
|
|
|
2111
3295
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2112
3296
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2113
3297
|
}
|
|
3298
|
+
function findGiftRef(obj) {
|
|
3299
|
+
const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
|
|
3300
|
+
const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
|
|
3301
|
+
const parent = obj.parent;
|
|
3302
|
+
const subscription = obj.subscription_details ?? parent?.subscription_details;
|
|
3303
|
+
const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
|
|
3304
|
+
const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
|
|
3305
|
+
return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
|
|
3306
|
+
}
|
|
2114
3307
|
function normalizeWebhookEvent(event) {
|
|
2115
3308
|
const obj = event.data?.object ?? {};
|
|
3309
|
+
const gift = findGiftRef(obj);
|
|
3310
|
+
if (gift.giftIntentId) {
|
|
3311
|
+
if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
|
|
3312
|
+
return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
|
|
3313
|
+
}
|
|
3314
|
+
if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
|
|
3315
|
+
if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
|
|
3316
|
+
return { kind: "ignored", type: event.type };
|
|
3317
|
+
}
|
|
2116
3318
|
const ref = findApplicationRef(obj);
|
|
2117
3319
|
switch (event.type) {
|
|
2118
3320
|
case "invoice.paid": {
|
|
@@ -2213,9 +3415,9 @@ function createStripeProvider(config) {
|
|
|
2213
3415
|
`sub:${input.applicationId}`
|
|
2214
3416
|
);
|
|
2215
3417
|
if (!sub.ok) fail("subscription create", sub);
|
|
2216
|
-
const
|
|
2217
|
-
if (!
|
|
2218
|
-
return { customerId, subscriptionId: String(sub.body.id), clientSecret };
|
|
3418
|
+
const clientSecret2 = clientSecretOf(sub.body);
|
|
3419
|
+
if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
|
|
3420
|
+
return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
|
|
2219
3421
|
},
|
|
2220
3422
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2221
3423
|
if (!webhookSecret || !await (0, import_stripe2.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2228,8 +3430,8 @@ function createStripeProvider(config) {
|
|
|
2228
3430
|
const sk = requireSecret(secretKey);
|
|
2229
3431
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2230
3432
|
if (!charges.ok) fail("charges list", charges);
|
|
2231
|
-
const
|
|
2232
|
-
const paid =
|
|
3433
|
+
const rows4 = charges.body.data ?? [];
|
|
3434
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2233
3435
|
const charge = paid[paid.length - 1];
|
|
2234
3436
|
if (!charge) {
|
|
2235
3437
|
const err = new Error("no paid charge to refund");
|
|
@@ -2245,12 +3447,337 @@ function createStripeProvider(config) {
|
|
|
2245
3447
|
};
|
|
2246
3448
|
}
|
|
2247
3449
|
|
|
3450
|
+
// src/gift-codes.ts
|
|
3451
|
+
var GIFT_CODE_VERSION = 1;
|
|
3452
|
+
var GIFT_CODE_BYTES = 32;
|
|
3453
|
+
var base64url = (bytes) => {
|
|
3454
|
+
let binary = "";
|
|
3455
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
3456
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
3457
|
+
};
|
|
3458
|
+
function createGiftCode() {
|
|
3459
|
+
const bytes = new Uint8Array(GIFT_CODE_BYTES);
|
|
3460
|
+
crypto.getRandomValues(bytes);
|
|
3461
|
+
return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
|
|
3462
|
+
}
|
|
3463
|
+
async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
|
|
3464
|
+
const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
|
|
3465
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
|
|
3466
|
+
}
|
|
3467
|
+
async function giftRequesterBucket(chapterId, requester) {
|
|
3468
|
+
const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
|
|
3469
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// src/gift-store.ts
|
|
3473
|
+
var first = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3474
|
+
async function prepareGiftCode(chapterId) {
|
|
3475
|
+
const code = createGiftCode();
|
|
3476
|
+
return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
|
|
3477
|
+
}
|
|
3478
|
+
async function issueGiftClaim(db, intent, provider, now) {
|
|
3479
|
+
const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
3480
|
+
if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
|
|
3481
|
+
const offer = intent.offerSnapshot;
|
|
3482
|
+
const orderId = `gift-order:${intent.id}`;
|
|
3483
|
+
const claimId = `gift-claim:${intent.id}`;
|
|
3484
|
+
const shared = {
|
|
3485
|
+
chapterId: intent.chapterId,
|
|
3486
|
+
offerId: intent.offerId,
|
|
3487
|
+
...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
|
|
3488
|
+
...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
|
|
3489
|
+
purchaserEmailNormalized: intent.purchaserEmailNormalized
|
|
3490
|
+
};
|
|
3491
|
+
const ops = [
|
|
3492
|
+
{ t: "update", ns: "giftOrders", id: orderId, attrs: {
|
|
3493
|
+
id: orderId,
|
|
3494
|
+
intentId: intent.id,
|
|
3495
|
+
...shared,
|
|
3496
|
+
environment: intent.environment,
|
|
3497
|
+
offerSnapshot: offer,
|
|
3498
|
+
purchaserType: intent.purchaserType,
|
|
3499
|
+
providerEventId: provider.eventId,
|
|
3500
|
+
providerObjectId: provider.objectId,
|
|
3501
|
+
amountCents: offer.priceCents,
|
|
3502
|
+
currency: offer.currency,
|
|
3503
|
+
billingMode: offer.billingMode,
|
|
3504
|
+
createdAt: now
|
|
3505
|
+
} },
|
|
3506
|
+
{ t: "update", ns: "giftClaims", id: claimId, attrs: {
|
|
3507
|
+
id: claimId,
|
|
3508
|
+
orderId,
|
|
3509
|
+
intentId: intent.id,
|
|
3510
|
+
...shared,
|
|
3511
|
+
target: offer.target,
|
|
3512
|
+
codeHash: intent.codeHash,
|
|
3513
|
+
codeVersion: intent.codeVersion,
|
|
3514
|
+
rotationCount: 0,
|
|
3515
|
+
codeIssuedAt: intent.codeIssuedAt,
|
|
3516
|
+
codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
|
|
3517
|
+
attemptCount: 0,
|
|
3518
|
+
attemptWindowStartedAt: now,
|
|
3519
|
+
status: "pending",
|
|
3520
|
+
createdAt: now
|
|
3521
|
+
} },
|
|
3522
|
+
{ t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
|
|
3523
|
+
];
|
|
3524
|
+
if (intent.purchaserApplicationId) ops.push({
|
|
3525
|
+
t: "update",
|
|
3526
|
+
ns: "applications",
|
|
3527
|
+
id: intent.purchaserApplicationId,
|
|
3528
|
+
attrs: { giftPaidAt: now, giftClaimId: claimId }
|
|
3529
|
+
});
|
|
3530
|
+
try {
|
|
3531
|
+
const result = await db.transact(ops, {
|
|
3532
|
+
mutationId: `gift-paid:${provider.eventId}`,
|
|
3533
|
+
guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
|
|
3534
|
+
});
|
|
3535
|
+
return { claimId, orderId, duplicate: result.duplicate };
|
|
3536
|
+
} catch {
|
|
3537
|
+
const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
3538
|
+
if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
|
|
3539
|
+
throw new Error("gift issuance conflict");
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
function buyerGiftClaims(rows4, now) {
|
|
3543
|
+
return rows4.map((row) => {
|
|
3544
|
+
const stored = String(row.status ?? "pending");
|
|
3545
|
+
const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
|
|
3546
|
+
return {
|
|
3547
|
+
id: String(row.id),
|
|
3548
|
+
offerId: String(row.offerId),
|
|
3549
|
+
status,
|
|
3550
|
+
createdAt: Number(row.createdAt),
|
|
3551
|
+
codeExpiresAt: Number(row.codeExpiresAt),
|
|
3552
|
+
...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
|
|
3553
|
+
...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
|
|
3554
|
+
};
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
async function rotateGiftClaim(db, claim, now) {
|
|
3558
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
3559
|
+
const prepared = await prepareGiftCode(String(claim.chapterId));
|
|
3560
|
+
const currentRotation = Number(claim.rotationCount ?? 0);
|
|
3561
|
+
try {
|
|
3562
|
+
const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3563
|
+
codeHash: prepared.codeHash,
|
|
3564
|
+
codeVersion: prepared.codeVersion,
|
|
3565
|
+
rotationCount: currentRotation + 1,
|
|
3566
|
+
codeIssuedAt: now,
|
|
3567
|
+
rotatedAt: now,
|
|
3568
|
+
attemptCount: 0,
|
|
3569
|
+
attemptWindowStartedAt: now
|
|
3570
|
+
} }], {
|
|
3571
|
+
mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
|
|
3572
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
|
|
3573
|
+
});
|
|
3574
|
+
return write.duplicate ? null : { code: prepared.code };
|
|
3575
|
+
} catch {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
3580
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
3581
|
+
const chapterId = String(claim.chapterId);
|
|
3582
|
+
const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
|
|
3583
|
+
const target = claim.target;
|
|
3584
|
+
const ops = [];
|
|
3585
|
+
let result;
|
|
3586
|
+
if (target.type === "tier") {
|
|
3587
|
+
const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
3588
|
+
const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3589
|
+
const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
|
|
3590
|
+
const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
|
|
3591
|
+
ops.push({ t: "update", ns: "applications", id, attrs: {
|
|
3592
|
+
id,
|
|
3593
|
+
groupId: chapterId,
|
|
3594
|
+
email: beneficiary.email,
|
|
3595
|
+
emailNormalized,
|
|
3596
|
+
tierId: target.tierId,
|
|
3597
|
+
status,
|
|
3598
|
+
giftClaimId: String(claim.id),
|
|
3599
|
+
giftOrderId: String(claim.orderId),
|
|
3600
|
+
clerkUserId: beneficiary.userId,
|
|
3601
|
+
...existing ? {} : { createdAt: now }
|
|
3602
|
+
} });
|
|
3603
|
+
result = { kind: "application", id };
|
|
3604
|
+
} else {
|
|
3605
|
+
const id = `gift-entitlement:${String(claim.id)}`;
|
|
3606
|
+
ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
|
|
3607
|
+
id,
|
|
3608
|
+
claimId: String(claim.id),
|
|
3609
|
+
orderId: String(claim.orderId),
|
|
3610
|
+
chapterId,
|
|
3611
|
+
entitlementKey: target.entitlementKey,
|
|
3612
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3613
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3614
|
+
active: true,
|
|
3615
|
+
createdAt: now
|
|
3616
|
+
} });
|
|
3617
|
+
result = { kind: "entitlement", id };
|
|
3618
|
+
}
|
|
3619
|
+
ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3620
|
+
status: "redeemed",
|
|
3621
|
+
redeemedAt: now,
|
|
3622
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3623
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3624
|
+
...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
|
|
3625
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1
|
|
3626
|
+
} });
|
|
3627
|
+
try {
|
|
3628
|
+
await db.transact(ops, {
|
|
3629
|
+
mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
|
|
3630
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
|
|
3631
|
+
status: "pending",
|
|
3632
|
+
codeVersion: Number(claim.codeVersion),
|
|
3633
|
+
codeHash: String(claim.codeHash)
|
|
3634
|
+
} }]
|
|
3635
|
+
});
|
|
3636
|
+
return result;
|
|
3637
|
+
} catch {
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
async function revokeGiftClaim(db, claim, reason, now) {
|
|
3642
|
+
if (claim.status === "revoked") return false;
|
|
3643
|
+
const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3644
|
+
status: "revoked",
|
|
3645
|
+
revokedAt: now,
|
|
3646
|
+
revokedReason: reason
|
|
3647
|
+
} }];
|
|
3648
|
+
if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
|
|
3649
|
+
if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
|
|
3650
|
+
giftRevokedAt: now,
|
|
3651
|
+
...reason === "refunded" ? { status: "refunded" } : { canceled: true }
|
|
3652
|
+
} });
|
|
3653
|
+
try {
|
|
3654
|
+
await db.transact(ops, {
|
|
3655
|
+
mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
|
|
3656
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
|
|
3657
|
+
});
|
|
3658
|
+
return true;
|
|
3659
|
+
} catch {
|
|
3660
|
+
return false;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// src/gift-checkout.ts
|
|
3665
|
+
var clientSecret = (value) => {
|
|
3666
|
+
const direct = value.client_secret;
|
|
3667
|
+
if (typeof direct === "string" && direct) return direct;
|
|
3668
|
+
const invoice = value.latest_invoice;
|
|
3669
|
+
const confirmation = invoice?.confirmation_secret;
|
|
3670
|
+
const intent = invoice?.payment_intent;
|
|
3671
|
+
const nested = confirmation?.client_secret ?? intent?.client_secret;
|
|
3672
|
+
return typeof nested === "string" && nested ? nested : null;
|
|
3673
|
+
};
|
|
3674
|
+
async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
|
|
3675
|
+
const offer = intent.offerSnapshot;
|
|
3676
|
+
const metadata = {
|
|
3677
|
+
giftIntentId: intent.id,
|
|
3678
|
+
chapterId: intent.chapterId,
|
|
3679
|
+
environment: intent.environment,
|
|
3680
|
+
offerId: intent.offerId
|
|
3681
|
+
};
|
|
3682
|
+
let customerId = existingCustomerId;
|
|
3683
|
+
if (!customerId) {
|
|
3684
|
+
const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
|
|
3685
|
+
if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
|
|
3686
|
+
customerId = String(customer.body.id);
|
|
3687
|
+
}
|
|
3688
|
+
if (offer.billingMode === "subscription") {
|
|
3689
|
+
const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
|
|
3690
|
+
customer: customerId,
|
|
3691
|
+
"items[0][price]": offer.stripePriceId,
|
|
3692
|
+
payment_behavior: "default_incomplete",
|
|
3693
|
+
"payment_settings[save_default_payment_method]": "on_subscription",
|
|
3694
|
+
"payment_settings[payment_method_types][0]": "card",
|
|
3695
|
+
"expand[0]": "latest_invoice.confirmation_secret",
|
|
3696
|
+
metadata
|
|
3697
|
+
}, `gift-subscription:${intent.id}`);
|
|
3698
|
+
const secret2 = created2.ok ? clientSecret(created2.body) : null;
|
|
3699
|
+
if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
|
|
3700
|
+
return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
|
|
3701
|
+
}
|
|
3702
|
+
const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
|
|
3703
|
+
if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
|
|
3704
|
+
throw new Error("gift price does not match the signed offer");
|
|
3705
|
+
}
|
|
3706
|
+
const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
|
|
3707
|
+
customer: customerId,
|
|
3708
|
+
amount: offer.priceCents,
|
|
3709
|
+
currency: offer.currency,
|
|
3710
|
+
"automatic_payment_methods[enabled]": true,
|
|
3711
|
+
metadata
|
|
3712
|
+
}, `gift-payment:${intent.id}`);
|
|
3713
|
+
const secret = created.ok ? clientSecret(created.body) : null;
|
|
3714
|
+
if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
|
|
3715
|
+
return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
// src/gift-intents.ts
|
|
3719
|
+
var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3720
|
+
async function startGiftIntent(db, input) {
|
|
3721
|
+
let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
|
|
3722
|
+
let giftCode;
|
|
3723
|
+
if (!stored) {
|
|
3724
|
+
const prepared = await prepareGiftCode(input.chapterId);
|
|
3725
|
+
const attrs = {
|
|
3726
|
+
id: input.id,
|
|
3727
|
+
chapterId: input.chapterId,
|
|
3728
|
+
environment: input.environment,
|
|
3729
|
+
purchaserType: input.purchaserType,
|
|
3730
|
+
purchaserEmailNormalized: input.purchaserEmailNormalized,
|
|
3731
|
+
...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
|
|
3732
|
+
...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
|
|
3733
|
+
offerId: input.offer.id,
|
|
3734
|
+
offerSnapshot: input.offer,
|
|
3735
|
+
status: "created",
|
|
3736
|
+
codeHash: prepared.codeHash,
|
|
3737
|
+
codeVersion: prepared.codeVersion,
|
|
3738
|
+
codeIssuedAt: input.now,
|
|
3739
|
+
createdAt: input.now,
|
|
3740
|
+
updatedAt: input.now
|
|
3741
|
+
};
|
|
3742
|
+
const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
|
|
3743
|
+
mutationId: `gift-intent:${input.id}`
|
|
3744
|
+
});
|
|
3745
|
+
stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
|
|
3746
|
+
if (!write.duplicate) giftCode = prepared.code;
|
|
3747
|
+
}
|
|
3748
|
+
if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
|
|
3749
|
+
throw new Error("gift purchase intent does not match this checkout");
|
|
3750
|
+
}
|
|
3751
|
+
if (stored.status === "paid") throw new Error("gift purchase is already paid");
|
|
3752
|
+
const checkout = await startGiftProviderCheckout(
|
|
3753
|
+
input.stripeSecret,
|
|
3754
|
+
stored,
|
|
3755
|
+
input.purchaserEmail,
|
|
3756
|
+
typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
|
|
3757
|
+
);
|
|
3758
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
|
|
3759
|
+
status: "checkout_ready",
|
|
3760
|
+
providerObjectId: checkout.providerObjectId,
|
|
3761
|
+
...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
|
|
3762
|
+
checkoutStartedAt: input.now,
|
|
3763
|
+
updatedAt: input.now
|
|
3764
|
+
} }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
|
|
3765
|
+
return {
|
|
3766
|
+
gift: true,
|
|
3767
|
+
intentId: input.id,
|
|
3768
|
+
clientSecret: checkout.clientSecret,
|
|
3769
|
+
publishableKey: input.publishableKey,
|
|
3770
|
+
lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
|
|
3771
|
+
...giftCode ? { giftCode } : {}
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
|
|
2248
3775
|
// src/worker-routes-payments.ts
|
|
2249
3776
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2250
3777
|
async function firstRow2(db, ns, q) {
|
|
2251
3778
|
const res = await db.query({ [ns]: { $: q } });
|
|
2252
|
-
const
|
|
2253
|
-
return Array.isArray(
|
|
3779
|
+
const rows4 = res[ns];
|
|
3780
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2254
3781
|
}
|
|
2255
3782
|
function lineItems(group) {
|
|
2256
3783
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2335,6 +3862,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2335
3862
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2336
3863
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2337
3864
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3865
|
+
if (typeof app.giftOfferId === "string") {
|
|
3866
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3867
|
+
const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3868
|
+
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3869
|
+
return json({ error: "gift offer is unavailable" }, 409);
|
|
3870
|
+
}
|
|
3871
|
+
try {
|
|
3872
|
+
const started = await startGiftIntent(db, {
|
|
3873
|
+
id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
|
|
3874
|
+
chapterId: ctx.chapter.id,
|
|
3875
|
+
environment: env.ODLA_ENV,
|
|
3876
|
+
offer,
|
|
3877
|
+
purchaserType: "application",
|
|
3878
|
+
purchaserEmail: String(app.email ?? ""),
|
|
3879
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
|
|
3880
|
+
purchaserApplicationId: applicationId,
|
|
3881
|
+
stripeSecret: secretKey,
|
|
3882
|
+
publishableKey: group.stripePublishableKey,
|
|
3883
|
+
now: Date.now()
|
|
3884
|
+
});
|
|
3885
|
+
await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
|
|
3886
|
+
giftIntentId: started.intentId,
|
|
3887
|
+
giftCheckoutStartedAt: Date.now(),
|
|
3888
|
+
refundPolicyAckAt: Date.now()
|
|
3889
|
+
} }]);
|
|
3890
|
+
return json(started);
|
|
3891
|
+
} catch (error) {
|
|
3892
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
2338
3895
|
const tier = findTier(
|
|
2339
3896
|
await loadTiers(db, group),
|
|
2340
3897
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2376,17 +3933,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2376
3933
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2377
3934
|
const { eventId, event } = ingest;
|
|
2378
3935
|
if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
|
|
3936
|
+
if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
|
|
3937
|
+
const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
|
|
3938
|
+
if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
|
|
3939
|
+
return json({ ok: true, matched: false });
|
|
3940
|
+
}
|
|
3941
|
+
if (event.kind === "gift_paid") {
|
|
3942
|
+
if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
|
|
3943
|
+
if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
|
|
3944
|
+
try {
|
|
3945
|
+
const issued = await issueGiftClaim(db, intent, {
|
|
3946
|
+
eventId,
|
|
3947
|
+
objectId: event.providerObjectId
|
|
3948
|
+
}, Date.now());
|
|
3949
|
+
return json({ ok: true, matched: true, claimId: issued.claimId });
|
|
3950
|
+
} catch {
|
|
3951
|
+
return json({ error: "gift issuance failed" }, 503);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
|
|
3955
|
+
status: event.reason,
|
|
3956
|
+
updatedAt: Date.now()
|
|
3957
|
+
} }], { mutationId: `gift-revoked-intent:${eventId}` });
|
|
3958
|
+
const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
|
|
3959
|
+
if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
|
|
3960
|
+
return json({ ok: true, matched: Boolean(claim) });
|
|
3961
|
+
}
|
|
2379
3962
|
const app = await findApplication(db, event);
|
|
2380
3963
|
if (!app) return json({ ok: true, matched: false });
|
|
2381
3964
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3965
|
+
let next = app;
|
|
2382
3966
|
if (Object.keys(patch).length) {
|
|
2383
3967
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2384
|
-
|
|
3968
|
+
next = { ...app, ...patch };
|
|
2385
3969
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2386
3970
|
}
|
|
2387
3971
|
if (event.kind === "first_payment") {
|
|
2388
3972
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2389
3973
|
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
3974
|
+
if (typeof next.interviewWaivedAt === "number") {
|
|
3975
|
+
await approveApplication({
|
|
3976
|
+
db,
|
|
3977
|
+
env,
|
|
3978
|
+
ctx,
|
|
3979
|
+
app: next,
|
|
3980
|
+
origin: new URL(req.url).origin,
|
|
3981
|
+
reason: "interview_waiver"
|
|
3982
|
+
});
|
|
3983
|
+
}
|
|
2390
3984
|
}
|
|
2391
3985
|
return json({ ok: true });
|
|
2392
3986
|
}
|
|
@@ -2397,6 +3991,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2397
3991
|
return null;
|
|
2398
3992
|
};
|
|
2399
3993
|
|
|
3994
|
+
// src/worker-routes-gifts.ts
|
|
3995
|
+
var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3996
|
+
var rows2 = (value) => Array.isArray(value) ? value : [];
|
|
3997
|
+
var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
|
|
3998
|
+
var userFor = async (req, env, ctx) => {
|
|
3999
|
+
const user = await ctx.verifyUser(req, env);
|
|
4000
|
+
return user?.email ? user : null;
|
|
4001
|
+
};
|
|
4002
|
+
var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
|
|
4003
|
+
async function consumeRateLimit(db, chapterId, requester, now) {
|
|
4004
|
+
const bucket = await giftRequesterBucket(chapterId, requester);
|
|
4005
|
+
const id = `gift-rate:${chapterId}:${bucket}`;
|
|
4006
|
+
const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
|
|
4007
|
+
const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
|
|
4008
|
+
const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
|
|
4009
|
+
if (attemptCount > 10) return false;
|
|
4010
|
+
await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
|
|
4011
|
+
id,
|
|
4012
|
+
chapterId,
|
|
4013
|
+
bucket,
|
|
4014
|
+
attemptCount,
|
|
4015
|
+
windowStartedAt: within ? Number(current?.windowStartedAt) : now,
|
|
4016
|
+
updatedAt: now
|
|
4017
|
+
} }]);
|
|
4018
|
+
return true;
|
|
4019
|
+
}
|
|
4020
|
+
var handleGifts = async (req, url, env, ctx) => {
|
|
4021
|
+
if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
|
|
4022
|
+
const db = ctx.makeDb(env);
|
|
4023
|
+
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
4024
|
+
const user = await userFor(req, env, ctx);
|
|
4025
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4026
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4027
|
+
const found = await db.query({ giftClaims: { $: { where: {
|
|
4028
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
|
|
4029
|
+
chapterId: ctx.chapter.id
|
|
4030
|
+
}, limit: 200 } } });
|
|
4031
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4032
|
+
return json({
|
|
4033
|
+
offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
|
|
4034
|
+
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
4035
|
+
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
4036
|
+
});
|
|
4037
|
+
}
|
|
4038
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
|
|
4039
|
+
const user = await userFor(req, env, ctx);
|
|
4040
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4041
|
+
let body;
|
|
4042
|
+
try {
|
|
4043
|
+
body = JSON.parse(await req.text());
|
|
4044
|
+
} catch {
|
|
4045
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
4046
|
+
}
|
|
4047
|
+
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
4048
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
4049
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4050
|
+
const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
4051
|
+
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
4052
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4053
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
4054
|
+
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
4055
|
+
const emailNormalized = normalizeAdmissionEmail(user.email);
|
|
4056
|
+
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
4057
|
+
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
4058
|
+
try {
|
|
4059
|
+
return json(await startGiftIntent(db, {
|
|
4060
|
+
id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
|
|
4061
|
+
chapterId: ctx.chapter.id,
|
|
4062
|
+
environment: env.ODLA_ENV,
|
|
4063
|
+
offer,
|
|
4064
|
+
purchaserType: "member",
|
|
4065
|
+
purchaserEmail: user.email,
|
|
4066
|
+
purchaserEmailNormalized: emailNormalized,
|
|
4067
|
+
purchaserUserId: user.userId,
|
|
4068
|
+
...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
|
|
4069
|
+
stripeSecret,
|
|
4070
|
+
publishableKey: group.stripePublishableKey,
|
|
4071
|
+
now: Date.now()
|
|
4072
|
+
}));
|
|
4073
|
+
} catch (error) {
|
|
4074
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
|
|
4078
|
+
if (req.method === "POST" && rotateMatch) {
|
|
4079
|
+
let claimId = "";
|
|
4080
|
+
try {
|
|
4081
|
+
claimId = decodeURIComponent(rotateMatch[1]);
|
|
4082
|
+
} catch {
|
|
4083
|
+
return invalid();
|
|
4084
|
+
}
|
|
4085
|
+
if (!claimId.startsWith("gift-claim:")) return invalid();
|
|
4086
|
+
const claim = await loadClaim(db, claimId);
|
|
4087
|
+
if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
|
|
4088
|
+
const user = await userFor(req, env, ctx);
|
|
4089
|
+
let applicationId = "";
|
|
4090
|
+
if (!user) {
|
|
4091
|
+
try {
|
|
4092
|
+
applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
|
|
4093
|
+
} catch {
|
|
4094
|
+
return invalid();
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
4098
|
+
if (!owns) return invalid();
|
|
4099
|
+
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
4100
|
+
return rotated ? json(rotated) : invalid();
|
|
4101
|
+
}
|
|
4102
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
|
|
4103
|
+
const user = await userFor(req, env, ctx);
|
|
4104
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4105
|
+
const now = Date.now();
|
|
4106
|
+
if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
|
|
4107
|
+
return json({ error: "too many attempts" }, 429);
|
|
4108
|
+
}
|
|
4109
|
+
let code = "";
|
|
4110
|
+
try {
|
|
4111
|
+
code = String(JSON.parse(await req.text()).code ?? "").trim();
|
|
4112
|
+
} catch {
|
|
4113
|
+
return invalid();
|
|
4114
|
+
}
|
|
4115
|
+
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
|
|
4116
|
+
const codeHash = await hashGiftCode(ctx.chapter.id, code);
|
|
4117
|
+
const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
|
|
4118
|
+
if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
|
|
4119
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
|
|
4120
|
+
await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
4121
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1,
|
|
4122
|
+
...claim.status === "pending" ? { status: "expired" } : {}
|
|
4123
|
+
} }]).catch(() => void 0);
|
|
4124
|
+
return invalid();
|
|
4125
|
+
}
|
|
4126
|
+
const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
|
|
4127
|
+
return redeemed ? json({ ok: true, ...redeemed }) : invalid();
|
|
4128
|
+
}
|
|
4129
|
+
return null;
|
|
4130
|
+
};
|
|
4131
|
+
|
|
2400
4132
|
// src/worker-routes-admin.ts
|
|
2401
4133
|
var import_calendar2 = require("@odla-ai/calendar");
|
|
2402
4134
|
|
|
@@ -2468,8 +4200,8 @@ async function upcomingEvents(env) {
|
|
|
2468
4200
|
const res = await cal.availability.upcoming();
|
|
2469
4201
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2470
4202
|
}
|
|
2471
|
-
function toReconcile(
|
|
2472
|
-
return
|
|
4203
|
+
function toReconcile(rows4) {
|
|
4204
|
+
return rows4.map((m) => ({
|
|
2473
4205
|
id: String(m.id),
|
|
2474
4206
|
applicationId: String(m.applicationId),
|
|
2475
4207
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2491,12 +4223,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2491
4223
|
const query = await db.query({
|
|
2492
4224
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2493
4225
|
});
|
|
2494
|
-
let
|
|
2495
|
-
if (Number.isFinite(from))
|
|
2496
|
-
if (Number.isFinite(to))
|
|
4226
|
+
let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
|
|
4227
|
+
if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
|
|
4228
|
+
if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
|
|
2497
4229
|
let decisions = [];
|
|
2498
4230
|
try {
|
|
2499
|
-
decisions = reconcileMeetings(toReconcile(
|
|
4231
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2500
4232
|
} catch {
|
|
2501
4233
|
}
|
|
2502
4234
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2523,69 +4255,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2523
4255
|
};
|
|
2524
4256
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2525
4257
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2526
|
-
const meetings =
|
|
4258
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2527
4259
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2528
4260
|
};
|
|
2529
4261
|
|
|
2530
|
-
// src/clerk-roles.ts
|
|
2531
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2532
|
-
var DEFAULT_ROLE = "provisional";
|
|
2533
|
-
var PAGE = 100;
|
|
2534
|
-
function toRecord(u) {
|
|
2535
|
-
if (typeof u.id !== "string") return null;
|
|
2536
|
-
const pm = u.public_metadata ?? {};
|
|
2537
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2538
|
-
const rawProfile = privateMetadata.profile;
|
|
2539
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2540
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2541
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2542
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2543
|
-
return {
|
|
2544
|
-
id: u.id,
|
|
2545
|
-
email: typeof email === "string" ? email : void 0,
|
|
2546
|
-
role,
|
|
2547
|
-
publicMetadata: pm,
|
|
2548
|
-
privateMetadata,
|
|
2549
|
-
profile,
|
|
2550
|
-
...applicationId ? { applicationId } : {}
|
|
2551
|
-
};
|
|
2552
|
-
}
|
|
2553
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2554
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2555
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2556
|
-
return res.json();
|
|
2557
|
-
}
|
|
2558
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2559
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2560
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2561
|
-
return user ? toRecord(user) : null;
|
|
2562
|
-
}
|
|
2563
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2564
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2565
|
-
return data ? toRecord(data) : null;
|
|
2566
|
-
}
|
|
2567
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2568
|
-
const out = [];
|
|
2569
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2570
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2571
|
-
const page = Array.isArray(data) ? data : [];
|
|
2572
|
-
for (const u of page) {
|
|
2573
|
-
const record = toRecord(u);
|
|
2574
|
-
if (record) out.push(record);
|
|
2575
|
-
}
|
|
2576
|
-
if (page.length < PAGE) break;
|
|
2577
|
-
}
|
|
2578
|
-
return out;
|
|
2579
|
-
}
|
|
2580
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2581
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2582
|
-
method: "PATCH",
|
|
2583
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2584
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2585
|
-
});
|
|
2586
|
-
return res.ok;
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
4262
|
// src/worker-routes-admin-people.ts
|
|
2590
4263
|
async function adminGate(req, env, ctx) {
|
|
2591
4264
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2603,16 +4276,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2603
4276
|
});
|
|
2604
4277
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2605
4278
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2606
|
-
const
|
|
2607
|
-
if (
|
|
2608
|
-
const result = await backfillCrm(crmDeps2(
|
|
4279
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4280
|
+
if (gate9 instanceof Response) return gate9;
|
|
4281
|
+
const result = await backfillCrm(crmDeps2(gate9.db, ctx));
|
|
2609
4282
|
return json({ ok: true, ...result });
|
|
2610
4283
|
};
|
|
2611
4284
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2612
4285
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2613
|
-
const
|
|
2614
|
-
if (
|
|
2615
|
-
const { db } =
|
|
4286
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4287
|
+
if (gate9 instanceof Response) return gate9;
|
|
4288
|
+
const { db } = gate9;
|
|
2616
4289
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2617
4290
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2618
4291
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2656,16 +4329,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2656
4329
|
});
|
|
2657
4330
|
}
|
|
2658
4331
|
}
|
|
2659
|
-
const
|
|
4332
|
+
const rows4 = [...people.values()].sort(
|
|
2660
4333
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2661
4334
|
);
|
|
2662
|
-
return json({ people:
|
|
4335
|
+
return json({ people: rows4 });
|
|
2663
4336
|
};
|
|
2664
4337
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2665
4338
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2666
|
-
const
|
|
2667
|
-
if (
|
|
2668
|
-
const { db } =
|
|
4339
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4340
|
+
if (gate9 instanceof Response) return gate9;
|
|
4341
|
+
const { db } = gate9;
|
|
2669
4342
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2670
4343
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2671
4344
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2676,9 +4349,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2676
4349
|
};
|
|
2677
4350
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2678
4351
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2679
|
-
const
|
|
2680
|
-
if (
|
|
2681
|
-
const { db, actor } =
|
|
4352
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4353
|
+
if (gate9 instanceof Response) return gate9;
|
|
4354
|
+
const { db, actor } = gate9;
|
|
2682
4355
|
let body;
|
|
2683
4356
|
try {
|
|
2684
4357
|
body = await req.json();
|
|
@@ -2825,17 +4498,17 @@ function subAnnualCents(sub) {
|
|
|
2825
4498
|
}
|
|
2826
4499
|
|
|
2827
4500
|
// src/worker-routes-admin-dashboard.ts
|
|
2828
|
-
async function
|
|
4501
|
+
async function gate2(req, env, ctx) {
|
|
2829
4502
|
const rawDb = ctx.makeDb(env);
|
|
2830
4503
|
const u = await ctx.verifyUser(req, env);
|
|
2831
4504
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2832
4505
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2833
4506
|
return rawDb;
|
|
2834
4507
|
}
|
|
2835
|
-
var
|
|
4508
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2836
4509
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2837
4510
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2838
|
-
const got = await
|
|
4511
|
+
const got = await gate2(req, env, ctx);
|
|
2839
4512
|
if (got instanceof Response) return got;
|
|
2840
4513
|
const db = got;
|
|
2841
4514
|
const now = Date.now();
|
|
@@ -2847,7 +4520,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2847
4520
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2848
4521
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2849
4522
|
]);
|
|
2850
|
-
const apps =
|
|
4523
|
+
const apps = rows3(appsRes.applications);
|
|
2851
4524
|
const applications = {
|
|
2852
4525
|
total: apps.length,
|
|
2853
4526
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2859,7 +4532,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2859
4532
|
pipeline[s] = 0;
|
|
2860
4533
|
pipelineDelta[s] = 0;
|
|
2861
4534
|
}
|
|
2862
|
-
for (const r of
|
|
4535
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2863
4536
|
const s = r.stage;
|
|
2864
4537
|
if (s in pipeline) {
|
|
2865
4538
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2867,7 +4540,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2867
4540
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2868
4541
|
}
|
|
2869
4542
|
}
|
|
2870
|
-
const meetings =
|
|
4543
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2871
4544
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2872
4545
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2873
4546
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2892,7 +4565,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2892
4565
|
if (sk) {
|
|
2893
4566
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2894
4567
|
if (subsRes.ok) {
|
|
2895
|
-
const subs =
|
|
4568
|
+
const subs = rows3(subsRes.body.data);
|
|
2896
4569
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2897
4570
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2898
4571
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2912,7 +4585,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2912
4585
|
};
|
|
2913
4586
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2914
4587
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2915
|
-
const got = await
|
|
4588
|
+
const got = await gate2(req, env, ctx);
|
|
2916
4589
|
if (got instanceof Response) return got;
|
|
2917
4590
|
const db = got;
|
|
2918
4591
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2924,9 +4597,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2924
4597
|
]);
|
|
2925
4598
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2926
4599
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2927
|
-
const subById = new Map(
|
|
4600
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2928
4601
|
const billingRows = [];
|
|
2929
|
-
for (const a of
|
|
4602
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2930
4603
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2931
4604
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2932
4605
|
const items = sub?.items?.data ?? [];
|
|
@@ -2970,7 +4643,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2970
4643
|
|
|
2971
4644
|
// src/worker-routes-admin-lifecycle.ts
|
|
2972
4645
|
var import_calendar3 = require("@odla-ai/calendar");
|
|
2973
|
-
async function
|
|
4646
|
+
async function gate3(req, env, ctx) {
|
|
2974
4647
|
const rawDb = ctx.makeDb(env);
|
|
2975
4648
|
const u = await ctx.verifyUser(req, env);
|
|
2976
4649
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2996,7 +4669,7 @@ async function loadGroup(db, id) {
|
|
|
2996
4669
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2997
4670
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2998
4671
|
if (req.method !== "POST" || !m) return null;
|
|
2999
|
-
const got = await
|
|
4672
|
+
const got = await gate3(req, env, ctx);
|
|
3000
4673
|
if (got instanceof Response) return got;
|
|
3001
4674
|
const db = got;
|
|
3002
4675
|
const body = await readJson(req);
|
|
@@ -3037,7 +4710,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3037
4710
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3038
4711
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3039
4712
|
if (req.method !== "POST" || !m) return null;
|
|
3040
|
-
const got = await
|
|
4713
|
+
const got = await gate3(req, env, ctx);
|
|
3041
4714
|
if (got instanceof Response) return got;
|
|
3042
4715
|
const db = got;
|
|
3043
4716
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3060,43 +4733,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3060
4733
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3061
4734
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3062
4735
|
if (req.method !== "POST" || !m) return null;
|
|
3063
|
-
const got = await
|
|
4736
|
+
const got = await gate3(req, env, ctx);
|
|
3064
4737
|
if (got instanceof Response) return got;
|
|
3065
4738
|
const db = got;
|
|
3066
4739
|
const id = m[1];
|
|
3067
4740
|
const app = await loadApp(db, id);
|
|
3068
4741
|
if (!app) return json({ error: "not found" }, 404);
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3073
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3074
|
-
let rolePromoted = false;
|
|
3075
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3076
|
-
if (promoteTo !== false && sk) {
|
|
3077
|
-
let userId = app.clerkUserId || null;
|
|
3078
|
-
if (!userId) {
|
|
3079
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3080
|
-
userId = found?.id ?? null;
|
|
3081
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3082
|
-
}
|
|
3083
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3084
|
-
}
|
|
3085
|
-
let emailLogged = false;
|
|
3086
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3087
|
-
if (send !== false && group) {
|
|
3088
|
-
const res = await sendTemplated(
|
|
3089
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3090
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3091
|
-
);
|
|
3092
|
-
emailLogged = res.sent;
|
|
3093
|
-
}
|
|
3094
|
-
return json({ ok: true, status: target, rolePromoted, emailLogged });
|
|
4742
|
+
const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
|
|
4743
|
+
if (!result.ok) return json({ error: result.error }, result.status);
|
|
4744
|
+
return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
|
|
3095
4745
|
};
|
|
3096
4746
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3097
4747
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3098
4748
|
if (req.method !== "POST" || !m) return null;
|
|
3099
|
-
const got = await
|
|
4749
|
+
const got = await gate3(req, env, ctx);
|
|
3100
4750
|
if (got instanceof Response) return got;
|
|
3101
4751
|
const db = got;
|
|
3102
4752
|
const app = await loadApp(db, m[1]);
|
|
@@ -3128,7 +4778,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3128
4778
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3129
4779
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3130
4780
|
if (req.method !== "PATCH" || !m) return null;
|
|
3131
|
-
const got = await
|
|
4781
|
+
const got = await gate3(req, env, ctx);
|
|
3132
4782
|
if (got instanceof Response) return got;
|
|
3133
4783
|
const db = got;
|
|
3134
4784
|
const id = m[1];
|
|
@@ -3156,7 +4806,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3156
4806
|
};
|
|
3157
4807
|
|
|
3158
4808
|
// src/worker-routes-admin-comms.ts
|
|
3159
|
-
async function
|
|
4809
|
+
async function gate4(req, env, ctx) {
|
|
3160
4810
|
const rawDb = ctx.makeDb(env);
|
|
3161
4811
|
const u = await ctx.verifyUser(req, env);
|
|
3162
4812
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3178,7 +4828,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3178
4828
|
});
|
|
3179
4829
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3180
4830
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3181
|
-
const got = await
|
|
4831
|
+
const got = await gate4(req, env, ctx);
|
|
3182
4832
|
if (got instanceof Response) return got;
|
|
3183
4833
|
const db = got;
|
|
3184
4834
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3219,11 +4869,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3219
4869
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3220
4870
|
const t = templates[key];
|
|
3221
4871
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3222
|
-
const
|
|
3223
|
-
if (!subject || !
|
|
4872
|
+
const text3 = typeof t?.text === "string" ? t.text : "";
|
|
4873
|
+
if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
|
|
3224
4874
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3225
|
-
if (
|
|
3226
|
-
clean[key] = { subject, text:
|
|
4875
|
+
if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
|
|
4876
|
+
clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
|
|
3227
4877
|
}
|
|
3228
4878
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3229
4879
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3240,7 +4890,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3240
4890
|
};
|
|
3241
4891
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3242
4892
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3243
|
-
const got = await
|
|
4893
|
+
const got = await gate4(req, env, ctx);
|
|
3244
4894
|
if (got instanceof Response) return got;
|
|
3245
4895
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3246
4896
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3257,7 +4907,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3257
4907
|
};
|
|
3258
4908
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3259
4909
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3260
|
-
const got = await
|
|
4910
|
+
const got = await gate4(req, env, ctx);
|
|
3261
4911
|
if (got instanceof Response) return got;
|
|
3262
4912
|
const db = got;
|
|
3263
4913
|
let body;
|
|
@@ -3285,7 +4935,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3285
4935
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3286
4936
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3287
4937
|
if (req.method !== "GET" || !m) return null;
|
|
3288
|
-
const got = await
|
|
4938
|
+
const got = await gate4(req, env, ctx);
|
|
3289
4939
|
if (got instanceof Response) return got;
|
|
3290
4940
|
const db = got;
|
|
3291
4941
|
const appId = m[1];
|
|
@@ -3318,7 +4968,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3318
4968
|
|
|
3319
4969
|
// src/worker-routes-network.ts
|
|
3320
4970
|
var import_crm6 = require("@odla-ai/crm");
|
|
3321
|
-
var
|
|
4971
|
+
var import_db7 = require("@odla-ai/db");
|
|
3322
4972
|
|
|
3323
4973
|
// src/worker-network-fetch.ts
|
|
3324
4974
|
function targetFetcher(env, target) {
|
|
@@ -3336,8 +4986,8 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3336
4986
|
|
|
3337
4987
|
// src/worker-network-push.ts
|
|
3338
4988
|
var import_crm5 = require("@odla-ai/crm");
|
|
3339
|
-
var
|
|
3340
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4989
|
+
var import_db6 = require("@odla-ai/db");
|
|
4990
|
+
async function pushNetworkRecord(db, env, ctx, target, record3) {
|
|
3341
4991
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3342
4992
|
const crmDeps3 = {
|
|
3343
4993
|
crm: ctx.chapter.crm,
|
|
@@ -3348,7 +4998,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3348
4998
|
if (!secret) {
|
|
3349
4999
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3350
5000
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3351
|
-
recordId:
|
|
5001
|
+
recordId: record3.id,
|
|
3352
5002
|
targetId: target.id,
|
|
3353
5003
|
status: "failed",
|
|
3354
5004
|
payloadVersion: 2,
|
|
@@ -3358,7 +5008,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3358
5008
|
}
|
|
3359
5009
|
let payload;
|
|
3360
5010
|
try {
|
|
3361
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
5011
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
|
|
3362
5012
|
} catch (err) {
|
|
3363
5013
|
return {
|
|
3364
5014
|
id: target.id,
|
|
@@ -3370,7 +5020,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3370
5020
|
const payloadBody = JSON.stringify(payload);
|
|
3371
5021
|
const destination = new URL("/api/network/shared", target.url);
|
|
3372
5022
|
try {
|
|
3373
|
-
const signed = await (0,
|
|
5023
|
+
const signed = await (0, import_db6.signFederatedRequest)({
|
|
3374
5024
|
secret,
|
|
3375
5025
|
sender: ctx.chapter.id,
|
|
3376
5026
|
method: "POST",
|
|
@@ -3387,7 +5037,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3387
5037
|
if (!res.ok) {
|
|
3388
5038
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3389
5039
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3390
|
-
recordId:
|
|
5040
|
+
recordId: record3.id,
|
|
3391
5041
|
targetId: target.id,
|
|
3392
5042
|
status: "failed",
|
|
3393
5043
|
payloadVersion: 2,
|
|
@@ -3402,7 +5052,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3402
5052
|
};
|
|
3403
5053
|
}
|
|
3404
5054
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3405
|
-
recordId:
|
|
5055
|
+
recordId: record3.id,
|
|
3406
5056
|
targetId: target.id,
|
|
3407
5057
|
status: "delivered",
|
|
3408
5058
|
payloadVersion: 2,
|
|
@@ -3418,7 +5068,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3418
5068
|
} catch (err) {
|
|
3419
5069
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3420
5070
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
3421
|
-
recordId:
|
|
5071
|
+
recordId: record3.id,
|
|
3422
5072
|
targetId: target.id,
|
|
3423
5073
|
status: "failed",
|
|
3424
5074
|
payloadVersion: 2,
|
|
@@ -3429,7 +5079,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3429
5079
|
}
|
|
3430
5080
|
|
|
3431
5081
|
// src/worker-routes-network.ts
|
|
3432
|
-
async function
|
|
5082
|
+
async function gate5(req, env, ctx) {
|
|
3433
5083
|
const db = ctx.makeDb(env);
|
|
3434
5084
|
const user = await ctx.verifyUser(req, env);
|
|
3435
5085
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3438,7 +5088,7 @@ async function gate4(req, env, ctx) {
|
|
|
3438
5088
|
}
|
|
3439
5089
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3440
5090
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3441
|
-
const got = await
|
|
5091
|
+
const got = await gate5(req, env, ctx);
|
|
3442
5092
|
if ("response" in got) return got.response;
|
|
3443
5093
|
return json({
|
|
3444
5094
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3455,7 +5105,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3455
5105
|
const db = ctx.makeDb(env);
|
|
3456
5106
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3457
5107
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3458
|
-
const verified = await (0,
|
|
5108
|
+
const verified = await (0, import_db7.verifyFederatedRequest)(req, { secret });
|
|
3459
5109
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3460
5110
|
const types = await Promise.all(
|
|
3461
5111
|
Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3473,7 +5123,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3473
5123
|
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
3474
5124
|
const destination = new URL("/api/network/snapshot", target.url);
|
|
3475
5125
|
try {
|
|
3476
|
-
const headers = await (0,
|
|
5126
|
+
const headers = await (0, import_db7.signFederatedRequest)({
|
|
3477
5127
|
secret,
|
|
3478
5128
|
sender: ctx.chapter.id,
|
|
3479
5129
|
method: "GET",
|
|
@@ -3514,7 +5164,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3514
5164
|
}
|
|
3515
5165
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3516
5166
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3517
|
-
const got = await
|
|
5167
|
+
const got = await gate5(req, env, ctx);
|
|
3518
5168
|
if ("response" in got) return got.response;
|
|
3519
5169
|
const targets = await Promise.all(
|
|
3520
5170
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3541,7 +5191,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3541
5191
|
};
|
|
3542
5192
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3543
5193
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3544
|
-
const got = await
|
|
5194
|
+
const got = await gate5(req, env, ctx);
|
|
3545
5195
|
if ("response" in got) return got.response;
|
|
3546
5196
|
let body;
|
|
3547
5197
|
try {
|
|
@@ -3556,22 +5206,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3556
5206
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3557
5207
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3558
5208
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3559
|
-
const
|
|
3560
|
-
if (!
|
|
5209
|
+
const record3 = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
5210
|
+
if (!record3) return json({ error: "record not found" }, 404);
|
|
3561
5211
|
const results = await Promise.all(
|
|
3562
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
5212
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
|
|
3563
5213
|
);
|
|
3564
5214
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3565
5215
|
};
|
|
3566
5216
|
|
|
3567
5217
|
// src/worker-routes-network-records.ts
|
|
3568
5218
|
var import_crm7 = require("@odla-ai/crm");
|
|
3569
|
-
var
|
|
5219
|
+
var import_db8 = require("@odla-ai/db");
|
|
3570
5220
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3571
5221
|
var MAX_PAGE = 50;
|
|
3572
5222
|
var MAX_OFFSET = 1e4;
|
|
3573
5223
|
var MAX_SEARCH = 200;
|
|
3574
|
-
async function
|
|
5224
|
+
async function gate6(req, env, ctx) {
|
|
3575
5225
|
const db = ctx.makeDb(env);
|
|
3576
5226
|
const user = await ctx.verifyUser(req, env);
|
|
3577
5227
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3584,18 +5234,18 @@ function integer(value, fallback, min, max) {
|
|
|
3584
5234
|
const parsed = Number(value);
|
|
3585
5235
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3586
5236
|
}
|
|
3587
|
-
function projectRecord(
|
|
5237
|
+
function projectRecord(record3, allowed2) {
|
|
3588
5238
|
const fields = Object.fromEntries(
|
|
3589
|
-
|
|
5239
|
+
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
|
|
3590
5240
|
);
|
|
3591
5241
|
return {
|
|
3592
|
-
id:
|
|
3593
|
-
type:
|
|
3594
|
-
name:
|
|
3595
|
-
...
|
|
5242
|
+
id: record3.id,
|
|
5243
|
+
type: record3.type,
|
|
5244
|
+
name: record3.name,
|
|
5245
|
+
...record3.stage ? { stage: record3.stage } : {},
|
|
3596
5246
|
fields,
|
|
3597
|
-
createdAt:
|
|
3598
|
-
updatedAt:
|
|
5247
|
+
createdAt: record3.createdAt,
|
|
5248
|
+
updatedAt: record3.updatedAt
|
|
3599
5249
|
};
|
|
3600
5250
|
}
|
|
3601
5251
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3604,15 +5254,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3604
5254
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3605
5255
|
const readers = ctx.chapter.network.readers;
|
|
3606
5256
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3607
|
-
const verified = await (0,
|
|
5257
|
+
const verified = await (0, import_db8.verifyFederatedRequest)(req, {
|
|
3608
5258
|
secret,
|
|
3609
5259
|
senders: readers.map((reader2) => reader2.id)
|
|
3610
5260
|
});
|
|
3611
5261
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3612
5262
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
3613
5263
|
const type = url.searchParams.get("type") ?? "";
|
|
3614
|
-
const
|
|
3615
|
-
if (!IDENTIFIER.test(type) || !
|
|
5264
|
+
const allowed2 = reader?.fields[type];
|
|
5265
|
+
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
3616
5266
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
3617
5267
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
3618
5268
|
const search = (url.searchParams.get("search") ?? "").trim();
|
|
@@ -3633,7 +5283,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3633
5283
|
version: 1,
|
|
3634
5284
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3635
5285
|
type,
|
|
3636
|
-
records: page.records.map((
|
|
5286
|
+
records: page.records.map((record3) => projectRecord(record3, allowed2)),
|
|
3637
5287
|
total: page.total,
|
|
3638
5288
|
limit: page.limit,
|
|
3639
5289
|
offset: page.offset
|
|
@@ -3649,7 +5299,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3649
5299
|
const destination = new URL("/api/network/records", target.url);
|
|
3650
5300
|
destination.search = query.toString();
|
|
3651
5301
|
try {
|
|
3652
|
-
const headers = await (0,
|
|
5302
|
+
const headers = await (0, import_db8.signFederatedRequest)({
|
|
3653
5303
|
secret,
|
|
3654
5304
|
sender: ctx.chapter.id,
|
|
3655
5305
|
method: "GET",
|
|
@@ -3674,7 +5324,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3674
5324
|
}
|
|
3675
5325
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3676
5326
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3677
|
-
const got = await
|
|
5327
|
+
const got = await gate6(req, env, ctx);
|
|
3678
5328
|
if ("response" in got) return got.response;
|
|
3679
5329
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3680
5330
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3701,8 +5351,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3701
5351
|
// src/worker-routes-network-notes.ts
|
|
3702
5352
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3703
5353
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3704
|
-
var
|
|
3705
|
-
async function
|
|
5354
|
+
var MAX_NOTE2 = 4e3;
|
|
5355
|
+
async function gate7(req, env, ctx) {
|
|
3706
5356
|
const db = ctx.makeDb(env);
|
|
3707
5357
|
const user = await ctx.verifyUser(req, env);
|
|
3708
5358
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3736,7 +5386,7 @@ function asNote(row) {
|
|
|
3736
5386
|
}
|
|
3737
5387
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3738
5388
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3739
|
-
const got = await
|
|
5389
|
+
const got = await gate7(req, env, ctx);
|
|
3740
5390
|
if ("response" in got) return got.response;
|
|
3741
5391
|
const identity = noteIdentity(url, ctx);
|
|
3742
5392
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3753,7 +5403,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3753
5403
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3754
5404
|
}
|
|
3755
5405
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3756
|
-
if (contentLength >
|
|
5406
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3757
5407
|
let input;
|
|
3758
5408
|
try {
|
|
3759
5409
|
input = await req.json();
|
|
@@ -3761,7 +5411,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3761
5411
|
return json({ error: "invalid JSON body" }, 400);
|
|
3762
5412
|
}
|
|
3763
5413
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3764
|
-
if (!body || body.length >
|
|
5414
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3765
5415
|
const id = crypto.randomUUID();
|
|
3766
5416
|
const note = {
|
|
3767
5417
|
id,
|
|
@@ -3784,13 +5434,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3784
5434
|
|
|
3785
5435
|
// src/worker-routes-network-shared-notes.ts
|
|
3786
5436
|
var import_crm8 = require("@odla-ai/crm");
|
|
3787
|
-
var
|
|
5437
|
+
var import_db9 = require("@odla-ai/db");
|
|
3788
5438
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3789
5439
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3790
5440
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3791
|
-
var
|
|
5441
|
+
var MAX_NOTE3 = 4e3;
|
|
3792
5442
|
var NETWORK_META = "chapterNetwork";
|
|
3793
|
-
async function
|
|
5443
|
+
async function gate8(req, env, ctx) {
|
|
3794
5444
|
const db = ctx.makeDb(env);
|
|
3795
5445
|
const user = await ctx.verifyUser(req, env);
|
|
3796
5446
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3836,14 +5486,14 @@ function sourceLabel(sourceId) {
|
|
|
3836
5486
|
}
|
|
3837
5487
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3838
5488
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3839
|
-
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) >
|
|
5489
|
+
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
|
|
3840
5490
|
return json({ error: "note is too large" }, 413);
|
|
3841
5491
|
}
|
|
3842
5492
|
const db = ctx.makeDb(env);
|
|
3843
5493
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3844
5494
|
const readers = ctx.chapter.network.readers;
|
|
3845
5495
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3846
|
-
const verified = await (0,
|
|
5496
|
+
const verified = await (0, import_db9.verifyFederatedRequest)(req, {
|
|
3847
5497
|
secret,
|
|
3848
5498
|
senders: readers.map((reader2) => reader2.id)
|
|
3849
5499
|
});
|
|
@@ -3854,8 +5504,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3854
5504
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3855
5505
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3856
5506
|
}
|
|
3857
|
-
const
|
|
3858
|
-
if (!
|
|
5507
|
+
const record3 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
|
|
5508
|
+
if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
|
|
3859
5509
|
if (req.method === "GET") {
|
|
3860
5510
|
return json({
|
|
3861
5511
|
version: 1,
|
|
@@ -3871,8 +5521,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3871
5521
|
}
|
|
3872
5522
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3873
5523
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3874
|
-
if (!body || body.length >
|
|
3875
|
-
return json({ error: `body must be 1\u2013${
|
|
5524
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
|
|
5525
|
+
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
3876
5526
|
}
|
|
3877
5527
|
const createdAt = Date.now();
|
|
3878
5528
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3914,7 +5564,7 @@ function targetFor2(ctx, id) {
|
|
|
3914
5564
|
}
|
|
3915
5565
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3916
5566
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3917
|
-
const got = await
|
|
5567
|
+
const got = await gate8(req, env, ctx);
|
|
3918
5568
|
if ("response" in got) return got.response;
|
|
3919
5569
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3920
5570
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3932,7 +5582,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3932
5582
|
return json({ error: "invalid JSON body" }, 400);
|
|
3933
5583
|
}
|
|
3934
5584
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3935
|
-
if (!note || note.length >
|
|
5585
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3936
5586
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3937
5587
|
}
|
|
3938
5588
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -3940,7 +5590,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3940
5590
|
const destination = new URL("/api/network/shared-notes", target.url);
|
|
3941
5591
|
destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
|
|
3942
5592
|
try {
|
|
3943
|
-
const headers = await (0,
|
|
5593
|
+
const headers = await (0, import_db9.signFederatedRequest)({
|
|
3944
5594
|
secret,
|
|
3945
5595
|
sender: ctx.chapter.id,
|
|
3946
5596
|
method: req.method,
|
|
@@ -4009,15 +5659,15 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
4009
5659
|
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
4010
5660
|
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
4011
5661
|
}
|
|
4012
|
-
const
|
|
4013
|
-
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !
|
|
5662
|
+
const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
5663
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
|
|
4014
5664
|
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
4015
5665
|
for (const field of formation.required) {
|
|
4016
5666
|
const value = body[field];
|
|
4017
5667
|
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
4018
5668
|
}
|
|
4019
5669
|
const input = {};
|
|
4020
|
-
for (const field of
|
|
5670
|
+
for (const field of allowed2) {
|
|
4021
5671
|
const value = body[field];
|
|
4022
5672
|
if (value === void 0) continue;
|
|
4023
5673
|
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
@@ -4061,11 +5711,11 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
4061
5711
|
};
|
|
4062
5712
|
|
|
4063
5713
|
// src/signup-control-delivery.ts
|
|
4064
|
-
var
|
|
5714
|
+
var import_db10 = require("@odla-ai/db");
|
|
4065
5715
|
async function deliverSignupControl(options) {
|
|
4066
5716
|
const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
|
|
4067
5717
|
const body = JSON.stringify(options.envelope);
|
|
4068
|
-
const signed = await (0,
|
|
5718
|
+
const signed = await (0, import_db10.signFederatedRequest)({
|
|
4069
5719
|
secret: options.secret,
|
|
4070
5720
|
sender: options.sender,
|
|
4071
5721
|
method: "POST",
|
|
@@ -4085,6 +5735,56 @@ async function deliverSignupControl(options) {
|
|
|
4085
5735
|
return result;
|
|
4086
5736
|
}
|
|
4087
5737
|
|
|
5738
|
+
// src/member-content-delivery.ts
|
|
5739
|
+
var import_db11 = require("@odla-ai/db");
|
|
5740
|
+
async function deliverMemberContent(options) {
|
|
5741
|
+
const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
|
|
5742
|
+
const body = JSON.stringify(options.envelope);
|
|
5743
|
+
const signed = await (0, import_db11.signFederatedRequest)({
|
|
5744
|
+
secret: options.secret,
|
|
5745
|
+
sender: options.sender,
|
|
5746
|
+
method: "POST",
|
|
5747
|
+
url: destination,
|
|
5748
|
+
body
|
|
5749
|
+
});
|
|
5750
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5751
|
+
method: "POST",
|
|
5752
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5753
|
+
body
|
|
5754
|
+
});
|
|
5755
|
+
const result = await response.json();
|
|
5756
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5757
|
+
const failure = result;
|
|
5758
|
+
throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5759
|
+
}
|
|
5760
|
+
return result;
|
|
5761
|
+
}
|
|
5762
|
+
|
|
5763
|
+
// src/admission-grants-delivery.ts
|
|
5764
|
+
var import_db12 = require("@odla-ai/db");
|
|
5765
|
+
async function deliverAdmissionGrants(options) {
|
|
5766
|
+
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5767
|
+
const body = JSON.stringify(options.envelope);
|
|
5768
|
+
const signed = await (0, import_db12.signFederatedRequest)({
|
|
5769
|
+
secret: options.secret,
|
|
5770
|
+
sender: options.sender,
|
|
5771
|
+
method: "POST",
|
|
5772
|
+
url: destination,
|
|
5773
|
+
body
|
|
5774
|
+
});
|
|
5775
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5776
|
+
method: "POST",
|
|
5777
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5778
|
+
body
|
|
5779
|
+
});
|
|
5780
|
+
const result = await response.json();
|
|
5781
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5782
|
+
const failure = result;
|
|
5783
|
+
throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5784
|
+
}
|
|
5785
|
+
return result;
|
|
5786
|
+
}
|
|
5787
|
+
|
|
4088
5788
|
// src/signup-control-theme.ts
|
|
4089
5789
|
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
4090
5790
|
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
@@ -4224,11 +5924,15 @@ var BUILTIN_ROUTES = [
|
|
|
4224
5924
|
handleCrm,
|
|
4225
5925
|
handleNetworkShared,
|
|
4226
5926
|
handleSignupControl,
|
|
5927
|
+
handleMemberContentControl,
|
|
5928
|
+
handleAdmissionGrants,
|
|
4227
5929
|
handleNetworkSnapshot,
|
|
4228
5930
|
handleNetworkRecords,
|
|
4229
5931
|
handleNetworkSharedNotes,
|
|
4230
5932
|
handleFormation,
|
|
4231
5933
|
handleMember,
|
|
5934
|
+
handleMemberContent,
|
|
5935
|
+
handleGifts,
|
|
4232
5936
|
handleSchedule,
|
|
4233
5937
|
handlePayments,
|
|
4234
5938
|
handleAdminMeetings,
|
|
@@ -4238,10 +5942,12 @@ var BUILTIN_ROUTES = [
|
|
|
4238
5942
|
handleAdminPeopleAccess,
|
|
4239
5943
|
handleAdminPeopleRole,
|
|
4240
5944
|
handleAdminCrmSync,
|
|
5945
|
+
handleAdminAdmissionGrants,
|
|
4241
5946
|
handleAdminClerkPrivateProfileSync,
|
|
4242
5947
|
// Aggregation
|
|
4243
5948
|
handleAdminDashboard,
|
|
4244
5949
|
handleAdminBilling,
|
|
5950
|
+
handleAdminMemberContent,
|
|
4245
5951
|
// Lifecycle actions
|
|
4246
5952
|
handleAdminMeetingReschedule,
|
|
4247
5953
|
handleAdminMeetingCancel,
|