@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.js
CHANGED
|
@@ -167,6 +167,205 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
167
167
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// src/admission-grants-contract.ts
|
|
171
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
172
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
173
|
+
|
|
174
|
+
// src/signup-control-canonical.ts
|
|
175
|
+
var encoder = new TextEncoder();
|
|
176
|
+
function canonicalValue(value) {
|
|
177
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
178
|
+
return JSON.stringify(value);
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
181
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
182
|
+
const record3 = value;
|
|
183
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
184
|
+
const item = record3[key];
|
|
185
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
186
|
+
});
|
|
187
|
+
return `{${entries.join(",")}}`;
|
|
188
|
+
}
|
|
189
|
+
function canonicalSignupControl(value) {
|
|
190
|
+
return canonicalValue(value);
|
|
191
|
+
}
|
|
192
|
+
async function signupControlDigest(document) {
|
|
193
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
194
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/admission-grants.ts
|
|
198
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
199
|
+
var MAX_GRANTS = 2e3;
|
|
200
|
+
var MAX_NOTE = 2e3;
|
|
201
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
202
|
+
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
203
|
+
function normalizeAdmissionEmail(email) {
|
|
204
|
+
return email.trim().toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
function validateAdmissionGrantDocument(input) {
|
|
207
|
+
const errors = [];
|
|
208
|
+
const root = input && typeof input === "object" ? input : {};
|
|
209
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
210
|
+
const revision = Number(root.revision);
|
|
211
|
+
const generatedAt = Number(root.generatedAt);
|
|
212
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
213
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
214
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
215
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
216
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
217
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
218
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
219
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
220
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
221
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
222
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
223
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
224
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
225
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
226
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
227
|
+
const grants = [];
|
|
228
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
229
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
230
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
231
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
232
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
233
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
234
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
235
|
+
const decision = grant.decision;
|
|
236
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
237
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
238
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
239
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
240
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
241
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
242
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
243
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
244
|
+
}
|
|
245
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
246
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
247
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
248
|
+
}
|
|
249
|
+
seenIds.add(id);
|
|
250
|
+
seenEmails.add(normalized);
|
|
251
|
+
grants.push({
|
|
252
|
+
id,
|
|
253
|
+
email,
|
|
254
|
+
decision,
|
|
255
|
+
...note ? { note } : {},
|
|
256
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (errors.length) return { ok: false, errors };
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
value: {
|
|
263
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
264
|
+
revision,
|
|
265
|
+
generatedAt,
|
|
266
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
267
|
+
target: { chapterId, environment },
|
|
268
|
+
grants
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
273
|
+
function asGrant(row) {
|
|
274
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
275
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
276
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
277
|
+
return row;
|
|
278
|
+
}
|
|
279
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
280
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
281
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
282
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
283
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
284
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
285
|
+
)).sort((a, b) => {
|
|
286
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
287
|
+
return scope || b.createdAt - a.createdAt;
|
|
288
|
+
});
|
|
289
|
+
return active[0] ?? null;
|
|
290
|
+
}
|
|
291
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
292
|
+
const where = { sourceId, targetId, environment };
|
|
293
|
+
if (db.aggregate) {
|
|
294
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
295
|
+
}
|
|
296
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
297
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
298
|
+
}
|
|
299
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
300
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
301
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
302
|
+
where: { sourceId, targetId, environment },
|
|
303
|
+
order: { revision: "desc" },
|
|
304
|
+
limit: 1
|
|
305
|
+
} } });
|
|
306
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
307
|
+
return row ? row : null;
|
|
308
|
+
}
|
|
309
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
310
|
+
const { document, digest } = envelope;
|
|
311
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
312
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
313
|
+
limit: MAX_GRANTS
|
|
314
|
+
} } })).admissionGrants);
|
|
315
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
316
|
+
const ops = [];
|
|
317
|
+
for (const row of current) {
|
|
318
|
+
const id = String(row.id ?? "");
|
|
319
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
320
|
+
active: false,
|
|
321
|
+
revokedAt: receivedAt,
|
|
322
|
+
revokedBy: document.source.actor,
|
|
323
|
+
updatedAt: receivedAt,
|
|
324
|
+
updatedBy: document.source.actor
|
|
325
|
+
} });
|
|
326
|
+
}
|
|
327
|
+
for (const grant of document.grants) {
|
|
328
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
329
|
+
const existing = current.find((row) => row.id === id);
|
|
330
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
331
|
+
id,
|
|
332
|
+
sourceGrantId: grant.id,
|
|
333
|
+
sourceScope: "parent",
|
|
334
|
+
sourceId: document.source.appId,
|
|
335
|
+
targetId: document.target.chapterId,
|
|
336
|
+
email: grant.email,
|
|
337
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
338
|
+
decision: grant.decision,
|
|
339
|
+
...grant.note ? { note: grant.note } : {},
|
|
340
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
341
|
+
active: true,
|
|
342
|
+
sourceRevision: document.revision,
|
|
343
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
344
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
345
|
+
updatedAt: receivedAt,
|
|
346
|
+
updatedBy: document.source.actor
|
|
347
|
+
} });
|
|
348
|
+
const retract = [
|
|
349
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
350
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
351
|
+
];
|
|
352
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
353
|
+
}
|
|
354
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
355
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
356
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
357
|
+
id: revisionId,
|
|
358
|
+
sourceId: document.source.appId,
|
|
359
|
+
targetId: document.target.chapterId,
|
|
360
|
+
environment: document.target.environment,
|
|
361
|
+
revision: document.revision,
|
|
362
|
+
digest,
|
|
363
|
+
active: true,
|
|
364
|
+
receivedAt
|
|
365
|
+
} });
|
|
366
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
367
|
+
}
|
|
368
|
+
|
|
170
369
|
// src/member.ts
|
|
171
370
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
172
371
|
var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
|
|
@@ -218,9 +417,9 @@ function resolveApplication(a) {
|
|
|
218
417
|
validateEmail: a?.validateEmail ?? true
|
|
219
418
|
};
|
|
220
419
|
}
|
|
221
|
-
var
|
|
420
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
222
421
|
function isValidEmail(value) {
|
|
223
|
-
return typeof value === "string" &&
|
|
422
|
+
return typeof value === "string" && EMAIL_RE2.test(value);
|
|
224
423
|
}
|
|
225
424
|
function clampArray(value, max) {
|
|
226
425
|
if (!Array.isArray(value)) return value;
|
|
@@ -232,14 +431,14 @@ function hasDisclaimerAck(fields) {
|
|
|
232
431
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
233
432
|
function applicantProfile(chapter, fields) {
|
|
234
433
|
const app = chapter.application;
|
|
235
|
-
const
|
|
434
|
+
const allowed2 = (f) => app.profileFields.includes(f);
|
|
236
435
|
const profile = {};
|
|
237
436
|
for (const f of [...app.required, ...app.optional]) {
|
|
238
|
-
if (IDENTITY_FIELDS.has(f) || !
|
|
437
|
+
if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
|
|
239
438
|
const v = fields[f];
|
|
240
439
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
241
440
|
}
|
|
242
|
-
if (fields.focus !== void 0 &&
|
|
441
|
+
if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
243
442
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
244
443
|
}
|
|
245
444
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -270,6 +469,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
270
469
|
}
|
|
271
470
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
272
471
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
472
|
+
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
473
|
+
row.emailNormalized = normalizeAdmissionEmail(fields.email);
|
|
474
|
+
}
|
|
475
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
476
|
+
row.admissionGrantId = opts.admissionGrant.id;
|
|
477
|
+
row.admissionSource = opts.admissionGrant.sourceScope;
|
|
478
|
+
row.interviewWaivedAt = opts.now;
|
|
479
|
+
}
|
|
480
|
+
if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
|
|
273
481
|
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
274
482
|
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
275
483
|
return { ok: false, error: "tierId is not an offered tier" };
|
|
@@ -277,8 +485,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
277
485
|
row.tierId = fields.tierId;
|
|
278
486
|
}
|
|
279
487
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
488
|
+
const ops = [
|
|
489
|
+
{ t: "update", ns: "applications", id, attrs: row }
|
|
490
|
+
];
|
|
491
|
+
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
492
|
+
ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
|
|
493
|
+
consumedAt: opts.now,
|
|
494
|
+
consumedApplicationId: id
|
|
495
|
+
} });
|
|
496
|
+
}
|
|
280
497
|
const { duplicate } = await db.transact(
|
|
281
|
-
|
|
498
|
+
ops,
|
|
282
499
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
283
500
|
);
|
|
284
501
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
@@ -312,8 +529,8 @@ function tierPayable(tier, group, hasSecretKey) {
|
|
|
312
529
|
function byOrder(a, b) {
|
|
313
530
|
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
314
531
|
}
|
|
315
|
-
function resolveTiers(group,
|
|
316
|
-
const stored =
|
|
532
|
+
function resolveTiers(group, rows4 = []) {
|
|
533
|
+
const stored = rows4.filter((tier) => tier.active);
|
|
317
534
|
if (stored.length) return [...stored].sort(byOrder);
|
|
318
535
|
return [{
|
|
319
536
|
id: "standard",
|
|
@@ -359,15 +576,15 @@ function rowToTier(row, groupId) {
|
|
|
359
576
|
}
|
|
360
577
|
async function loadTiers(db, group) {
|
|
361
578
|
const groupId = String(group.id ?? "");
|
|
362
|
-
let
|
|
579
|
+
let rows4;
|
|
363
580
|
try {
|
|
364
|
-
|
|
581
|
+
rows4 = (await db.query({
|
|
365
582
|
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
366
583
|
})).tiers;
|
|
367
584
|
} catch {
|
|
368
|
-
|
|
585
|
+
rows4 = void 0;
|
|
369
586
|
}
|
|
370
|
-
const list = Array.isArray(
|
|
587
|
+
const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
|
|
371
588
|
return resolveTiers(group, list);
|
|
372
589
|
}
|
|
373
590
|
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
@@ -481,346 +698,257 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
481
698
|
};
|
|
482
699
|
}
|
|
483
700
|
|
|
484
|
-
// src/
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
701
|
+
// src/gift-offers.ts
|
|
702
|
+
var GIFT_OFFER_CATALOG_VERSION = 1;
|
|
703
|
+
var MAX_GIFT_OFFERS = 20;
|
|
704
|
+
var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
|
|
705
|
+
var PRICE = /^price_[A-Za-z0-9]{12,}$/;
|
|
706
|
+
var CURRENCY = /^[a-z]{3}$/;
|
|
707
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
708
|
+
var text = (value, path, errors, max) => {
|
|
709
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
710
|
+
errors.push(`${path} is required`);
|
|
711
|
+
return "";
|
|
712
|
+
}
|
|
713
|
+
if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
|
|
714
|
+
return value.trim();
|
|
715
|
+
};
|
|
716
|
+
function validateGiftOfferCatalog(input, tierIds) {
|
|
717
|
+
const errors = [];
|
|
718
|
+
const root = record(input);
|
|
719
|
+
if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
|
|
720
|
+
for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
|
|
721
|
+
if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
|
|
722
|
+
if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
|
|
723
|
+
const rawOffers = Array.isArray(root.offers) ? root.offers : [];
|
|
724
|
+
if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
|
|
725
|
+
const ids = /* @__PURE__ */ new Set();
|
|
726
|
+
const offers = [];
|
|
727
|
+
for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
|
|
728
|
+
const path = `document.giftOffers.offers[${index}]`;
|
|
729
|
+
const item = record(raw);
|
|
730
|
+
if (!item) {
|
|
731
|
+
errors.push(`${path} must be an object`);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
735
|
+
for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
736
|
+
const id = text(item.id, `${path}.id`, errors, 80);
|
|
737
|
+
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
738
|
+
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
739
|
+
ids.add(id);
|
|
740
|
+
const targetRaw = record(item.target);
|
|
741
|
+
let target = { type: "entitlement", entitlementKey: "invalid" };
|
|
742
|
+
if (targetRaw?.type === "tier") {
|
|
743
|
+
const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
|
|
744
|
+
if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
|
|
745
|
+
target = { type: "tier", tierId };
|
|
746
|
+
} else if (targetRaw?.type === "entitlement") {
|
|
747
|
+
const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
|
|
748
|
+
if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
|
|
749
|
+
target = { type: "entitlement", entitlementKey };
|
|
750
|
+
} else errors.push(`${path}.target.type must be tier or entitlement`);
|
|
751
|
+
if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
|
|
752
|
+
const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
|
|
753
|
+
if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
754
|
+
if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
|
|
755
|
+
const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
|
|
756
|
+
if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
|
|
757
|
+
const lifetime = Number(item.redemptionLifetimeHours);
|
|
758
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
|
|
759
|
+
const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
|
|
760
|
+
const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
|
|
761
|
+
if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
|
|
762
|
+
if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
|
|
763
|
+
if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
|
|
764
|
+
offers.push({
|
|
765
|
+
id,
|
|
766
|
+
target,
|
|
767
|
+
billingMode: item.billingMode,
|
|
768
|
+
stripePriceId,
|
|
769
|
+
priceCents: Number(item.priceCents),
|
|
770
|
+
currency,
|
|
771
|
+
redemptionLifetimeHours: lifetime,
|
|
772
|
+
...activeFrom !== void 0 ? { activeFrom } : {},
|
|
773
|
+
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
774
|
+
title: text(item.title, `${path}.title`, errors, 160),
|
|
775
|
+
description: text(item.description, `${path}.description`, errors, 2e3),
|
|
776
|
+
ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
|
|
489
780
|
}
|
|
490
|
-
|
|
491
|
-
|
|
781
|
+
var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
|
|
782
|
+
function publicGiftOffers(catalog, now) {
|
|
783
|
+
return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
|
|
492
784
|
}
|
|
493
|
-
function
|
|
494
|
-
return
|
|
785
|
+
function findGiftOffer(catalog, offerId, now) {
|
|
786
|
+
return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
|
|
495
787
|
}
|
|
496
788
|
|
|
497
|
-
// src/
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
789
|
+
// src/clerk-roles.ts
|
|
790
|
+
var CLERK_API = "https://api.clerk.com";
|
|
791
|
+
var DEFAULT_ROLE = "provisional";
|
|
792
|
+
var PAGE = 100;
|
|
793
|
+
function toRecord(u) {
|
|
794
|
+
if (typeof u.id !== "string") return null;
|
|
795
|
+
const pm = u.public_metadata ?? {};
|
|
796
|
+
const privateMetadata = u.private_metadata ?? {};
|
|
797
|
+
const rawProfile = privateMetadata.profile;
|
|
798
|
+
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
799
|
+
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
800
|
+
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
801
|
+
const email = u.email_addresses?.[0]?.email_address;
|
|
802
|
+
return {
|
|
803
|
+
id: u.id,
|
|
804
|
+
email: typeof email === "string" ? email : void 0,
|
|
805
|
+
role,
|
|
806
|
+
publicMetadata: pm,
|
|
807
|
+
privateMetadata,
|
|
808
|
+
profile,
|
|
809
|
+
...applicationId ? { applicationId } : {}
|
|
810
|
+
};
|
|
501
811
|
}
|
|
502
|
-
async function
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
505
|
-
|
|
812
|
+
async function clerkGet(path, secretKey, fetchImpl) {
|
|
813
|
+
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
814
|
+
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
815
|
+
return res.json();
|
|
816
|
+
}
|
|
817
|
+
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
818
|
+
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
819
|
+
const user = Array.isArray(data) ? data[0] : void 0;
|
|
820
|
+
return user ? toRecord(user) : null;
|
|
821
|
+
}
|
|
822
|
+
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
823
|
+
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
824
|
+
return data ? toRecord(data) : null;
|
|
825
|
+
}
|
|
826
|
+
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
827
|
+
const out = [];
|
|
828
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
829
|
+
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
830
|
+
const page = Array.isArray(data) ? data : [];
|
|
831
|
+
for (const u of page) {
|
|
832
|
+
const record3 = toRecord(u);
|
|
833
|
+
if (record3) out.push(record3);
|
|
834
|
+
}
|
|
835
|
+
if (page.length < PAGE) break;
|
|
506
836
|
}
|
|
507
|
-
|
|
508
|
-
return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
837
|
+
return out;
|
|
509
838
|
}
|
|
510
|
-
async function
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
518
|
-
return asStored(latest);
|
|
839
|
+
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
840
|
+
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
841
|
+
method: "PATCH",
|
|
842
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
843
|
+
body: JSON.stringify({ public_metadata: { role } })
|
|
844
|
+
});
|
|
845
|
+
return res.ok;
|
|
519
846
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
847
|
+
|
|
848
|
+
// src/crm-sync.ts
|
|
849
|
+
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
850
|
+
|
|
851
|
+
// src/network.ts
|
|
852
|
+
import {
|
|
853
|
+
createRecord,
|
|
854
|
+
getRecordByOrigin,
|
|
855
|
+
updateRecord,
|
|
856
|
+
upsertRecordOrigin
|
|
857
|
+
} from "@odla-ai/crm";
|
|
858
|
+
|
|
859
|
+
// src/network-contract.ts
|
|
860
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
861
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
862
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/network.ts
|
|
866
|
+
function sharedPersonInput(person) {
|
|
867
|
+
const email = person.email.toLowerCase();
|
|
868
|
+
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
869
|
+
const input = { name: person.name ?? fullName ?? email, email };
|
|
870
|
+
if (input.name === "") input.name = email;
|
|
871
|
+
if (person.firstName) input.firstName = person.firstName;
|
|
872
|
+
if (person.lastName) input.lastName = person.lastName;
|
|
873
|
+
if (person.phone) input.phone = person.phone;
|
|
874
|
+
if (person.linkedin) input.linkedin = person.linkedin;
|
|
875
|
+
return input;
|
|
529
876
|
}
|
|
530
|
-
function
|
|
531
|
-
|
|
532
|
-
|
|
877
|
+
function shortHash(value) {
|
|
878
|
+
let a = 2166136261;
|
|
879
|
+
let b = 2654435769;
|
|
880
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
881
|
+
const n = value.charCodeAt(i);
|
|
882
|
+
a = Math.imul(a ^ n, 16777619);
|
|
883
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
884
|
+
}
|
|
885
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
533
886
|
}
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
887
|
+
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
888
|
+
const typeKey = type.toLowerCase();
|
|
889
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
890
|
+
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
891
|
+
const raw = `${prefix}:${hubRecordId}`;
|
|
892
|
+
if (readable && raw.length <= 64) return raw;
|
|
893
|
+
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
538
894
|
}
|
|
539
|
-
function
|
|
540
|
-
|
|
541
|
-
|
|
895
|
+
function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
|
|
896
|
+
if ("input" in record3 && record3.version === 2) {
|
|
897
|
+
return {
|
|
898
|
+
version: 2,
|
|
899
|
+
sourceId: record3.source.siteId,
|
|
900
|
+
sourceRecordId: record3.source.recordId,
|
|
901
|
+
type: record3.type,
|
|
902
|
+
input: record3.input
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if ("input" in record3) {
|
|
906
|
+
return {
|
|
907
|
+
version: 1,
|
|
908
|
+
sourceId: fallbackSourceId,
|
|
909
|
+
sourceRecordId: record3.hubRecordId,
|
|
910
|
+
type: record3.type,
|
|
911
|
+
input: record3.input
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
if ("type" in record3 && record3.type === "company") {
|
|
915
|
+
const input = { name: record3.name };
|
|
916
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
917
|
+
if (record3[key]) input[key] = record3[key];
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
version: 1,
|
|
921
|
+
sourceId: fallbackSourceId,
|
|
922
|
+
sourceRecordId: record3.hubRecordId,
|
|
923
|
+
type: "company",
|
|
924
|
+
input
|
|
925
|
+
};
|
|
926
|
+
}
|
|
542
927
|
return {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
928
|
+
version: 1,
|
|
929
|
+
sourceId: fallbackSourceId,
|
|
930
|
+
sourceRecordId: record3.hubRecordId,
|
|
931
|
+
type: "person",
|
|
932
|
+
input: sharedPersonInput(record3)
|
|
548
933
|
};
|
|
549
934
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
value
|
|
554
|
-
});
|
|
555
|
-
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
556
|
-
const { document, digest } = envelope;
|
|
557
|
-
const currentRows = (await db.query({ tiers: { $: {
|
|
558
|
-
where: { groupId: document.target.chapterId },
|
|
559
|
-
limit: 100
|
|
560
|
-
} } })).tiers ?? [];
|
|
561
|
-
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
562
|
-
const ops = [];
|
|
563
|
-
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
564
|
-
for (const row of currentRows) {
|
|
565
|
-
const id2 = String(row.id ?? "");
|
|
566
|
-
if (id2 && !incoming.has(id2)) {
|
|
567
|
-
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
for (const tier of document.tiers) {
|
|
571
|
-
const tierRef = byPublicId("tiers", tier.id);
|
|
572
|
-
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
573
|
-
id: tier.id,
|
|
574
|
-
groupId: document.target.chapterId,
|
|
575
|
-
name: tier.name,
|
|
576
|
-
priceCents: tier.priceCents,
|
|
577
|
-
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
578
|
-
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
579
|
-
sortOrder: tier.sortOrder,
|
|
580
|
-
active: tier.available,
|
|
581
|
-
createdAt: receivedAt
|
|
582
|
-
} });
|
|
583
|
-
const retract = [
|
|
584
|
-
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
585
|
-
...!tier.blurb ? ["blurb"] : []
|
|
586
|
-
];
|
|
587
|
-
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
588
|
-
}
|
|
589
|
-
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
590
|
-
const attrs = groupAttrs(document);
|
|
591
|
-
ops.push({
|
|
592
|
-
t: "update",
|
|
593
|
-
ns: "groups",
|
|
594
|
-
id: groupRef,
|
|
595
|
-
attrs
|
|
596
|
-
});
|
|
597
|
-
if (!("stripePriceId" in attrs)) {
|
|
598
|
-
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
599
|
-
}
|
|
600
|
-
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
601
|
-
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
602
|
-
id,
|
|
603
|
-
sourceId: document.source.appId,
|
|
604
|
-
targetId: document.target.chapterId,
|
|
605
|
-
environment: document.target.environment,
|
|
606
|
-
revision: document.revision,
|
|
607
|
-
digest,
|
|
608
|
-
document,
|
|
609
|
-
active: true,
|
|
610
|
-
receivedAt
|
|
611
|
-
} });
|
|
612
|
-
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// src/session.ts
|
|
616
|
-
function applicationSummary(app) {
|
|
617
|
-
return {
|
|
618
|
-
id: app.id,
|
|
619
|
-
firstName: app.firstName ?? null,
|
|
620
|
-
lastName: app.lastName ?? null,
|
|
621
|
-
email: app.email ?? null,
|
|
622
|
-
status: app.status,
|
|
623
|
-
createdAt: app.createdAt ?? null,
|
|
624
|
-
meetingLink: app.meetingLink ?? null,
|
|
625
|
-
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
626
|
-
renewalAt: app.renewalAt ?? null,
|
|
627
|
-
canceled: app.canceled === true
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
function memberApplication(app, meeting, defaultTimezone) {
|
|
631
|
-
const summary = applicationSummary(app);
|
|
632
|
-
let meetingAt = app.meetingAt ?? null;
|
|
633
|
-
let meetUrl = null;
|
|
634
|
-
let timezone = defaultTimezone;
|
|
635
|
-
if (meeting) {
|
|
636
|
-
timezone = meeting.timezone ?? timezone;
|
|
637
|
-
if (meeting.status === "scheduled") {
|
|
638
|
-
meetingAt = meeting.startAt ?? null;
|
|
639
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
640
|
-
} else {
|
|
641
|
-
meetingAt = null;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return { ...summary, meetingAt, meetUrl, timezone };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// src/clerk.ts
|
|
648
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
649
|
-
function clerkInviteRequest(input) {
|
|
650
|
-
return {
|
|
651
|
-
path: "/v1/invitations",
|
|
652
|
-
body: {
|
|
653
|
-
email_address: input.email,
|
|
654
|
-
notify: true,
|
|
655
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
656
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
657
|
-
}
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
661
|
-
const { path, body } = clerkInviteRequest(input);
|
|
662
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
663
|
-
method: "POST",
|
|
664
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
665
|
-
body: JSON.stringify(body)
|
|
666
|
-
});
|
|
667
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
668
|
-
}
|
|
669
|
-
function clerkUserRequest(input) {
|
|
670
|
-
return {
|
|
671
|
-
path: "/v1/users",
|
|
672
|
-
body: {
|
|
673
|
-
email_address: [input.email],
|
|
674
|
-
skip_password_requirement: true,
|
|
675
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
676
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
677
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
678
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
679
|
-
}
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
683
|
-
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
684
|
-
method: "PATCH",
|
|
685
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
686
|
-
body: JSON.stringify({
|
|
687
|
-
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
688
|
-
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
689
|
-
})
|
|
690
|
-
});
|
|
691
|
-
return res.ok;
|
|
692
|
-
}
|
|
693
|
-
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
694
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
695
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
696
|
-
if (!found.ok) return false;
|
|
697
|
-
const users = await found.json().catch(() => null);
|
|
698
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
699
|
-
if (!id) return false;
|
|
700
|
-
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
701
|
-
}
|
|
702
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
703
|
-
const { path, body } = clerkUserRequest(input);
|
|
704
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
705
|
-
method: "POST",
|
|
706
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
707
|
-
body: JSON.stringify(body)
|
|
708
|
-
});
|
|
709
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
710
|
-
const healed = heal(res.status);
|
|
711
|
-
const repair = input.repairMetadata ?? {
|
|
712
|
-
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
713
|
-
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
714
|
-
};
|
|
715
|
-
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
716
|
-
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
717
|
-
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
718
|
-
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
719
|
-
}, fetchImpl).catch(() => false);
|
|
720
|
-
return { ...healed, refreshed };
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// src/network.ts
|
|
724
|
-
import {
|
|
725
|
-
createRecord,
|
|
726
|
-
getRecordByOrigin,
|
|
727
|
-
updateRecord,
|
|
728
|
-
upsertRecordOrigin
|
|
729
|
-
} from "@odla-ai/crm";
|
|
730
|
-
|
|
731
|
-
// src/network-contract.ts
|
|
732
|
-
var DEFAULT_SHARE_FIELDS = {
|
|
733
|
-
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
734
|
-
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
// src/network.ts
|
|
738
|
-
function sharedPersonInput(person) {
|
|
739
|
-
const email = person.email.toLowerCase();
|
|
740
|
-
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
741
|
-
const input = { name: person.name ?? fullName ?? email, email };
|
|
742
|
-
if (input.name === "") input.name = email;
|
|
743
|
-
if (person.firstName) input.firstName = person.firstName;
|
|
744
|
-
if (person.lastName) input.lastName = person.lastName;
|
|
745
|
-
if (person.phone) input.phone = person.phone;
|
|
746
|
-
if (person.linkedin) input.linkedin = person.linkedin;
|
|
747
|
-
return input;
|
|
748
|
-
}
|
|
749
|
-
function shortHash(value) {
|
|
750
|
-
let a = 2166136261;
|
|
751
|
-
let b = 2654435769;
|
|
752
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
753
|
-
const n = value.charCodeAt(i);
|
|
754
|
-
a = Math.imul(a ^ n, 16777619);
|
|
755
|
-
b = Math.imul(b ^ n, 2246822507);
|
|
756
|
-
}
|
|
757
|
-
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
758
|
-
}
|
|
759
|
-
function networkSourceTag(type, hubRecordId, sourceId) {
|
|
760
|
-
const typeKey = type.toLowerCase();
|
|
761
|
-
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
762
|
-
const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
|
|
763
|
-
const raw = `${prefix}:${hubRecordId}`;
|
|
764
|
-
if (readable && raw.length <= 64) return raw;
|
|
765
|
-
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
766
|
-
}
|
|
767
|
-
function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
|
|
768
|
-
if ("input" in record && record.version === 2) {
|
|
769
|
-
return {
|
|
770
|
-
version: 2,
|
|
771
|
-
sourceId: record.source.siteId,
|
|
772
|
-
sourceRecordId: record.source.recordId,
|
|
773
|
-
type: record.type,
|
|
774
|
-
input: record.input
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
if ("input" in record) {
|
|
778
|
-
return {
|
|
779
|
-
version: 1,
|
|
780
|
-
sourceId: fallbackSourceId,
|
|
781
|
-
sourceRecordId: record.hubRecordId,
|
|
782
|
-
type: record.type,
|
|
783
|
-
input: record.input
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
if ("type" in record && record.type === "company") {
|
|
787
|
-
const input = { name: record.name };
|
|
788
|
-
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
789
|
-
if (record[key]) input[key] = record[key];
|
|
790
|
-
}
|
|
791
|
-
return {
|
|
792
|
-
version: 1,
|
|
793
|
-
sourceId: fallbackSourceId,
|
|
794
|
-
sourceRecordId: record.hubRecordId,
|
|
795
|
-
type: "company",
|
|
796
|
-
input
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
return {
|
|
800
|
-
version: 1,
|
|
801
|
-
sourceId: fallbackSourceId,
|
|
802
|
-
sourceRecordId: record.hubRecordId,
|
|
803
|
-
type: "person",
|
|
804
|
-
input: sharedPersonInput(record)
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
function sharedRecordFromCrm(crm, record, target, sourceId) {
|
|
808
|
-
if (target.fields && !target.fields[record.type]) {
|
|
809
|
-
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
935
|
+
function sharedRecordFromCrm(crm, record3, target, sourceId) {
|
|
936
|
+
if (target.fields && !target.fields[record3.type]) {
|
|
937
|
+
throw new Error(`${target.name} does not accept "${record3.type}" records`);
|
|
810
938
|
}
|
|
811
|
-
const def = crm.type(
|
|
812
|
-
const fields = target.fields?.[
|
|
939
|
+
const def = crm.type(record3.type);
|
|
940
|
+
const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
|
|
813
941
|
if (!fields) {
|
|
814
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
942
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
|
|
815
943
|
}
|
|
816
944
|
const nameField = def.nameField ?? "name";
|
|
817
945
|
const input = {};
|
|
818
946
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
819
|
-
const value =
|
|
947
|
+
const value = record3.fields?.[field];
|
|
820
948
|
if (value !== void 0) input[field] = value;
|
|
821
949
|
}
|
|
822
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
823
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
950
|
+
if (input[nameField] === void 0) input[nameField] = record3.name;
|
|
951
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
|
|
824
952
|
}
|
|
825
953
|
async function upsertPerson(deps, opts) {
|
|
826
954
|
const email = opts.email.toLowerCase();
|
|
@@ -834,11 +962,11 @@ async function upsertPerson(deps, opts) {
|
|
|
834
962
|
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
835
963
|
return { recordId: created.id };
|
|
836
964
|
}
|
|
837
|
-
async function findSharedRecord(deps,
|
|
965
|
+
async function findSharedRecord(deps, record3, tag) {
|
|
838
966
|
const structured = await getRecordByOrigin(
|
|
839
967
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
840
|
-
|
|
841
|
-
|
|
968
|
+
record3.sourceId,
|
|
969
|
+
record3.sourceRecordId
|
|
842
970
|
);
|
|
843
971
|
if (structured) return structured;
|
|
844
972
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -847,53 +975,53 @@ async function findSharedRecord(deps, record, tag) {
|
|
|
847
975
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
848
976
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
849
977
|
}
|
|
850
|
-
const def = deps.crm.type(
|
|
978
|
+
const def = deps.crm.type(record3.type);
|
|
851
979
|
const emailField = def.emailField;
|
|
852
|
-
if (emailField && typeof
|
|
853
|
-
const primaryEmail =
|
|
980
|
+
if (emailField && typeof record3.input[emailField] === "string") {
|
|
981
|
+
const primaryEmail = record3.input[emailField].toLowerCase();
|
|
854
982
|
const found = await deps.db.query({
|
|
855
|
-
crm_record: { $: { where: { type:
|
|
983
|
+
crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
|
|
856
984
|
});
|
|
857
985
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
858
986
|
}
|
|
859
|
-
const domain =
|
|
987
|
+
const domain = record3.input.domain;
|
|
860
988
|
const domainSlot = def.fields.domain?.slot;
|
|
861
989
|
if (typeof domain === "string" && domainSlot) {
|
|
862
990
|
const found = await deps.db.query({
|
|
863
|
-
crm_record: { $: { where: { type:
|
|
991
|
+
crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
|
|
864
992
|
});
|
|
865
993
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
866
994
|
}
|
|
867
995
|
const nameField = def.nameField ?? "name";
|
|
868
|
-
const name =
|
|
869
|
-
if (
|
|
996
|
+
const name = record3.input[nameField];
|
|
997
|
+
if (record3.type === "company" && typeof name === "string" && name.trim()) {
|
|
870
998
|
const found = await deps.db.query({
|
|
871
|
-
crm_record: { $: { where: { type:
|
|
999
|
+
crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
|
|
872
1000
|
});
|
|
873
1001
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
874
1002
|
}
|
|
875
1003
|
return void 0;
|
|
876
1004
|
}
|
|
877
1005
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
878
|
-
const
|
|
879
|
-
if (!
|
|
1006
|
+
const record3 = normalizeSharedRecord(shared, options.sourceId);
|
|
1007
|
+
if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
|
|
880
1008
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
881
1009
|
}
|
|
882
|
-
if (options.sourceId && options.sourceId !==
|
|
1010
|
+
if (options.sourceId && options.sourceId !== record3.sourceId) {
|
|
883
1011
|
throw new Error("signed sender does not match payload source");
|
|
884
1012
|
}
|
|
885
|
-
const tag = networkSourceTag(
|
|
1013
|
+
const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
|
|
886
1014
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
887
|
-
const existing = await findSharedRecord(deps,
|
|
1015
|
+
const existing = await findSharedRecord(deps, record3, tag);
|
|
888
1016
|
let recordId;
|
|
889
1017
|
if (existing && typeof existing.id === "string") {
|
|
890
|
-
await updateRecord(crmDeps3, { id: existing.id, input:
|
|
1018
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record3.input });
|
|
891
1019
|
recordId = existing.id;
|
|
892
1020
|
} else {
|
|
893
|
-
recordId = `network_${shortHash(`${
|
|
1021
|
+
recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
|
|
894
1022
|
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
895
|
-
type:
|
|
896
|
-
input:
|
|
1023
|
+
type: record3.type,
|
|
1024
|
+
input: record3.input,
|
|
897
1025
|
mutationId: `share-create:${tag}`
|
|
898
1026
|
});
|
|
899
1027
|
}
|
|
@@ -903,10 +1031,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
903
1031
|
);
|
|
904
1032
|
await upsertRecordOrigin(crmDeps3, {
|
|
905
1033
|
recordId,
|
|
906
|
-
sourceId:
|
|
907
|
-
sourceRecordId:
|
|
1034
|
+
sourceId: record3.sourceId,
|
|
1035
|
+
sourceRecordId: record3.sourceRecordId,
|
|
908
1036
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
909
|
-
payloadVersion:
|
|
1037
|
+
payloadVersion: record3.version
|
|
910
1038
|
});
|
|
911
1039
|
return { recordId };
|
|
912
1040
|
}
|
|
@@ -929,29 +1057,115 @@ async function projectApplicant(deps, applicant) {
|
|
|
929
1057
|
}
|
|
930
1058
|
}
|
|
931
1059
|
|
|
932
|
-
// src/
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1060
|
+
// src/crm-sync.ts
|
|
1061
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1062
|
+
var crmDeps = (db, ctx) => ({
|
|
1063
|
+
crm: ctx.chapter.crm,
|
|
1064
|
+
db,
|
|
1065
|
+
now: () => Date.now(),
|
|
1066
|
+
newId: () => crypto.randomUUID(),
|
|
1067
|
+
chapter: ctx.chapter
|
|
1068
|
+
});
|
|
1069
|
+
function personInputFromApp(chapter, app) {
|
|
1070
|
+
const input = sharedPersonInput({
|
|
1071
|
+
email: str(app.email),
|
|
1072
|
+
firstName: str(app.firstName) || void 0,
|
|
1073
|
+
lastName: str(app.lastName) || void 0,
|
|
1074
|
+
phone: str(app.phone) || void 0,
|
|
1075
|
+
linkedin: str(app.linkedin) || void 0,
|
|
1076
|
+
hubRecordId: str(app.id)
|
|
1077
|
+
});
|
|
1078
|
+
for (const f of chapter.application.crmFields) {
|
|
1079
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
1080
|
+
}
|
|
1081
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1082
|
+
return input;
|
|
1083
|
+
}
|
|
1084
|
+
function billingColumns(app) {
|
|
1085
|
+
const status = str(app.status);
|
|
1086
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1087
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1088
|
+
const cols = { billingStatus };
|
|
1089
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1090
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1091
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1092
|
+
return cols;
|
|
1093
|
+
}
|
|
1094
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
1095
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
1096
|
+
if (!emailKey) return null;
|
|
1097
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1098
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1099
|
+
const { crm_record } = await deps.db.query({
|
|
1100
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1101
|
+
});
|
|
1102
|
+
const existing = crm_record?.[0] ?? null;
|
|
1103
|
+
const stage = opts.stage || void 0;
|
|
1104
|
+
let recordId;
|
|
1105
|
+
if (existing && typeof existing.id === "string") {
|
|
1106
|
+
recordId = existing.id;
|
|
1107
|
+
await updateRecord2(recordDeps, { id: recordId, input });
|
|
1108
|
+
} else {
|
|
1109
|
+
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1110
|
+
recordId = created.id;
|
|
1111
|
+
}
|
|
1112
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1113
|
+
await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1114
|
+
}
|
|
1115
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1116
|
+
await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1117
|
+
return recordId;
|
|
1118
|
+
}
|
|
1119
|
+
async function backfillCrm(deps) {
|
|
1120
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1121
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1122
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1123
|
+
]);
|
|
1124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1125
|
+
let synced = 0;
|
|
1126
|
+
const errors = [];
|
|
1127
|
+
const run = async (app, stage) => {
|
|
1128
|
+
const key = str(app.email).toLowerCase();
|
|
1129
|
+
if (!key || seen.has(key)) return;
|
|
1130
|
+
seen.add(key);
|
|
1131
|
+
try {
|
|
1132
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1133
|
+
synced += 1;
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1139
|
+
for (const u of usersRes.$users ?? []) {
|
|
1140
|
+
if (u.deleted === true) continue;
|
|
1141
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1142
|
+
}
|
|
1143
|
+
return { synced, errors };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/email.ts
|
|
1147
|
+
function render(template, vars) {
|
|
1148
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
1149
|
+
}
|
|
1150
|
+
function groupVars(group, vars) {
|
|
1151
|
+
return {
|
|
1152
|
+
...vars,
|
|
1153
|
+
refundPolicyText: group.refundPolicyText ?? "",
|
|
1154
|
+
commitmentText: group.commitmentText ?? "",
|
|
1155
|
+
normsText: group.normsText ?? ""
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function renderTemplateBody(group, template, vars) {
|
|
1159
|
+
const tpl = group.emailTemplates?.[template];
|
|
1160
|
+
if (!tpl) return null;
|
|
1161
|
+
return render(tpl.text, groupVars(group, vars));
|
|
1162
|
+
}
|
|
1163
|
+
function isAlreadySent(priorRows) {
|
|
1164
|
+
return priorRows.some((row) => !row.error);
|
|
1165
|
+
}
|
|
1166
|
+
function planDelivery(input) {
|
|
1167
|
+
const tpl = input.group.emailTemplates?.[input.template];
|
|
1168
|
+
if (!tpl) return { deliver: false, reason: "template-missing" };
|
|
955
1169
|
if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
|
|
956
1170
|
const vars = groupVars(input.group, input.vars);
|
|
957
1171
|
const isProd = input.envName === "prod";
|
|
@@ -959,10 +1173,10 @@ function planDelivery(input) {
|
|
|
959
1173
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
960
1174
|
const to = redirect ? input.group.debugEmail : input.to;
|
|
961
1175
|
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
962
|
-
const
|
|
1176
|
+
const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
963
1177
|
|
|
964
1178
|
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
965
|
-
return { deliver: true, transport, to, subject, text:
|
|
1179
|
+
return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
|
|
966
1180
|
}
|
|
967
1181
|
|
|
968
1182
|
// src/notify.ts
|
|
@@ -1032,144 +1246,482 @@ function emailGroupFrom(row) {
|
|
|
1032
1246
|
};
|
|
1033
1247
|
}
|
|
1034
1248
|
|
|
1035
|
-
// src/
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
|
|
1041
|
-
};
|
|
1042
|
-
};
|
|
1043
|
-
var publicProfileRemoval = { applicationId: null, profile: null };
|
|
1044
|
-
async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
|
|
1045
|
-
await db.transact([{
|
|
1046
|
-
t: "update",
|
|
1047
|
-
ns: "applications",
|
|
1048
|
-
id: applicationId,
|
|
1049
|
-
attrs: {
|
|
1050
|
-
clerkPrivateMetadataSyncedAt: Date.now(),
|
|
1051
|
-
...clerkUserId ? { clerkUserId } : {}
|
|
1052
|
-
}
|
|
1053
|
-
}]);
|
|
1249
|
+
// src/pipeline.ts
|
|
1250
|
+
function canTransition(from, to, p) {
|
|
1251
|
+
const fi = p.stages.indexOf(from);
|
|
1252
|
+
const ti = p.stages.indexOf(to);
|
|
1253
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
1054
1254
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
try {
|
|
1058
|
-
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1059
|
-
if (!secret) return false;
|
|
1060
|
-
const ok = await updateClerkUserMetadata(secret, clerkUserId, {
|
|
1061
|
-
publicMetadata: publicProfileRemoval,
|
|
1062
|
-
privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
|
|
1063
|
-
});
|
|
1064
|
-
if (!ok) return false;
|
|
1065
|
-
await markPrivateMetadataSynced(db, applicationId, clerkUserId);
|
|
1066
|
-
return true;
|
|
1067
|
-
} catch {
|
|
1068
|
-
return false;
|
|
1069
|
-
}
|
|
1255
|
+
function canBook(status, p) {
|
|
1256
|
+
return p.bookableFrom.includes(status);
|
|
1070
1257
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
extra
|
|
1090
|
-
}
|
|
1091
|
-
);
|
|
1092
|
-
} catch {
|
|
1258
|
+
function canApprove(status, p) {
|
|
1259
|
+
return p.approvableFrom.includes(status);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/approve-application.ts
|
|
1263
|
+
async function approveApplication(input) {
|
|
1264
|
+
const { db, env, ctx, app } = input;
|
|
1265
|
+
const id = String(app.id ?? "");
|
|
1266
|
+
const status = String(app.status ?? "");
|
|
1267
|
+
if (!id) return { ok: false, status: 404, error: "not found" };
|
|
1268
|
+
if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
|
|
1269
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1270
|
+
}
|
|
1271
|
+
if (input.reason === "interview_waiver") {
|
|
1272
|
+
if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
|
|
1273
|
+
if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
|
|
1274
|
+
return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
|
|
1275
|
+
}
|
|
1093
1276
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
});
|
|
1110
|
-
if (result.ok && (!result.existed || result.refreshed)) {
|
|
1111
|
-
await markPrivateMetadataSynced(db, applicationId);
|
|
1112
|
-
}
|
|
1113
|
-
} else {
|
|
1114
|
-
const result = await createClerkInvitation(secret, { email });
|
|
1115
|
-
if (result.existed) {
|
|
1116
|
-
const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
|
|
1117
|
-
publicMetadata: publicProfileRemoval,
|
|
1118
|
-
privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
|
|
1119
|
-
});
|
|
1120
|
-
if (refreshed) await markPrivateMetadataSynced(db, applicationId);
|
|
1121
|
-
}
|
|
1277
|
+
const write = await db.transact(
|
|
1278
|
+
[{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
|
|
1279
|
+
{ mutationId: `approve:${id}` }
|
|
1280
|
+
);
|
|
1281
|
+
const approved = { ...app, status: "approved" };
|
|
1282
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
|
|
1283
|
+
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
1284
|
+
let rolePromoted = false;
|
|
1285
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1286
|
+
if (promoteTo !== false && secret) {
|
|
1287
|
+
let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
|
|
1288
|
+
if (!userId && typeof app.email === "string") {
|
|
1289
|
+
const found = await clerkGetUserByEmail(secret, app.email);
|
|
1290
|
+
userId = found?.id ?? null;
|
|
1291
|
+
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
1122
1292
|
}
|
|
1123
|
-
|
|
1293
|
+
if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
|
|
1124
1294
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
const stringValue = (value) => typeof value === "string" ? value : "";
|
|
1132
|
-
await sendTemplated(
|
|
1133
|
-
{
|
|
1134
|
-
db,
|
|
1135
|
-
envName: env.ODLA_ENV,
|
|
1136
|
-
sender: env.SEND_EMAIL,
|
|
1137
|
-
from: env.EMAIL_FROM,
|
|
1138
|
-
now: () => Date.now(),
|
|
1139
|
-
newId: () => crypto.randomUUID()
|
|
1140
|
-
},
|
|
1295
|
+
let emailLogged = false;
|
|
1296
|
+
const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
|
|
1297
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1298
|
+
if (send !== false && group && typeof app.email === "string") {
|
|
1299
|
+
const result = await sendTemplated(
|
|
1300
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1141
1301
|
{
|
|
1142
1302
|
group: emailGroupFrom(group),
|
|
1143
|
-
template:
|
|
1144
|
-
to:
|
|
1145
|
-
vars: {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
email: stringValue(fields.email),
|
|
1149
|
-
phone: stringValue(fields.phone),
|
|
1150
|
-
state: stringValue(fields.state)
|
|
1151
|
-
},
|
|
1152
|
-
dedupeKey: `apply:${applicationId}:admin`,
|
|
1153
|
-
applicationId
|
|
1303
|
+
template: send,
|
|
1304
|
+
to: app.email,
|
|
1305
|
+
vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
|
|
1306
|
+
applicationId: id,
|
|
1307
|
+
dedupeKey: `approve:${id}`
|
|
1154
1308
|
}
|
|
1155
1309
|
);
|
|
1156
|
-
|
|
1310
|
+
emailLogged = result.sent;
|
|
1157
1311
|
}
|
|
1312
|
+
return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
|
|
1158
1313
|
}
|
|
1159
1314
|
|
|
1160
|
-
// src/
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
return
|
|
1171
|
-
|
|
1172
|
-
|
|
1315
|
+
// src/join-continuation.ts
|
|
1316
|
+
function resolveJoinContinuation(input) {
|
|
1317
|
+
if (input.status === "approved") return "accepted";
|
|
1318
|
+
if (input.interviewWaived) {
|
|
1319
|
+
if (!input.payable) return "approve";
|
|
1320
|
+
if (input.status === input.initialStatus) {
|
|
1321
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1322
|
+
}
|
|
1323
|
+
return "approve";
|
|
1324
|
+
}
|
|
1325
|
+
if (!input.payable) return "booking";
|
|
1326
|
+
if (input.status === input.initialStatus) {
|
|
1327
|
+
return input.subscriptionStarted ? "paymentPending" : "payment";
|
|
1328
|
+
}
|
|
1329
|
+
if (canBook(input.status, input.pipeline)) return "booking";
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/join-continuation-server.ts
|
|
1334
|
+
async function continueJoin(db, ctx, env, origin, app, group) {
|
|
1335
|
+
if (typeof app.giftOfferId === "string") {
|
|
1336
|
+
if (typeof app.giftClaimId === "string") return "gift";
|
|
1337
|
+
return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
|
|
1338
|
+
}
|
|
1339
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1340
|
+
const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
|
|
1341
|
+
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1342
|
+
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1343
|
+
const state = resolveJoinContinuation({
|
|
1344
|
+
status: String(app.status ?? ""),
|
|
1345
|
+
initialStatus: ctx.chapter.pipeline.initial,
|
|
1346
|
+
pipeline: ctx.chapter.pipeline,
|
|
1347
|
+
payable,
|
|
1348
|
+
subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
|
|
1349
|
+
interviewWaived: typeof app.interviewWaivedAt === "number"
|
|
1350
|
+
});
|
|
1351
|
+
if (state !== "approve") return state;
|
|
1352
|
+
const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
|
|
1353
|
+
return approved.ok ? "accepted" : null;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/signup-control-store.ts
|
|
1357
|
+
function asStored(row) {
|
|
1358
|
+
if (!row || typeof row.document !== "object" || row.document === null) return null;
|
|
1359
|
+
return row;
|
|
1360
|
+
}
|
|
1361
|
+
async function hasSignupControl(db, targetId, environment, active) {
|
|
1362
|
+
const where = { targetId, environment, ...active ? { active: true } : {} };
|
|
1363
|
+
if (db.aggregate) {
|
|
1364
|
+
return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
|
|
1365
|
+
}
|
|
1366
|
+
const rows4 = (await db.query({ signupControls: {} })).signupControls;
|
|
1367
|
+
return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
|
|
1368
|
+
}
|
|
1369
|
+
async function loadLatestSignupControl(db, targetId, environment) {
|
|
1370
|
+
if (!await hasSignupControl(db, targetId, environment)) return null;
|
|
1371
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1372
|
+
where: { targetId, environment },
|
|
1373
|
+
order: { revision: "desc" },
|
|
1374
|
+
limit: 1
|
|
1375
|
+
} } })).signupControls;
|
|
1376
|
+
const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1377
|
+
return asStored(latest);
|
|
1378
|
+
}
|
|
1379
|
+
async function loadActiveSignupControl(db, targetId, environment) {
|
|
1380
|
+
if (!await hasSignupControl(db, targetId, environment, true)) return null;
|
|
1381
|
+
const rows4 = (await db.query({ signupControls: { $: {
|
|
1382
|
+
where: { targetId, environment, active: true },
|
|
1383
|
+
order: { revision: "desc" },
|
|
1384
|
+
limit: 1
|
|
1385
|
+
} } })).signupControls;
|
|
1386
|
+
const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
|
|
1387
|
+
return asStored(active);
|
|
1388
|
+
}
|
|
1389
|
+
function controlledApplication(base, document) {
|
|
1390
|
+
const delegated = document?.application;
|
|
1391
|
+
return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
|
|
1392
|
+
}
|
|
1393
|
+
function validateControlledApplication(chapter, document) {
|
|
1394
|
+
if (!document.application) return [];
|
|
1395
|
+
const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
|
|
1396
|
+
return [...document.application.required, ...document.application.optional].filter((field) => !declared.has(field)).map((field) => `document.application field "${field}" is not declared by this chapter schema`);
|
|
1397
|
+
}
|
|
1398
|
+
function groupAttrs(document) {
|
|
1399
|
+
const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
1400
|
+
const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
|
|
1401
|
+
return {
|
|
1402
|
+
...first4 ? { standardPriceCents: first4.priceCents } : {},
|
|
1403
|
+
foundingDiscountCents: 0,
|
|
1404
|
+
...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
|
|
1405
|
+
...document.policy ?? {},
|
|
1406
|
+
...document.scheduling ? { schedulingJson: document.scheduling } : {}
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
var byPublicId = (ns, value) => ({
|
|
1410
|
+
ns,
|
|
1411
|
+
attr: "id",
|
|
1412
|
+
value
|
|
1413
|
+
});
|
|
1414
|
+
async function materializeSignupControl(db, envelope, previous, receivedAt) {
|
|
1415
|
+
const { document, digest } = envelope;
|
|
1416
|
+
const currentRows = (await db.query({ tiers: { $: {
|
|
1417
|
+
where: { groupId: document.target.chapterId },
|
|
1418
|
+
limit: 100
|
|
1419
|
+
} } })).tiers ?? [];
|
|
1420
|
+
const incoming = new Set(document.tiers.map((tier) => tier.id));
|
|
1421
|
+
const ops = [];
|
|
1422
|
+
if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
|
|
1423
|
+
for (const row of currentRows) {
|
|
1424
|
+
const id2 = String(row.id ?? "");
|
|
1425
|
+
if (id2 && !incoming.has(id2)) {
|
|
1426
|
+
ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
for (const tier of document.tiers) {
|
|
1430
|
+
const tierRef = byPublicId("tiers", tier.id);
|
|
1431
|
+
ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
|
|
1432
|
+
id: tier.id,
|
|
1433
|
+
groupId: document.target.chapterId,
|
|
1434
|
+
name: tier.name,
|
|
1435
|
+
priceCents: tier.priceCents,
|
|
1436
|
+
...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
|
|
1437
|
+
...tier.blurb ? { blurb: tier.blurb } : {},
|
|
1438
|
+
sortOrder: tier.sortOrder,
|
|
1439
|
+
active: tier.available,
|
|
1440
|
+
createdAt: receivedAt
|
|
1441
|
+
} });
|
|
1442
|
+
const retract = [
|
|
1443
|
+
...!tier.stripePriceId ? ["stripePriceId"] : [],
|
|
1444
|
+
...!tier.blurb ? ["blurb"] : []
|
|
1445
|
+
];
|
|
1446
|
+
if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
|
|
1447
|
+
}
|
|
1448
|
+
const groupRef = byPublicId("groups", document.target.chapterId);
|
|
1449
|
+
const attrs = groupAttrs(document);
|
|
1450
|
+
ops.push({
|
|
1451
|
+
t: "update",
|
|
1452
|
+
ns: "groups",
|
|
1453
|
+
id: groupRef,
|
|
1454
|
+
attrs
|
|
1455
|
+
});
|
|
1456
|
+
if (!("stripePriceId" in attrs)) {
|
|
1457
|
+
ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
|
|
1458
|
+
}
|
|
1459
|
+
const id = `signup-control:${document.target.environment}:${document.revision}`;
|
|
1460
|
+
ops.push({ t: "update", ns: "signupControls", id, attrs: {
|
|
1461
|
+
id,
|
|
1462
|
+
sourceId: document.source.appId,
|
|
1463
|
+
targetId: document.target.chapterId,
|
|
1464
|
+
environment: document.target.environment,
|
|
1465
|
+
revision: document.revision,
|
|
1466
|
+
digest,
|
|
1467
|
+
document,
|
|
1468
|
+
active: true,
|
|
1469
|
+
receivedAt
|
|
1470
|
+
} });
|
|
1471
|
+
await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/session.ts
|
|
1475
|
+
function applicationSummary(app) {
|
|
1476
|
+
return {
|
|
1477
|
+
id: app.id,
|
|
1478
|
+
firstName: app.firstName ?? null,
|
|
1479
|
+
lastName: app.lastName ?? null,
|
|
1480
|
+
email: app.email ?? null,
|
|
1481
|
+
status: app.status,
|
|
1482
|
+
createdAt: app.createdAt ?? null,
|
|
1483
|
+
meetingLink: app.meetingLink ?? null,
|
|
1484
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1485
|
+
renewalAt: app.renewalAt ?? null,
|
|
1486
|
+
canceled: app.canceled === true,
|
|
1487
|
+
tierId: app.tierId ?? null
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
1491
|
+
const summary = applicationSummary(app);
|
|
1492
|
+
let meetingAt = app.meetingAt ?? null;
|
|
1493
|
+
let meetUrl = null;
|
|
1494
|
+
let timezone = defaultTimezone;
|
|
1495
|
+
if (meeting) {
|
|
1496
|
+
timezone = meeting.timezone ?? timezone;
|
|
1497
|
+
if (meeting.status === "scheduled") {
|
|
1498
|
+
meetingAt = meeting.startAt ?? null;
|
|
1499
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
1500
|
+
} else {
|
|
1501
|
+
meetingAt = null;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// src/clerk.ts
|
|
1508
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
1509
|
+
function clerkInviteRequest(input) {
|
|
1510
|
+
return {
|
|
1511
|
+
path: "/v1/invitations",
|
|
1512
|
+
body: {
|
|
1513
|
+
email_address: input.email,
|
|
1514
|
+
notify: true,
|
|
1515
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
1516
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
1521
|
+
const { path, body } = clerkInviteRequest(input);
|
|
1522
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1523
|
+
method: "POST",
|
|
1524
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1525
|
+
body: JSON.stringify(body)
|
|
1526
|
+
});
|
|
1527
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
1528
|
+
}
|
|
1529
|
+
function clerkUserRequest(input) {
|
|
1530
|
+
return {
|
|
1531
|
+
path: "/v1/users",
|
|
1532
|
+
body: {
|
|
1533
|
+
email_address: [input.email],
|
|
1534
|
+
skip_password_requirement: true,
|
|
1535
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
1536
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
1537
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1538
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
|
|
1543
|
+
const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
|
|
1544
|
+
method: "PATCH",
|
|
1545
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1546
|
+
body: JSON.stringify({
|
|
1547
|
+
...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
|
|
1548
|
+
...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
|
|
1549
|
+
})
|
|
1550
|
+
});
|
|
1551
|
+
return res.ok;
|
|
1552
|
+
}
|
|
1553
|
+
async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
|
|
1554
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
1555
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
1556
|
+
if (!found.ok) return false;
|
|
1557
|
+
const users = await found.json().catch(() => null);
|
|
1558
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
1559
|
+
if (!id) return false;
|
|
1560
|
+
return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
|
|
1561
|
+
}
|
|
1562
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
1563
|
+
const { path, body } = clerkUserRequest(input);
|
|
1564
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
1565
|
+
method: "POST",
|
|
1566
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
1567
|
+
body: JSON.stringify(body)
|
|
1568
|
+
});
|
|
1569
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
1570
|
+
const healed = heal(res.status);
|
|
1571
|
+
const repair = input.repairMetadata ?? {
|
|
1572
|
+
...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
|
|
1573
|
+
...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
|
|
1574
|
+
};
|
|
1575
|
+
if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
|
|
1576
|
+
const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
|
|
1577
|
+
...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
|
|
1578
|
+
...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
|
|
1579
|
+
}, fetchImpl).catch(() => false);
|
|
1580
|
+
return { ...healed, refreshed };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// src/worker-provisioning.ts
|
|
1584
|
+
var applicantPrivateMetadata = (chapter, applicationId, fields, removeMissingProfile = false) => {
|
|
1585
|
+
const profile = applicantProfile(chapter, fields);
|
|
1586
|
+
return {
|
|
1587
|
+
applicationId,
|
|
1588
|
+
...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
|
|
1589
|
+
};
|
|
1590
|
+
};
|
|
1591
|
+
var publicProfileRemoval = { applicationId: null, profile: null };
|
|
1592
|
+
async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
|
|
1593
|
+
await db.transact([{
|
|
1594
|
+
t: "update",
|
|
1595
|
+
ns: "applications",
|
|
1596
|
+
id: applicationId,
|
|
1597
|
+
attrs: {
|
|
1598
|
+
clerkPrivateMetadataSyncedAt: Date.now(),
|
|
1599
|
+
...clerkUserId ? { clerkUserId } : {}
|
|
1600
|
+
}
|
|
1601
|
+
}]);
|
|
1602
|
+
}
|
|
1603
|
+
async function syncApplicantPrivateMetadata(db, chapter, applicationId, fields, clerkUserId) {
|
|
1604
|
+
if (chapter.account === "none") return false;
|
|
1605
|
+
try {
|
|
1606
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1607
|
+
if (!secret) return false;
|
|
1608
|
+
const ok = await updateClerkUserMetadata(secret, clerkUserId, {
|
|
1609
|
+
publicMetadata: publicProfileRemoval,
|
|
1610
|
+
privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
|
|
1611
|
+
});
|
|
1612
|
+
if (!ok) return false;
|
|
1613
|
+
await markPrivateMetadataSynced(db, applicationId, clerkUserId);
|
|
1614
|
+
return true;
|
|
1615
|
+
} catch {
|
|
1616
|
+
return false;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
1620
|
+
const email = typeof fields.email === "string" ? fields.email : "";
|
|
1621
|
+
if (!email) return;
|
|
1622
|
+
const stringValue = (value) => typeof value === "string" ? value : void 0;
|
|
1623
|
+
const extra = {};
|
|
1624
|
+
for (const field of chapter.application.crmFields) {
|
|
1625
|
+
if (fields[field] !== void 0) extra[field] = fields[field];
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
await projectApplicant(
|
|
1629
|
+
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1630
|
+
{
|
|
1631
|
+
applicationId,
|
|
1632
|
+
email,
|
|
1633
|
+
firstName: stringValue(fields.firstName),
|
|
1634
|
+
lastName: stringValue(fields.lastName),
|
|
1635
|
+
phone: stringValue(fields.phone),
|
|
1636
|
+
linkedin: stringValue(fields.linkedin),
|
|
1637
|
+
extra
|
|
1638
|
+
}
|
|
1639
|
+
);
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
if (chapter.account === "none") return;
|
|
1643
|
+
try {
|
|
1644
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
1645
|
+
if (!secret) return;
|
|
1646
|
+
const privateMetadata = applicantPrivateMetadata(chapter, applicationId, fields);
|
|
1647
|
+
if (chapter.account === "create") {
|
|
1648
|
+
const result = await createClerkUser(secret, {
|
|
1649
|
+
email,
|
|
1650
|
+
firstName: stringValue(fields.firstName),
|
|
1651
|
+
lastName: stringValue(fields.lastName),
|
|
1652
|
+
privateMetadata,
|
|
1653
|
+
repairMetadata: {
|
|
1654
|
+
publicMetadata: publicProfileRemoval,
|
|
1655
|
+
privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
if (result.ok && (!result.existed || result.refreshed)) {
|
|
1659
|
+
await markPrivateMetadataSynced(db, applicationId);
|
|
1660
|
+
}
|
|
1661
|
+
} else {
|
|
1662
|
+
const result = await createClerkInvitation(secret, { email });
|
|
1663
|
+
if (result.existed) {
|
|
1664
|
+
const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
|
|
1665
|
+
publicMetadata: publicProfileRemoval,
|
|
1666
|
+
privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
|
|
1667
|
+
});
|
|
1668
|
+
if (refreshed) await markPrivateMetadataSynced(db, applicationId);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
} catch {
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
|
|
1675
|
+
try {
|
|
1676
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
1677
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1678
|
+
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
1679
|
+
const stringValue = (value) => typeof value === "string" ? value : "";
|
|
1680
|
+
await sendTemplated(
|
|
1681
|
+
{
|
|
1682
|
+
db,
|
|
1683
|
+
envName: env.ODLA_ENV,
|
|
1684
|
+
sender: env.SEND_EMAIL,
|
|
1685
|
+
from: env.EMAIL_FROM,
|
|
1686
|
+
now: () => Date.now(),
|
|
1687
|
+
newId: () => crypto.randomUUID()
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
group: emailGroupFrom(group),
|
|
1691
|
+
template: "adminNotification",
|
|
1692
|
+
to: group.notificationEmail,
|
|
1693
|
+
vars: {
|
|
1694
|
+
firstName: stringValue(fields.firstName),
|
|
1695
|
+
lastName: stringValue(fields.lastName),
|
|
1696
|
+
email: stringValue(fields.email),
|
|
1697
|
+
phone: stringValue(fields.phone),
|
|
1698
|
+
state: stringValue(fields.state)
|
|
1699
|
+
},
|
|
1700
|
+
dedupeKey: `apply:${applicationId}:admin`,
|
|
1701
|
+
applicationId
|
|
1702
|
+
}
|
|
1703
|
+
);
|
|
1704
|
+
} catch {
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/worker-routes.ts
|
|
1709
|
+
async function memberSessionApplication(db, chapterId, email) {
|
|
1710
|
+
let apps = (await db.query({ applications: { $: {
|
|
1711
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
1712
|
+
limit: 20
|
|
1713
|
+
} } })).applications;
|
|
1714
|
+
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
1715
|
+
const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
1716
|
+
if (!app) return null;
|
|
1717
|
+
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
1718
|
+
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
1719
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
1720
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1721
|
+
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
1722
|
+
return {
|
|
1723
|
+
application: memberApplication(app, meeting, timezone),
|
|
1724
|
+
source: app
|
|
1173
1725
|
};
|
|
1174
1726
|
}
|
|
1175
1727
|
var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
|
|
@@ -1262,7 +1814,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1262
1814
|
membershipPage: activeControl.document.membershipPage,
|
|
1263
1815
|
application: activeControl.document.application,
|
|
1264
1816
|
theme: activeControl.document.theme
|
|
1265
|
-
} } : {}
|
|
1817
|
+
} } : {},
|
|
1818
|
+
giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
|
|
1266
1819
|
});
|
|
1267
1820
|
}
|
|
1268
1821
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
@@ -1301,18 +1854,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1301
1854
|
})).groups;
|
|
1302
1855
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1303
1856
|
if (!group) return json({ error: "not found" }, 404);
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1308
|
-
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1309
|
-
if (!payable) return json({ state: "booking", applicationId });
|
|
1310
|
-
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1311
|
-
return json({ state: "paymentPending", applicationId });
|
|
1312
|
-
}
|
|
1313
|
-
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
1314
|
-
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
1315
|
-
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
1857
|
+
const state = await continueJoin(db, ctx, env, url.origin, app, group);
|
|
1858
|
+
if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
|
|
1859
|
+
return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
|
|
1316
1860
|
}
|
|
1317
1861
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
1318
1862
|
const raw = await req.text();
|
|
@@ -1328,12 +1872,19 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1328
1872
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
1329
1873
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
1330
1874
|
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
1875
|
+
const now = Date.now();
|
|
1876
|
+
const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
|
|
1877
|
+
const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
|
|
1878
|
+
if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
|
|
1879
|
+
const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
|
|
1331
1880
|
const result = await submitApplication(db, effectiveChapter, parsed, {
|
|
1332
1881
|
submissionId,
|
|
1333
1882
|
groupId: chapter.id,
|
|
1334
|
-
now
|
|
1883
|
+
now,
|
|
1335
1884
|
newId: () => crypto.randomUUID(),
|
|
1336
|
-
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1885
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
1886
|
+
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
1887
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
1337
1888
|
});
|
|
1338
1889
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1339
1890
|
if (!result.duplicate) {
|
|
@@ -1342,10 +1893,16 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1342
1893
|
}
|
|
1343
1894
|
await provisionApplicant(db, effectiveChapter, result.id, parsed);
|
|
1344
1895
|
}
|
|
1896
|
+
const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
|
|
1897
|
+
const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
|
|
1898
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
1899
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1900
|
+
const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
|
|
1345
1901
|
return json({
|
|
1346
1902
|
id: result.id,
|
|
1347
1903
|
duplicate: result.duplicate,
|
|
1348
1904
|
status: result.status,
|
|
1905
|
+
...state ? { state } : {},
|
|
1349
1906
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
1350
1907
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
1351
1908
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -1401,8 +1958,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1401
1958
|
payload,
|
|
1402
1959
|
sourceId ? { sourceId } : {}
|
|
1403
1960
|
);
|
|
1404
|
-
const
|
|
1405
|
-
return json({ recordId, type:
|
|
1961
|
+
const record3 = normalizeSharedRecord(payload, sourceId);
|
|
1962
|
+
return json({ recordId, type: record3.type });
|
|
1406
1963
|
} catch (error) {
|
|
1407
1964
|
return json({
|
|
1408
1965
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1410,31 +1967,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1410
1967
|
}
|
|
1411
1968
|
};
|
|
1412
1969
|
|
|
1413
|
-
// src/worker-routes-signup-control.ts
|
|
1414
|
-
import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
|
|
1415
|
-
|
|
1416
|
-
// src/signup-control-canonical.ts
|
|
1417
|
-
var encoder = new TextEncoder();
|
|
1418
|
-
function canonicalValue(value) {
|
|
1419
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
1420
|
-
return JSON.stringify(value);
|
|
1421
|
-
}
|
|
1422
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
1423
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
1424
|
-
const record = value;
|
|
1425
|
-
const entries = Object.keys(record).sort().flatMap((key) => {
|
|
1426
|
-
const item = record[key];
|
|
1427
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
1428
|
-
});
|
|
1429
|
-
return `{${entries.join(",")}}`;
|
|
1430
|
-
}
|
|
1431
|
-
function canonicalSignupControl(value) {
|
|
1432
|
-
return canonicalValue(value);
|
|
1433
|
-
}
|
|
1434
|
-
async function signupControlDigest(document) {
|
|
1435
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
1436
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1437
|
-
}
|
|
1970
|
+
// src/worker-routes-signup-control.ts
|
|
1971
|
+
import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
|
|
1438
1972
|
|
|
1439
1973
|
// src/signup-control-contract.ts
|
|
1440
1974
|
var SIGNUP_CONTROL_PATH = "/api/signup-control";
|
|
@@ -1465,9 +1999,9 @@ var MAX_BENEFITS = 24;
|
|
|
1465
1999
|
var MAX_MARKDOWN = 65536;
|
|
1466
2000
|
var MAX_LABEL = 160;
|
|
1467
2001
|
var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1468
|
-
function rejectUnknownKeys(value,
|
|
2002
|
+
function rejectUnknownKeys(value, allowed2, path, errors) {
|
|
1469
2003
|
for (const key of Object.keys(value)) {
|
|
1470
|
-
if (!
|
|
2004
|
+
if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1471
2005
|
}
|
|
1472
2006
|
}
|
|
1473
2007
|
function boundedString(value, path, errors, max, required = false) {
|
|
@@ -1479,10 +2013,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
1479
2013
|
errors.push(`${path} must be a string`);
|
|
1480
2014
|
return void 0;
|
|
1481
2015
|
}
|
|
1482
|
-
const
|
|
1483
|
-
if (required && !
|
|
1484
|
-
if (
|
|
1485
|
-
return
|
|
2016
|
+
const text3 = value.trim();
|
|
2017
|
+
if (required && !text3) errors.push(`${path} is required`);
|
|
2018
|
+
if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2019
|
+
return text3;
|
|
1486
2020
|
}
|
|
1487
2021
|
function validateMembershipPageDocument(input, options = {}) {
|
|
1488
2022
|
const errors = [];
|
|
@@ -1531,8 +2065,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1531
2065
|
else {
|
|
1532
2066
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
1533
2067
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
1534
|
-
const
|
|
1535
|
-
return
|
|
2068
|
+
const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
2069
|
+
return text3 ? [text3] : [];
|
|
1536
2070
|
});
|
|
1537
2071
|
}
|
|
1538
2072
|
}
|
|
@@ -1606,7 +2140,7 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
1606
2140
|
var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1607
2141
|
var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
1608
2142
|
var TIER = /^[a-z][a-z0-9-]{0,79}$/;
|
|
1609
|
-
var
|
|
2143
|
+
var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
|
|
1610
2144
|
var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
1611
2145
|
"schemaVersion",
|
|
1612
2146
|
"revision",
|
|
@@ -1618,14 +2152,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
1618
2152
|
"application",
|
|
1619
2153
|
"policy",
|
|
1620
2154
|
"scheduling",
|
|
1621
|
-
"theme"
|
|
2155
|
+
"theme",
|
|
2156
|
+
"giftOffers"
|
|
1622
2157
|
]);
|
|
1623
2158
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
1624
2159
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1625
|
-
function unknownKeys(value,
|
|
1626
|
-
for (const key of Object.keys(value)) if (!
|
|
2160
|
+
function unknownKeys(value, allowed2, path, errors) {
|
|
2161
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
1627
2162
|
}
|
|
1628
|
-
function
|
|
2163
|
+
function text2(value, path, errors, max = 65536) {
|
|
1629
2164
|
if (typeof value !== "string" || !value.trim()) {
|
|
1630
2165
|
errors.push(`${path} is required`);
|
|
1631
2166
|
return "";
|
|
@@ -1648,23 +2183,23 @@ function tiersOf(value, errors) {
|
|
|
1648
2183
|
return [];
|
|
1649
2184
|
}
|
|
1650
2185
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
1651
|
-
const id =
|
|
2186
|
+
const id = text2(item.id, `${path}.id`, errors, 80);
|
|
1652
2187
|
if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
1653
2188
|
if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
1654
2189
|
seen.add(id);
|
|
1655
|
-
const name =
|
|
2190
|
+
const name = text2(item.name, `${path}.name`, errors, 160);
|
|
1656
2191
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
1657
2192
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
1658
2193
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
1659
2194
|
errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
|
|
1660
2195
|
}
|
|
1661
|
-
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !
|
|
2196
|
+
if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
|
|
1662
2197
|
errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
1663
2198
|
}
|
|
1664
2199
|
if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
|
|
1665
2200
|
errors.push(`${path}.stripePriceId is required for an available paid tier`);
|
|
1666
2201
|
}
|
|
1667
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
2202
|
+
const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
1668
2203
|
return [{
|
|
1669
2204
|
id,
|
|
1670
2205
|
name,
|
|
@@ -1685,143 +2220,781 @@ function applicationOf(value, errors) {
|
|
|
1685
2220
|
}
|
|
1686
2221
|
unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
|
|
1687
2222
|
try {
|
|
1688
|
-
const resolved = resolveApplication(input);
|
|
1689
|
-
const copy = recordOf(input.copy);
|
|
1690
|
-
if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
|
|
1691
|
-
errors.push("document.application.copy must contain only string values");
|
|
2223
|
+
const resolved = resolveApplication(input);
|
|
2224
|
+
const copy = recordOf(input.copy);
|
|
2225
|
+
if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
|
|
2226
|
+
errors.push("document.application.copy must contain only string values");
|
|
2227
|
+
}
|
|
2228
|
+
return {
|
|
2229
|
+
required: resolved.required,
|
|
2230
|
+
optional: resolved.optional,
|
|
2231
|
+
...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
|
|
2232
|
+
...copy ? { copy } : {}
|
|
2233
|
+
};
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
errors.push(error instanceof Error ? error.message : "document.application is invalid");
|
|
2236
|
+
return void 0;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function policyOf(value, errors) {
|
|
2240
|
+
if (value === void 0) return void 0;
|
|
2241
|
+
const input = recordOf(value);
|
|
2242
|
+
if (!input) {
|
|
2243
|
+
errors.push("document.policy must be an object");
|
|
2244
|
+
return void 0;
|
|
2245
|
+
}
|
|
2246
|
+
const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
|
|
2247
|
+
unknownKeys(input, allowed2, "document.policy", errors);
|
|
2248
|
+
const result = {};
|
|
2249
|
+
for (const key of allowed2) {
|
|
2250
|
+
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
2251
|
+
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
2252
|
+
}
|
|
2253
|
+
return result;
|
|
2254
|
+
}
|
|
2255
|
+
function themeOf(value, errors) {
|
|
2256
|
+
if (value === void 0) return void 0;
|
|
2257
|
+
const input = recordOf(value);
|
|
2258
|
+
const tokens = recordOf(input?.tokens);
|
|
2259
|
+
if (!input || !tokens) {
|
|
2260
|
+
errors.push("document.theme.tokens must be an object");
|
|
2261
|
+
return void 0;
|
|
2262
|
+
}
|
|
2263
|
+
unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
|
|
2264
|
+
if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
|
|
2265
|
+
let serializedLength = 0;
|
|
2266
|
+
for (const [key, token] of Object.entries(tokens)) {
|
|
2267
|
+
if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
|
|
2268
|
+
errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
|
|
2269
|
+
}
|
|
2270
|
+
const rendered = String(token).trim();
|
|
2271
|
+
serializedLength += key.length + rendered.length + 4;
|
|
2272
|
+
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)) {
|
|
2273
|
+
errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
|
|
2277
|
+
return { tokens };
|
|
2278
|
+
}
|
|
2279
|
+
function validateSignupControlDocument(input) {
|
|
2280
|
+
const errors = [];
|
|
2281
|
+
const root = recordOf(input);
|
|
2282
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2283
|
+
unknownKeys(root, ROOT_KEYS2, "document", errors);
|
|
2284
|
+
if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
|
|
2285
|
+
if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2286
|
+
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
2287
|
+
const source = recordOf(root.source);
|
|
2288
|
+
const target = recordOf(root.target);
|
|
2289
|
+
const appId = text2(source?.appId, "document.source.appId", errors, 63);
|
|
2290
|
+
const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
2291
|
+
const environment = text2(target?.environment, "document.target.environment", errors, 63);
|
|
2292
|
+
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2293
|
+
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
2294
|
+
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
2295
|
+
if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
|
|
2296
|
+
const tiers = tiersOf(root.tiers, errors);
|
|
2297
|
+
const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
2298
|
+
const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
|
|
2299
|
+
if (!page.ok) errors.push(...page.errors);
|
|
2300
|
+
else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
|
|
2301
|
+
errors.push("document.membershipPage.tiers must follow the available tier display order");
|
|
2302
|
+
}
|
|
2303
|
+
const application = applicationOf(root.application, errors);
|
|
2304
|
+
const policy = policyOf(root.policy, errors);
|
|
2305
|
+
let scheduling;
|
|
2306
|
+
if (root.scheduling !== void 0) {
|
|
2307
|
+
const checked = validateScheduling(root.scheduling);
|
|
2308
|
+
if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
|
|
2309
|
+
else scheduling = root.scheduling;
|
|
2310
|
+
}
|
|
2311
|
+
const theme = themeOf(root.theme, errors);
|
|
2312
|
+
const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
|
|
2313
|
+
if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
|
|
2314
|
+
if (errors.length || !page.ok) return { ok: false, errors };
|
|
2315
|
+
return { ok: true, value: {
|
|
2316
|
+
schemaVersion: 1,
|
|
2317
|
+
revision: Number(root.revision),
|
|
2318
|
+
generatedAt: Number(root.generatedAt),
|
|
2319
|
+
source: { appId },
|
|
2320
|
+
target: { chapterId, environment, stripeMode: target?.stripeMode },
|
|
2321
|
+
tiers,
|
|
2322
|
+
membershipPage: page.value,
|
|
2323
|
+
...application ? { application } : {},
|
|
2324
|
+
...policy ? { policy } : {},
|
|
2325
|
+
...scheduling ? { scheduling } : {},
|
|
2326
|
+
...theme ? { theme } : {},
|
|
2327
|
+
...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
|
|
2328
|
+
} };
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// src/worker-routes-signup-control.ts
|
|
2332
|
+
var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2333
|
+
var handleSignupControl = async (req, url, env, ctx) => {
|
|
2334
|
+
if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
|
|
2335
|
+
const authority = ctx.chapter.signupControl;
|
|
2336
|
+
if (!authority) return json({ error: "not found" }, 404);
|
|
2337
|
+
const db = ctx.makeDb(env);
|
|
2338
|
+
const secret = await getVaultSecret(db, authority.secretName);
|
|
2339
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2340
|
+
const verified = await verifyFederatedRequest2(req, { secret, senders: [authority.sourceId] });
|
|
2341
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2342
|
+
let input;
|
|
2343
|
+
try {
|
|
2344
|
+
input = JSON.parse(verified.body);
|
|
2345
|
+
} catch {
|
|
2346
|
+
return reject("invalid_document", 400, ["request body must be JSON"]);
|
|
2347
|
+
}
|
|
2348
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2349
|
+
const checked = validateSignupControlDocument(input.document);
|
|
2350
|
+
if (!checked.ok) return reject("invalid_document", 400, checked.errors);
|
|
2351
|
+
const document = checked.value;
|
|
2352
|
+
if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
|
|
2353
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
|
|
2354
|
+
if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
|
|
2355
|
+
if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
|
|
2356
|
+
const applicationErrors = validateControlledApplication(ctx.chapter, document);
|
|
2357
|
+
if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
|
|
2358
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
|
|
2359
|
+
const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2360
|
+
if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
|
|
2361
|
+
if (latest && document.revision === latest.revision) {
|
|
2362
|
+
if (latest.digest !== digest) return reject("conflicting_revision", 409);
|
|
2363
|
+
return json({
|
|
2364
|
+
ok: true,
|
|
2365
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2366
|
+
revision: latest.revision,
|
|
2367
|
+
digest: latest.digest,
|
|
2368
|
+
status: "replayed",
|
|
2369
|
+
acknowledgedAt: latest.receivedAt
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
const receivedAt = Date.now();
|
|
2373
|
+
try {
|
|
2374
|
+
await materializeSignupControl(db, { digest, document }, latest, receivedAt);
|
|
2375
|
+
} catch {
|
|
2376
|
+
return reject("materialization_failed", 503);
|
|
2377
|
+
}
|
|
2378
|
+
return json({
|
|
2379
|
+
ok: true,
|
|
2380
|
+
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
2381
|
+
revision: document.revision,
|
|
2382
|
+
digest,
|
|
2383
|
+
status: "accepted",
|
|
2384
|
+
acknowledgedAt: receivedAt
|
|
2385
|
+
});
|
|
2386
|
+
};
|
|
2387
|
+
|
|
2388
|
+
// src/worker-routes-member-content.ts
|
|
2389
|
+
import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
|
|
2390
|
+
|
|
2391
|
+
// src/member-content-canonical.ts
|
|
2392
|
+
var encoder2 = new TextEncoder();
|
|
2393
|
+
function canonicalValue2(value) {
|
|
2394
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2395
|
+
return JSON.stringify(value);
|
|
2396
|
+
}
|
|
2397
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
|
|
2398
|
+
if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
|
|
2399
|
+
const record3 = value;
|
|
2400
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
2401
|
+
const item = record3[key];
|
|
2402
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
|
|
2403
|
+
});
|
|
2404
|
+
return `{${entries.join(",")}}`;
|
|
2405
|
+
}
|
|
2406
|
+
function canonicalMemberContent(value) {
|
|
2407
|
+
return canonicalValue2(value);
|
|
2408
|
+
}
|
|
2409
|
+
async function memberContentDigest(document) {
|
|
2410
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
|
|
2411
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/member-content-validation-helpers.ts
|
|
2415
|
+
var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
|
|
2416
|
+
var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2417
|
+
var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
|
|
2418
|
+
var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
|
|
2419
|
+
var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
|
|
2420
|
+
var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
|
|
2421
|
+
var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
|
|
2422
|
+
var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
|
|
2423
|
+
var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
|
|
2424
|
+
var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
|
|
2425
|
+
var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
|
|
2426
|
+
var MAX_PAGES = 100;
|
|
2427
|
+
var MAX_MARKDOWN2 = 65536;
|
|
2428
|
+
var MAX_TEXT = 240;
|
|
2429
|
+
var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2430
|
+
function unknownKeys2(value, allowed2, path, errors) {
|
|
2431
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2432
|
+
}
|
|
2433
|
+
function textValue(value, path, errors, max = MAX_TEXT, required = false) {
|
|
2434
|
+
if (value === void 0) {
|
|
2435
|
+
if (required) errors.push(`${path} is required`);
|
|
2436
|
+
return void 0;
|
|
2437
|
+
}
|
|
2438
|
+
if (typeof value !== "string") {
|
|
2439
|
+
errors.push(`${path} must be a string`);
|
|
2440
|
+
return void 0;
|
|
2441
|
+
}
|
|
2442
|
+
const valueText = value.trim();
|
|
2443
|
+
if (required && !valueText) errors.push(`${path} is required`);
|
|
2444
|
+
if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2445
|
+
return valueText;
|
|
2446
|
+
}
|
|
2447
|
+
function offerValue(value, path, errors, tiers, audience) {
|
|
2448
|
+
const raw = record2(value);
|
|
2449
|
+
if (!raw) {
|
|
2450
|
+
errors.push(`${path} must be an object`);
|
|
2451
|
+
return void 0;
|
|
2452
|
+
}
|
|
2453
|
+
unknownKeys2(raw, OFFER_KEYS, path, errors);
|
|
2454
|
+
const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
|
|
2455
|
+
if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
|
|
2456
|
+
if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
|
|
2457
|
+
if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
|
|
2458
|
+
errors.push(`${path}.tierId must grant access to this page`);
|
|
2459
|
+
}
|
|
2460
|
+
const label = textValue(raw.label, `${path}.label`, errors);
|
|
2461
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
|
|
2462
|
+
const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
|
|
2463
|
+
return {
|
|
2464
|
+
tierId,
|
|
2465
|
+
...label ? { label } : {},
|
|
2466
|
+
...bodyMarkdown ? { bodyMarkdown } : {},
|
|
2467
|
+
...ctaLabel ? { ctaLabel } : {}
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// src/member-content-validation.ts
|
|
2472
|
+
function validateMemberContentDocument(input, options = {}) {
|
|
2473
|
+
const errors = [];
|
|
2474
|
+
const root = record2(input);
|
|
2475
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2476
|
+
unknownKeys2(root, ROOT_KEYS3, "document", errors);
|
|
2477
|
+
if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
2478
|
+
if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2479
|
+
if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
|
|
2480
|
+
const sourceRaw = record2(root.source);
|
|
2481
|
+
let source = { type: "chapter", appId: "invalid" };
|
|
2482
|
+
if (!sourceRaw) errors.push("document.source must be an object");
|
|
2483
|
+
else {
|
|
2484
|
+
unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
|
|
2485
|
+
const type = sourceRaw.type;
|
|
2486
|
+
const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
|
|
2487
|
+
if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
|
|
2488
|
+
if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2489
|
+
if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
2490
|
+
if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
2491
|
+
if (type === "parent" || type === "chapter") source = { type, appId };
|
|
2492
|
+
}
|
|
2493
|
+
let target;
|
|
2494
|
+
if (root.target !== void 0) {
|
|
2495
|
+
const targetRaw = record2(root.target);
|
|
2496
|
+
if (!targetRaw) errors.push("document.target must be an object");
|
|
2497
|
+
else {
|
|
2498
|
+
unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
|
|
2499
|
+
const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
|
|
2500
|
+
const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
|
|
2501
|
+
target = { chapterId, environment };
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
|
|
2505
|
+
if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
|
|
2506
|
+
if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
|
|
2507
|
+
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
|
2508
|
+
if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
|
|
2509
|
+
const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
|
|
2510
|
+
const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
|
|
2511
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2512
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2513
|
+
const pages = [];
|
|
2514
|
+
for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
|
|
2515
|
+
const path = `document.pages[${index}]`;
|
|
2516
|
+
const raw = record2(value);
|
|
2517
|
+
if (!raw) {
|
|
2518
|
+
errors.push(`${path} must be an object`);
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
unknownKeys2(raw, PAGE_KEYS, path, errors);
|
|
2522
|
+
const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
|
|
2523
|
+
const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
|
|
2524
|
+
const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
|
|
2525
|
+
if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
2526
|
+
if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
|
|
2527
|
+
if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
|
|
2528
|
+
if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
|
|
2529
|
+
ids.add(id);
|
|
2530
|
+
slugs.add(slug);
|
|
2531
|
+
const navRaw = record2(raw.navigation);
|
|
2532
|
+
let navigation = { section: "General", order: index };
|
|
2533
|
+
if (!navRaw) errors.push(`${path}.navigation must be an object`);
|
|
2534
|
+
else {
|
|
2535
|
+
unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
|
|
2536
|
+
const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
|
|
2537
|
+
const order = navRaw.order;
|
|
2538
|
+
if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
|
|
2539
|
+
errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
|
|
2540
|
+
}
|
|
2541
|
+
const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
|
|
2542
|
+
navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
|
|
2543
|
+
}
|
|
2544
|
+
const audienceRaw = record2(raw.audience);
|
|
2545
|
+
let audience = { kind: "all-members" };
|
|
2546
|
+
if (!audienceRaw) errors.push(`${path}.audience must be an object`);
|
|
2547
|
+
else {
|
|
2548
|
+
unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
|
|
2549
|
+
if (audienceRaw.kind === "all-members") {
|
|
2550
|
+
if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
|
|
2551
|
+
} else if (audienceRaw.kind === "tiers") {
|
|
2552
|
+
if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
|
|
2553
|
+
errors.push(`${path}.audience.tierIds must be a non-empty array`);
|
|
2554
|
+
}
|
|
2555
|
+
const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
|
|
2556
|
+
const normalized = [];
|
|
2557
|
+
for (const [tierIndex, value2] of values.entries()) {
|
|
2558
|
+
const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
|
|
2559
|
+
if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
|
|
2560
|
+
if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
|
|
2561
|
+
normalized.push(tier);
|
|
2562
|
+
}
|
|
2563
|
+
audience = { kind: "tiers", tierIds: normalized };
|
|
2564
|
+
} else errors.push(`${path}.audience.kind must be all-members or tiers`);
|
|
2565
|
+
}
|
|
2566
|
+
const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
|
|
2567
|
+
const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
|
|
2568
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
|
|
2569
|
+
let upsell;
|
|
2570
|
+
if (raw.upsell !== void 0) {
|
|
2571
|
+
const upsellRaw = record2(raw.upsell);
|
|
2572
|
+
if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
|
|
2573
|
+
else {
|
|
2574
|
+
unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
|
|
2575
|
+
const owner = upsellRaw.owner;
|
|
2576
|
+
if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
|
|
2577
|
+
if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
|
|
2578
|
+
if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
|
|
2579
|
+
if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
|
|
2580
|
+
const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
|
|
2581
|
+
if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
|
|
2582
|
+
else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
|
|
2586
|
+
}
|
|
2587
|
+
const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
|
|
2588
|
+
for (const [index, page] of pages.entries()) {
|
|
2589
|
+
const parentId = page.navigation.parentId;
|
|
2590
|
+
if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
|
|
2591
|
+
if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
|
|
2592
|
+
}
|
|
2593
|
+
for (const page of pages) {
|
|
2594
|
+
const seen = /* @__PURE__ */ new Set([page.id]);
|
|
2595
|
+
let parentId = page.navigation.parentId;
|
|
2596
|
+
while (parentId) {
|
|
2597
|
+
if (seen.has(parentId)) {
|
|
2598
|
+
errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
|
|
2599
|
+
break;
|
|
2600
|
+
}
|
|
2601
|
+
seen.add(parentId);
|
|
2602
|
+
parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
let upsellOverrides;
|
|
2606
|
+
if (root.upsellOverrides !== void 0) {
|
|
2607
|
+
if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
|
|
2608
|
+
if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
|
|
2609
|
+
const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
|
|
2610
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2611
|
+
upsellOverrides = [];
|
|
2612
|
+
for (const [index, value] of values.entries()) {
|
|
2613
|
+
const path = `document.upsellOverrides[${index}]`;
|
|
2614
|
+
const raw = record2(value);
|
|
2615
|
+
if (!raw) {
|
|
2616
|
+
errors.push(`${path} must be an object`);
|
|
2617
|
+
continue;
|
|
2618
|
+
}
|
|
2619
|
+
unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
|
|
2620
|
+
const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
|
|
2621
|
+
if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
|
|
2622
|
+
seen.add(pageId);
|
|
2623
|
+
const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
|
|
2624
|
+
if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
|
|
2625
|
+
else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
|
|
2626
|
+
const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
|
|
2627
|
+
if (offer) upsellOverrides.push({ pageId, offer });
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (errors.length) return { ok: false, errors };
|
|
2631
|
+
return { ok: true, value: {
|
|
2632
|
+
schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
|
|
2633
|
+
revision: Number(root.revision),
|
|
2634
|
+
generatedAt: Number(root.generatedAt),
|
|
2635
|
+
source,
|
|
2636
|
+
...target ? { target } : {},
|
|
2637
|
+
pages,
|
|
2638
|
+
...upsellOverrides ? { upsellOverrides } : {}
|
|
2639
|
+
} };
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// src/member-content.ts
|
|
2643
|
+
var MEMBER_CONTENT_SCHEMA_VERSION = 1;
|
|
2644
|
+
var MEMBER_CONTENT_PATH = "/api/member-content";
|
|
2645
|
+
var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
|
|
2646
|
+
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);
|
|
2647
|
+
function composeMemberContent(parent, chapter) {
|
|
2648
|
+
const pages = [];
|
|
2649
|
+
const parentPages = /* @__PURE__ */ new Map();
|
|
2650
|
+
for (const page of parent?.pages ?? []) {
|
|
2651
|
+
const effective = { ...page, source: parent.source };
|
|
2652
|
+
parentPages.set(page.id, effective);
|
|
2653
|
+
pages.push(effective);
|
|
2654
|
+
}
|
|
2655
|
+
for (const override of chapter?.upsellOverrides ?? []) {
|
|
2656
|
+
const page = parentPages.get(override.pageId);
|
|
2657
|
+
if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
|
|
2658
|
+
}
|
|
2659
|
+
for (const page of chapter?.pages ?? []) {
|
|
2660
|
+
if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
|
|
2661
|
+
throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
|
|
2662
|
+
}
|
|
2663
|
+
pages.push({ ...page, source: chapter.source });
|
|
2664
|
+
}
|
|
2665
|
+
return {
|
|
2666
|
+
pages: pages.sort(pageOrder),
|
|
2667
|
+
parentRevision: parent?.revision ?? null,
|
|
2668
|
+
chapterRevision: chapter?.revision ?? null
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
|
|
2672
|
+
function projectMemberContent(content, viewer, tiers) {
|
|
2673
|
+
const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
|
|
2674
|
+
return {
|
|
2675
|
+
viewer: { ...viewer },
|
|
2676
|
+
revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
|
|
2677
|
+
pages: content.pages.map((page) => {
|
|
2678
|
+
const canRead = allowed(page, viewer);
|
|
2679
|
+
const offer = !canRead ? page.upsell?.offer : void 0;
|
|
2680
|
+
const tier = offer ? authority.get(offer.tierId) : void 0;
|
|
2681
|
+
const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
|
|
2682
|
+
return {
|
|
2683
|
+
id: page.id,
|
|
2684
|
+
slug: page.slug,
|
|
2685
|
+
title: page.title,
|
|
2686
|
+
...page.summary ? { summary: page.summary } : {},
|
|
2687
|
+
navigation: page.navigation,
|
|
2688
|
+
audience: page.audience,
|
|
2689
|
+
source: page.source,
|
|
2690
|
+
access: canRead ? "allowed" : "locked",
|
|
2691
|
+
...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
|
|
2692
|
+
...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
|
|
2693
|
+
...upsell ? { upsell } : {}
|
|
2694
|
+
};
|
|
2695
|
+
})
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// src/member-content-store.ts
|
|
2700
|
+
var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2701
|
+
var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2702
|
+
async function exists(db, ns, where) {
|
|
2703
|
+
if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
|
|
2704
|
+
const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
|
|
2705
|
+
return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
|
|
2706
|
+
}
|
|
2707
|
+
async function loadActiveMemberContentControl(db, targetId, environment) {
|
|
2708
|
+
const where = { targetId, environment, active: true };
|
|
2709
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2710
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2711
|
+
where,
|
|
2712
|
+
order: { revision: "desc" },
|
|
2713
|
+
limit: 1
|
|
2714
|
+
} } })).memberContentControls;
|
|
2715
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2716
|
+
}
|
|
2717
|
+
async function loadLatestMemberContentControl(db, targetId, environment) {
|
|
2718
|
+
const where = { targetId, environment };
|
|
2719
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2720
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2721
|
+
where,
|
|
2722
|
+
order: { revision: "desc" },
|
|
2723
|
+
limit: 1
|
|
2724
|
+
} } })).memberContentControls;
|
|
2725
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2726
|
+
}
|
|
2727
|
+
async function loadLocalMemberContent(db, chapterId, environment) {
|
|
2728
|
+
const id = `member-content-local:${environment}`;
|
|
2729
|
+
const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
|
|
2730
|
+
return local(Array.isArray(rows4) ? rows4[0] : void 0);
|
|
2731
|
+
}
|
|
2732
|
+
async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
|
|
2733
|
+
const { document, digest } = envelope;
|
|
2734
|
+
const target = document.target;
|
|
2735
|
+
const id = `member-content-control:${target.environment}:${document.revision}`;
|
|
2736
|
+
const ops = [];
|
|
2737
|
+
if (previous?.active) {
|
|
2738
|
+
ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
|
|
2739
|
+
}
|
|
2740
|
+
ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
|
|
2741
|
+
id,
|
|
2742
|
+
sourceId: document.source.appId,
|
|
2743
|
+
targetId: target.chapterId,
|
|
2744
|
+
environment: target.environment,
|
|
2745
|
+
revision: document.revision,
|
|
2746
|
+
digest,
|
|
2747
|
+
document,
|
|
2748
|
+
active: true,
|
|
2749
|
+
receivedAt
|
|
2750
|
+
} });
|
|
2751
|
+
await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
|
|
2752
|
+
}
|
|
2753
|
+
async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
|
|
2754
|
+
const id = `member-content-local:${environment}`;
|
|
2755
|
+
const current = await loadLocalMemberContent(db, document.source.appId, environment);
|
|
2756
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
|
|
2757
|
+
const row = {
|
|
2758
|
+
id,
|
|
2759
|
+
chapterId: document.source.appId,
|
|
2760
|
+
environment,
|
|
2761
|
+
revision: document.revision,
|
|
2762
|
+
document,
|
|
2763
|
+
updatedAt,
|
|
2764
|
+
...updatedBy ? { updatedBy } : {}
|
|
2765
|
+
};
|
|
2766
|
+
await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
|
|
2767
|
+
mutationId: `member-content-local:${environment}:${document.revision}`,
|
|
2768
|
+
guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
|
|
2769
|
+
});
|
|
2770
|
+
return row;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// src/worker-routes-member-content.ts
|
|
2774
|
+
var ADMIN_PATH = "/api/admin/member-content";
|
|
2775
|
+
var BODY_CAP = 1e6;
|
|
2776
|
+
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2777
|
+
async function groupAndTiers(db, chapterId) {
|
|
2778
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
2779
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
2780
|
+
if (!group) return { group: null, tiers: [] };
|
|
2781
|
+
return { group, tiers: await loadTiers(db, group) };
|
|
2782
|
+
}
|
|
2783
|
+
async function memberTierId(db, chapterId, email) {
|
|
2784
|
+
if (!email) return null;
|
|
2785
|
+
let rows4 = (await db.query({ applications: { $: {
|
|
2786
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
2787
|
+
limit: 20
|
|
2788
|
+
} } })).applications;
|
|
2789
|
+
if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
2790
|
+
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;
|
|
2791
|
+
return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
|
|
2792
|
+
}
|
|
2793
|
+
async function activeDocuments(db, chapterId, environment) {
|
|
2794
|
+
const [parent, chapter] = await Promise.all([
|
|
2795
|
+
loadActiveMemberContentControl(db, chapterId, environment),
|
|
2796
|
+
loadLocalMemberContent(db, chapterId, environment)
|
|
2797
|
+
]);
|
|
2798
|
+
return { parent, chapter };
|
|
2799
|
+
}
|
|
2800
|
+
var handleMemberContentControl = async (req, url, env, ctx) => {
|
|
2801
|
+
if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
|
|
2802
|
+
const settings = ctx.chapter.memberContent?.parent;
|
|
2803
|
+
if (!settings) return json({ error: "not found" }, 404);
|
|
2804
|
+
const db = ctx.makeDb(env);
|
|
2805
|
+
const secret = await getVaultSecret(db, settings.secretName);
|
|
2806
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2807
|
+
const verified = await verifyFederatedRequest3(req, { secret, senders: [settings.sourceId] });
|
|
2808
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2809
|
+
let input;
|
|
2810
|
+
try {
|
|
2811
|
+
input = JSON.parse(verified.body);
|
|
2812
|
+
} catch {
|
|
2813
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
2814
|
+
}
|
|
2815
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2816
|
+
const initial = validateMemberContentDocument(input.document, {
|
|
2817
|
+
sourceType: "parent"
|
|
2818
|
+
});
|
|
2819
|
+
if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
|
|
2820
|
+
const document = initial.value;
|
|
2821
|
+
if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
|
|
2822
|
+
if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2823
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2824
|
+
const { tiers } = await groupAndTiers(db, ctx.chapter.id);
|
|
2825
|
+
const checked = validateMemberContentDocument(document, {
|
|
2826
|
+
sourceType: "parent",
|
|
2827
|
+
sourceAppId: settings.sourceId,
|
|
2828
|
+
tiers
|
|
2829
|
+
});
|
|
2830
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2831
|
+
const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2832
|
+
if (local2) {
|
|
2833
|
+
const localCheck = validateMemberContentDocument(local2.document, {
|
|
2834
|
+
sourceType: "chapter",
|
|
2835
|
+
sourceAppId: ctx.chapter.id,
|
|
2836
|
+
tiers,
|
|
2837
|
+
parentPages: document.pages
|
|
2838
|
+
});
|
|
2839
|
+
if (!localCheck.ok) {
|
|
2840
|
+
return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
|
|
1692
2841
|
}
|
|
1693
|
-
return {
|
|
1694
|
-
required: resolved.required,
|
|
1695
|
-
optional: resolved.optional,
|
|
1696
|
-
...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
|
|
1697
|
-
...copy ? { copy } : {}
|
|
1698
|
-
};
|
|
1699
|
-
} catch (error) {
|
|
1700
|
-
errors.push(error instanceof Error ? error.message : "document.application is invalid");
|
|
1701
|
-
return void 0;
|
|
1702
2842
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
if (
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
2843
|
+
if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2844
|
+
const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2845
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2846
|
+
if (latest && document.revision === latest.revision) {
|
|
2847
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2848
|
+
return json({
|
|
2849
|
+
ok: true,
|
|
2850
|
+
target: document.target,
|
|
2851
|
+
revision: latest.revision,
|
|
2852
|
+
digest: latest.digest,
|
|
2853
|
+
status: "replayed",
|
|
2854
|
+
acknowledgedAt: latest.receivedAt
|
|
2855
|
+
});
|
|
1710
2856
|
}
|
|
1711
|
-
const
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
result[key] = text(input[key], `document.policy.${key}`, errors);
|
|
2857
|
+
const receivedAt = Date.now();
|
|
2858
|
+
try {
|
|
2859
|
+
await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
|
|
2860
|
+
} catch {
|
|
2861
|
+
return reject2("materialization_failed", 503);
|
|
1717
2862
|
}
|
|
1718
|
-
return
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2863
|
+
return json({
|
|
2864
|
+
ok: true,
|
|
2865
|
+
target: document.target,
|
|
2866
|
+
revision: document.revision,
|
|
2867
|
+
digest,
|
|
2868
|
+
status: "accepted",
|
|
2869
|
+
acknowledgedAt: receivedAt
|
|
2870
|
+
});
|
|
2871
|
+
};
|
|
2872
|
+
var handleMemberContent = async (req, url, env, ctx) => {
|
|
2873
|
+
if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
|
|
2874
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2875
|
+
const user = await ctx.verifyUser(req, env);
|
|
2876
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2877
|
+
const db = ctx.makeDb(env);
|
|
2878
|
+
const role = await ctx.roleFor(db, user);
|
|
2879
|
+
const admin = await ctx.isAdmin(db, user);
|
|
2880
|
+
const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
|
|
2881
|
+
const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
|
|
2882
|
+
if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
|
|
2883
|
+
const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
|
|
2884
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2885
|
+
groupAndTiers(db, ctx.chapter.id),
|
|
2886
|
+
memberTierId(db, ctx.chapter.id, user.email)
|
|
2887
|
+
]);
|
|
2888
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2889
|
+
let offerable = tiers;
|
|
2890
|
+
if (group) {
|
|
2891
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
2892
|
+
offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
|
|
1727
2893
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
2894
|
+
return json(projectMemberContent(effective, { tierId, admin }, offerable));
|
|
2895
|
+
};
|
|
2896
|
+
var handleAdminMemberContent = async (req, url, env, ctx) => {
|
|
2897
|
+
if (url.pathname !== ADMIN_PATH) return null;
|
|
2898
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2899
|
+
const user = await ctx.verifyUser(req, env);
|
|
2900
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2901
|
+
const db = ctx.makeDb(env);
|
|
2902
|
+
if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
|
|
2903
|
+
const [{ parent, chapter }, { tiers }] = await Promise.all([
|
|
2904
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2905
|
+
groupAndTiers(db, ctx.chapter.id)
|
|
2906
|
+
]);
|
|
2907
|
+
if (req.method === "GET") {
|
|
2908
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2909
|
+
return json({
|
|
2910
|
+
parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
|
|
2911
|
+
chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
|
|
2912
|
+
effective,
|
|
2913
|
+
tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
|
|
2914
|
+
localAuthoring: ctx.chapter.memberContent.localAuthoring
|
|
2915
|
+
});
|
|
1740
2916
|
}
|
|
1741
|
-
if (
|
|
1742
|
-
return {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
1751
|
-
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
1752
|
-
const source = recordOf(root.source);
|
|
1753
|
-
const target = recordOf(root.target);
|
|
1754
|
-
const appId = text(source?.appId, "document.source.appId", errors, 63);
|
|
1755
|
-
const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
1756
|
-
const environment = text(target?.environment, "document.target.environment", errors, 63);
|
|
1757
|
-
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
1758
|
-
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
1759
|
-
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
1760
|
-
if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
|
|
1761
|
-
const tiers = tiersOf(root.tiers, errors);
|
|
1762
|
-
const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
1763
|
-
const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
|
|
1764
|
-
if (!page.ok) errors.push(...page.errors);
|
|
1765
|
-
else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
|
|
1766
|
-
errors.push("document.membershipPage.tiers must follow the available tier display order");
|
|
2917
|
+
if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
|
|
2918
|
+
if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
|
|
2919
|
+
const raw = await req.text();
|
|
2920
|
+
if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
|
|
2921
|
+
let input;
|
|
2922
|
+
try {
|
|
2923
|
+
input = JSON.parse(raw);
|
|
2924
|
+
} catch {
|
|
2925
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1767
2926
|
}
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if (root.scheduling !== void 0) {
|
|
1772
|
-
const checked = validateScheduling(root.scheduling);
|
|
1773
|
-
if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
|
|
1774
|
-
else scheduling = root.scheduling;
|
|
2927
|
+
const expectedRevision = Number(input.expectedRevision);
|
|
2928
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
2929
|
+
return json({ error: "expectedRevision must be a non-negative integer" }, 400);
|
|
1775
2930
|
}
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
generatedAt: Number(root.generatedAt),
|
|
1782
|
-
source: { appId },
|
|
1783
|
-
target: { chapterId, environment, stripeMode: target?.stripeMode },
|
|
2931
|
+
const now = Date.now();
|
|
2932
|
+
const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
|
|
2933
|
+
const checked = validateMemberContentDocument(documentInput, {
|
|
2934
|
+
sourceType: "chapter",
|
|
2935
|
+
sourceAppId: ctx.chapter.id,
|
|
1784
2936
|
tiers,
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
2937
|
+
parentPages: parent?.document.pages ?? []
|
|
2938
|
+
});
|
|
2939
|
+
if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
|
|
2940
|
+
try {
|
|
2941
|
+
const saved = await saveLocalMemberContent(
|
|
2942
|
+
db,
|
|
2943
|
+
checked.value,
|
|
2944
|
+
env.ODLA_ENV,
|
|
2945
|
+
expectedRevision,
|
|
2946
|
+
now,
|
|
2947
|
+
user.email ?? user.userId
|
|
2948
|
+
);
|
|
2949
|
+
return json({ ok: true, revision: saved.revision, document: saved.document });
|
|
2950
|
+
} catch (error) {
|
|
2951
|
+
if (error instanceof Error && error.message === "member_content_revision_conflict") {
|
|
2952
|
+
return json({ error: "member content changed; reload before saving" }, 409);
|
|
2953
|
+
}
|
|
2954
|
+
return json({ error: "member content could not be saved" }, 503);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
function withLocalAuthority(value, revision, generatedAt, appId) {
|
|
2958
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
2959
|
+
return {
|
|
2960
|
+
...value,
|
|
2961
|
+
schemaVersion: 1,
|
|
2962
|
+
revision,
|
|
2963
|
+
generatedAt,
|
|
2964
|
+
source: { type: "chapter", appId }
|
|
2965
|
+
};
|
|
1791
2966
|
}
|
|
1792
2967
|
|
|
1793
|
-
// src/worker-routes-
|
|
1794
|
-
|
|
1795
|
-
var
|
|
1796
|
-
|
|
2968
|
+
// src/worker-routes-admission-grants.ts
|
|
2969
|
+
import { verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
|
|
2970
|
+
var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2971
|
+
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2972
|
+
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
1797
2973
|
const authority = ctx.chapter.signupControl;
|
|
1798
2974
|
if (!authority) return json({ error: "not found" }, 404);
|
|
1799
2975
|
const db = ctx.makeDb(env);
|
|
1800
2976
|
const secret = await getVaultSecret(db, authority.secretName);
|
|
1801
2977
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
1802
|
-
const verified = await
|
|
2978
|
+
const verified = await verifyFederatedRequest4(req, { secret, senders: [authority.sourceId] });
|
|
1803
2979
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
1804
2980
|
let input;
|
|
1805
2981
|
try {
|
|
1806
2982
|
input = JSON.parse(verified.body);
|
|
1807
2983
|
} catch {
|
|
1808
|
-
return
|
|
2984
|
+
return reject3("invalid_document", 400, ["request body must be JSON"]);
|
|
1809
2985
|
}
|
|
1810
2986
|
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
1811
|
-
const checked =
|
|
1812
|
-
if (!checked.ok) return
|
|
2987
|
+
const checked = validateAdmissionGrantDocument(input.document);
|
|
2988
|
+
if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
|
|
1813
2989
|
const document = checked.value;
|
|
1814
|
-
if (document.source.appId !== authority.sourceId) return
|
|
1815
|
-
if (document.target.chapterId !== ctx.chapter.id) return
|
|
1816
|
-
if (document.target.environment !== env.ODLA_ENV) return
|
|
1817
|
-
if (document
|
|
1818
|
-
const
|
|
1819
|
-
if (
|
|
1820
|
-
if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
|
|
1821
|
-
const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
1822
|
-
if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
|
|
2990
|
+
if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
|
|
2991
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
|
|
2992
|
+
if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
|
|
2993
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
|
|
2994
|
+
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2995
|
+
if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
|
|
1823
2996
|
if (latest && document.revision === latest.revision) {
|
|
1824
|
-
if (latest.digest !== digest) return
|
|
2997
|
+
if (latest.digest !== digest) return reject3("conflicting_revision", 409);
|
|
1825
2998
|
return json({
|
|
1826
2999
|
ok: true,
|
|
1827
3000
|
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
@@ -1833,9 +3006,9 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1833
3006
|
}
|
|
1834
3007
|
const receivedAt = Date.now();
|
|
1835
3008
|
try {
|
|
1836
|
-
await
|
|
3009
|
+
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
1837
3010
|
} catch {
|
|
1838
|
-
return
|
|
3011
|
+
return reject3("materialization_failed", 503);
|
|
1839
3012
|
}
|
|
1840
3013
|
return json({
|
|
1841
3014
|
ok: true,
|
|
@@ -1847,92 +3020,101 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
1847
3020
|
});
|
|
1848
3021
|
};
|
|
1849
3022
|
|
|
1850
|
-
// src/
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
}
|
|
1875
|
-
function billingColumns(app) {
|
|
1876
|
-
const status = str(app.status);
|
|
1877
|
-
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1878
|
-
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1879
|
-
const cols = { billingStatus };
|
|
1880
|
-
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1881
|
-
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1882
|
-
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1883
|
-
return cols;
|
|
1884
|
-
}
|
|
1885
|
-
async function syncApplicationToCrm(deps, opts) {
|
|
1886
|
-
const emailKey = str(opts.app.email).toLowerCase();
|
|
1887
|
-
if (!emailKey) return null;
|
|
1888
|
-
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1889
|
-
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1890
|
-
const { crm_record } = await deps.db.query({
|
|
1891
|
-
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1892
|
-
});
|
|
1893
|
-
const existing = crm_record?.[0] ?? null;
|
|
1894
|
-
const stage = opts.stage || void 0;
|
|
1895
|
-
let recordId;
|
|
1896
|
-
if (existing && typeof existing.id === "string") {
|
|
1897
|
-
recordId = existing.id;
|
|
1898
|
-
await updateRecord2(recordDeps, { id: recordId, input });
|
|
1899
|
-
} else {
|
|
1900
|
-
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1901
|
-
recordId = created.id;
|
|
1902
|
-
}
|
|
1903
|
-
if (existing && stage && existing.stage !== stage) {
|
|
1904
|
-
await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1905
|
-
}
|
|
1906
|
-
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1907
|
-
await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1908
|
-
return recordId;
|
|
1909
|
-
}
|
|
1910
|
-
async function backfillCrm(deps) {
|
|
1911
|
-
const [appsRes, usersRes] = await Promise.all([
|
|
1912
|
-
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1913
|
-
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1914
|
-
]);
|
|
1915
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1916
|
-
let synced = 0;
|
|
1917
|
-
const errors = [];
|
|
1918
|
-
const run = async (app, stage) => {
|
|
1919
|
-
const key = str(app.email).toLowerCase();
|
|
1920
|
-
if (!key || seen.has(key)) return;
|
|
1921
|
-
seen.add(key);
|
|
3023
|
+
// src/worker-routes-admin-admission.ts
|
|
3024
|
+
async function gate(req, env, ctx) {
|
|
3025
|
+
const rawDb = ctx.makeDb(env);
|
|
3026
|
+
const user = await ctx.verifyUser(req, env);
|
|
3027
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3028
|
+
if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
|
|
3029
|
+
return { db: rawDb, user };
|
|
3030
|
+
}
|
|
3031
|
+
var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
3032
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
|
|
3033
|
+
const auth = await gate(req, env, ctx);
|
|
3034
|
+
if (auth instanceof Response) return auth;
|
|
3035
|
+
const result = await auth.db.query({ admissionGrants: { $: {
|
|
3036
|
+
where: { targetId: ctx.chapter.id },
|
|
3037
|
+
order: { createdAt: "desc" },
|
|
3038
|
+
limit: 500
|
|
3039
|
+
} } });
|
|
3040
|
+
const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
|
|
3041
|
+
return json({ grants });
|
|
3042
|
+
}
|
|
3043
|
+
if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
|
|
3044
|
+
const auth = await gate(req, env, ctx);
|
|
3045
|
+
if (auth instanceof Response) return auth;
|
|
3046
|
+
let body;
|
|
1922
3047
|
try {
|
|
1923
|
-
await
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
3048
|
+
body = JSON.parse(await req.text());
|
|
3049
|
+
} catch {
|
|
3050
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3051
|
+
}
|
|
3052
|
+
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
3053
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
3054
|
+
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
3055
|
+
const decision = body.decision ?? "waive_interview";
|
|
3056
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
3057
|
+
return json({ error: "decision must be waive_interview or require_interview" }, 400);
|
|
3058
|
+
}
|
|
3059
|
+
const note = typeof body.note === "string" ? body.note.trim() : "";
|
|
3060
|
+
if (note.length > 2e3) return json({ error: "note is too long" }, 400);
|
|
3061
|
+
const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
|
|
3062
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
|
|
3063
|
+
return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
|
|
3064
|
+
}
|
|
3065
|
+
const sourceGrantId = crypto.randomUUID();
|
|
3066
|
+
const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
|
|
3067
|
+
const createdAt = Date.now();
|
|
3068
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
3069
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
3070
|
+
id,
|
|
3071
|
+
sourceGrantId,
|
|
3072
|
+
sourceScope: "local",
|
|
3073
|
+
sourceId: ctx.chapter.id,
|
|
3074
|
+
targetId: ctx.chapter.id,
|
|
3075
|
+
email,
|
|
3076
|
+
emailNormalized,
|
|
3077
|
+
decision,
|
|
3078
|
+
...note ? { note } : {},
|
|
3079
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
3080
|
+
active: true,
|
|
3081
|
+
createdAt,
|
|
3082
|
+
createdBy: actor,
|
|
3083
|
+
updatedAt: createdAt,
|
|
3084
|
+
updatedBy: actor
|
|
3085
|
+
} }], { mutationId: `admission-local-create:${sourceGrantId}` });
|
|
3086
|
+
return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
|
|
3087
|
+
}
|
|
3088
|
+
const prefix = "/api/admin/admission-grants/";
|
|
3089
|
+
let revokeId = "";
|
|
3090
|
+
if (url.pathname.startsWith(prefix)) {
|
|
3091
|
+
try {
|
|
3092
|
+
revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
3093
|
+
} catch {
|
|
3094
|
+
revokeId = "";
|
|
1927
3095
|
}
|
|
1928
|
-
};
|
|
1929
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1930
|
-
for (const u of usersRes.$users ?? []) {
|
|
1931
|
-
if (u.deleted === true) continue;
|
|
1932
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1933
3096
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
3097
|
+
if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
|
|
3098
|
+
const auth = await gate(req, env, ctx);
|
|
3099
|
+
if (auth instanceof Response) return auth;
|
|
3100
|
+
const id = revokeId;
|
|
3101
|
+
const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
|
|
3102
|
+
const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
|
|
3103
|
+
if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
|
|
3104
|
+
if (row.active !== true) return json({ ok: true, replayed: true });
|
|
3105
|
+
const revokedAt = Date.now();
|
|
3106
|
+
const actor = auth.user.email ?? auth.user.userId;
|
|
3107
|
+
await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
|
|
3108
|
+
active: false,
|
|
3109
|
+
revokedAt,
|
|
3110
|
+
revokedBy: actor,
|
|
3111
|
+
updatedAt: revokedAt,
|
|
3112
|
+
updatedBy: actor
|
|
3113
|
+
} }], { mutationId: `admission-local-revoke:${id}` });
|
|
3114
|
+
return json({ ok: true, replayed: false });
|
|
3115
|
+
}
|
|
3116
|
+
return null;
|
|
3117
|
+
};
|
|
1936
3118
|
|
|
1937
3119
|
// src/worker-routes-schedule.ts
|
|
1938
3120
|
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
@@ -1948,8 +3130,8 @@ function makeCalendar(env) {
|
|
|
1948
3130
|
}
|
|
1949
3131
|
async function firstRow(db, ns, q) {
|
|
1950
3132
|
const res = await db.query({ [ns]: { $: q } });
|
|
1951
|
-
const
|
|
1952
|
-
return Array.isArray(
|
|
3133
|
+
const rows4 = res[ns];
|
|
3134
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
1953
3135
|
}
|
|
1954
3136
|
async function computeSlots(cal, cfg) {
|
|
1955
3137
|
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
@@ -2086,8 +3268,26 @@ function findApplicationRef(obj) {
|
|
|
2086
3268
|
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
2087
3269
|
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
2088
3270
|
}
|
|
3271
|
+
function findGiftRef(obj) {
|
|
3272
|
+
const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
|
|
3273
|
+
const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
|
|
3274
|
+
const parent = obj.parent;
|
|
3275
|
+
const subscription = obj.subscription_details ?? parent?.subscription_details;
|
|
3276
|
+
const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
|
|
3277
|
+
const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
|
|
3278
|
+
return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
|
|
3279
|
+
}
|
|
2089
3280
|
function normalizeWebhookEvent(event) {
|
|
2090
3281
|
const obj = event.data?.object ?? {};
|
|
3282
|
+
const gift = findGiftRef(obj);
|
|
3283
|
+
if (gift.giftIntentId) {
|
|
3284
|
+
if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
|
|
3285
|
+
return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
|
|
3286
|
+
}
|
|
3287
|
+
if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
|
|
3288
|
+
if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
|
|
3289
|
+
return { kind: "ignored", type: event.type };
|
|
3290
|
+
}
|
|
2091
3291
|
const ref = findApplicationRef(obj);
|
|
2092
3292
|
switch (event.type) {
|
|
2093
3293
|
case "invoice.paid": {
|
|
@@ -2188,9 +3388,9 @@ function createStripeProvider(config) {
|
|
|
2188
3388
|
`sub:${input.applicationId}`
|
|
2189
3389
|
);
|
|
2190
3390
|
if (!sub.ok) fail("subscription create", sub);
|
|
2191
|
-
const
|
|
2192
|
-
if (!
|
|
2193
|
-
return { customerId, subscriptionId: String(sub.body.id), clientSecret };
|
|
3391
|
+
const clientSecret2 = clientSecretOf(sub.body);
|
|
3392
|
+
if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
|
|
3393
|
+
return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
|
|
2194
3394
|
},
|
|
2195
3395
|
async ingestWebhook(rawBody, sigHeader) {
|
|
2196
3396
|
if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
|
|
@@ -2203,8 +3403,8 @@ function createStripeProvider(config) {
|
|
|
2203
3403
|
const sk = requireSecret(secretKey);
|
|
2204
3404
|
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
2205
3405
|
if (!charges.ok) fail("charges list", charges);
|
|
2206
|
-
const
|
|
2207
|
-
const paid =
|
|
3406
|
+
const rows4 = charges.body.data ?? [];
|
|
3407
|
+
const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
2208
3408
|
const charge = paid[paid.length - 1];
|
|
2209
3409
|
if (!charge) {
|
|
2210
3410
|
const err = new Error("no paid charge to refund");
|
|
@@ -2220,12 +3420,337 @@ function createStripeProvider(config) {
|
|
|
2220
3420
|
};
|
|
2221
3421
|
}
|
|
2222
3422
|
|
|
3423
|
+
// src/gift-codes.ts
|
|
3424
|
+
var GIFT_CODE_VERSION = 1;
|
|
3425
|
+
var GIFT_CODE_BYTES = 32;
|
|
3426
|
+
var base64url = (bytes) => {
|
|
3427
|
+
let binary = "";
|
|
3428
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
3429
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
3430
|
+
};
|
|
3431
|
+
function createGiftCode() {
|
|
3432
|
+
const bytes = new Uint8Array(GIFT_CODE_BYTES);
|
|
3433
|
+
crypto.getRandomValues(bytes);
|
|
3434
|
+
return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
|
|
3435
|
+
}
|
|
3436
|
+
async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
|
|
3437
|
+
const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
|
|
3438
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
|
|
3439
|
+
}
|
|
3440
|
+
async function giftRequesterBucket(chapterId, requester) {
|
|
3441
|
+
const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
|
|
3442
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
// src/gift-store.ts
|
|
3446
|
+
var first = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3447
|
+
async function prepareGiftCode(chapterId) {
|
|
3448
|
+
const code = createGiftCode();
|
|
3449
|
+
return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
|
|
3450
|
+
}
|
|
3451
|
+
async function issueGiftClaim(db, intent, provider, now) {
|
|
3452
|
+
const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
3453
|
+
if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
|
|
3454
|
+
const offer = intent.offerSnapshot;
|
|
3455
|
+
const orderId = `gift-order:${intent.id}`;
|
|
3456
|
+
const claimId = `gift-claim:${intent.id}`;
|
|
3457
|
+
const shared = {
|
|
3458
|
+
chapterId: intent.chapterId,
|
|
3459
|
+
offerId: intent.offerId,
|
|
3460
|
+
...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
|
|
3461
|
+
...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
|
|
3462
|
+
purchaserEmailNormalized: intent.purchaserEmailNormalized
|
|
3463
|
+
};
|
|
3464
|
+
const ops = [
|
|
3465
|
+
{ t: "update", ns: "giftOrders", id: orderId, attrs: {
|
|
3466
|
+
id: orderId,
|
|
3467
|
+
intentId: intent.id,
|
|
3468
|
+
...shared,
|
|
3469
|
+
environment: intent.environment,
|
|
3470
|
+
offerSnapshot: offer,
|
|
3471
|
+
purchaserType: intent.purchaserType,
|
|
3472
|
+
providerEventId: provider.eventId,
|
|
3473
|
+
providerObjectId: provider.objectId,
|
|
3474
|
+
amountCents: offer.priceCents,
|
|
3475
|
+
currency: offer.currency,
|
|
3476
|
+
billingMode: offer.billingMode,
|
|
3477
|
+
createdAt: now
|
|
3478
|
+
} },
|
|
3479
|
+
{ t: "update", ns: "giftClaims", id: claimId, attrs: {
|
|
3480
|
+
id: claimId,
|
|
3481
|
+
orderId,
|
|
3482
|
+
intentId: intent.id,
|
|
3483
|
+
...shared,
|
|
3484
|
+
target: offer.target,
|
|
3485
|
+
codeHash: intent.codeHash,
|
|
3486
|
+
codeVersion: intent.codeVersion,
|
|
3487
|
+
rotationCount: 0,
|
|
3488
|
+
codeIssuedAt: intent.codeIssuedAt,
|
|
3489
|
+
codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
|
|
3490
|
+
attemptCount: 0,
|
|
3491
|
+
attemptWindowStartedAt: now,
|
|
3492
|
+
status: "pending",
|
|
3493
|
+
createdAt: now
|
|
3494
|
+
} },
|
|
3495
|
+
{ t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
|
|
3496
|
+
];
|
|
3497
|
+
if (intent.purchaserApplicationId) ops.push({
|
|
3498
|
+
t: "update",
|
|
3499
|
+
ns: "applications",
|
|
3500
|
+
id: intent.purchaserApplicationId,
|
|
3501
|
+
attrs: { giftPaidAt: now, giftClaimId: claimId }
|
|
3502
|
+
});
|
|
3503
|
+
try {
|
|
3504
|
+
const result = await db.transact(ops, {
|
|
3505
|
+
mutationId: `gift-paid:${provider.eventId}`,
|
|
3506
|
+
guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
|
|
3507
|
+
});
|
|
3508
|
+
return { claimId, orderId, duplicate: result.duplicate };
|
|
3509
|
+
} catch {
|
|
3510
|
+
const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
|
|
3511
|
+
if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
|
|
3512
|
+
throw new Error("gift issuance conflict");
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
function buyerGiftClaims(rows4, now) {
|
|
3516
|
+
return rows4.map((row) => {
|
|
3517
|
+
const stored = String(row.status ?? "pending");
|
|
3518
|
+
const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
|
|
3519
|
+
return {
|
|
3520
|
+
id: String(row.id),
|
|
3521
|
+
offerId: String(row.offerId),
|
|
3522
|
+
status,
|
|
3523
|
+
createdAt: Number(row.createdAt),
|
|
3524
|
+
codeExpiresAt: Number(row.codeExpiresAt),
|
|
3525
|
+
...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
|
|
3526
|
+
...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
|
|
3527
|
+
};
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3530
|
+
async function rotateGiftClaim(db, claim, now) {
|
|
3531
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
3532
|
+
const prepared = await prepareGiftCode(String(claim.chapterId));
|
|
3533
|
+
const currentRotation = Number(claim.rotationCount ?? 0);
|
|
3534
|
+
try {
|
|
3535
|
+
const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3536
|
+
codeHash: prepared.codeHash,
|
|
3537
|
+
codeVersion: prepared.codeVersion,
|
|
3538
|
+
rotationCount: currentRotation + 1,
|
|
3539
|
+
codeIssuedAt: now,
|
|
3540
|
+
rotatedAt: now,
|
|
3541
|
+
attemptCount: 0,
|
|
3542
|
+
attemptWindowStartedAt: now
|
|
3543
|
+
} }], {
|
|
3544
|
+
mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
|
|
3545
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
|
|
3546
|
+
});
|
|
3547
|
+
return write.duplicate ? null : { code: prepared.code };
|
|
3548
|
+
} catch {
|
|
3549
|
+
return null;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
3553
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
3554
|
+
const chapterId = String(claim.chapterId);
|
|
3555
|
+
const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
|
|
3556
|
+
const target = claim.target;
|
|
3557
|
+
const ops = [];
|
|
3558
|
+
let result;
|
|
3559
|
+
if (target.type === "tier") {
|
|
3560
|
+
const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
3561
|
+
const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3562
|
+
const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
|
|
3563
|
+
const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
|
|
3564
|
+
ops.push({ t: "update", ns: "applications", id, attrs: {
|
|
3565
|
+
id,
|
|
3566
|
+
groupId: chapterId,
|
|
3567
|
+
email: beneficiary.email,
|
|
3568
|
+
emailNormalized,
|
|
3569
|
+
tierId: target.tierId,
|
|
3570
|
+
status,
|
|
3571
|
+
giftClaimId: String(claim.id),
|
|
3572
|
+
giftOrderId: String(claim.orderId),
|
|
3573
|
+
clerkUserId: beneficiary.userId,
|
|
3574
|
+
...existing ? {} : { createdAt: now }
|
|
3575
|
+
} });
|
|
3576
|
+
result = { kind: "application", id };
|
|
3577
|
+
} else {
|
|
3578
|
+
const id = `gift-entitlement:${String(claim.id)}`;
|
|
3579
|
+
ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
|
|
3580
|
+
id,
|
|
3581
|
+
claimId: String(claim.id),
|
|
3582
|
+
orderId: String(claim.orderId),
|
|
3583
|
+
chapterId,
|
|
3584
|
+
entitlementKey: target.entitlementKey,
|
|
3585
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3586
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3587
|
+
active: true,
|
|
3588
|
+
createdAt: now
|
|
3589
|
+
} });
|
|
3590
|
+
result = { kind: "entitlement", id };
|
|
3591
|
+
}
|
|
3592
|
+
ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3593
|
+
status: "redeemed",
|
|
3594
|
+
redeemedAt: now,
|
|
3595
|
+
beneficiaryEmailNormalized: emailNormalized,
|
|
3596
|
+
beneficiaryUserId: beneficiary.userId,
|
|
3597
|
+
...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
|
|
3598
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1
|
|
3599
|
+
} });
|
|
3600
|
+
try {
|
|
3601
|
+
await db.transact(ops, {
|
|
3602
|
+
mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
|
|
3603
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
|
|
3604
|
+
status: "pending",
|
|
3605
|
+
codeVersion: Number(claim.codeVersion),
|
|
3606
|
+
codeHash: String(claim.codeHash)
|
|
3607
|
+
} }]
|
|
3608
|
+
});
|
|
3609
|
+
return result;
|
|
3610
|
+
} catch {
|
|
3611
|
+
return null;
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
async function revokeGiftClaim(db, claim, reason, now) {
|
|
3615
|
+
if (claim.status === "revoked") return false;
|
|
3616
|
+
const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
3617
|
+
status: "revoked",
|
|
3618
|
+
revokedAt: now,
|
|
3619
|
+
revokedReason: reason
|
|
3620
|
+
} }];
|
|
3621
|
+
if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
|
|
3622
|
+
if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
|
|
3623
|
+
giftRevokedAt: now,
|
|
3624
|
+
...reason === "refunded" ? { status: "refunded" } : { canceled: true }
|
|
3625
|
+
} });
|
|
3626
|
+
try {
|
|
3627
|
+
await db.transact(ops, {
|
|
3628
|
+
mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
|
|
3629
|
+
guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
|
|
3630
|
+
});
|
|
3631
|
+
return true;
|
|
3632
|
+
} catch {
|
|
3633
|
+
return false;
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
// src/gift-checkout.ts
|
|
3638
|
+
var clientSecret = (value) => {
|
|
3639
|
+
const direct = value.client_secret;
|
|
3640
|
+
if (typeof direct === "string" && direct) return direct;
|
|
3641
|
+
const invoice = value.latest_invoice;
|
|
3642
|
+
const confirmation = invoice?.confirmation_secret;
|
|
3643
|
+
const intent = invoice?.payment_intent;
|
|
3644
|
+
const nested = confirmation?.client_secret ?? intent?.client_secret;
|
|
3645
|
+
return typeof nested === "string" && nested ? nested : null;
|
|
3646
|
+
};
|
|
3647
|
+
async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
|
|
3648
|
+
const offer = intent.offerSnapshot;
|
|
3649
|
+
const metadata = {
|
|
3650
|
+
giftIntentId: intent.id,
|
|
3651
|
+
chapterId: intent.chapterId,
|
|
3652
|
+
environment: intent.environment,
|
|
3653
|
+
offerId: intent.offerId
|
|
3654
|
+
};
|
|
3655
|
+
let customerId = existingCustomerId;
|
|
3656
|
+
if (!customerId) {
|
|
3657
|
+
const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
|
|
3658
|
+
if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
|
|
3659
|
+
customerId = String(customer.body.id);
|
|
3660
|
+
}
|
|
3661
|
+
if (offer.billingMode === "subscription") {
|
|
3662
|
+
const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
|
|
3663
|
+
customer: customerId,
|
|
3664
|
+
"items[0][price]": offer.stripePriceId,
|
|
3665
|
+
payment_behavior: "default_incomplete",
|
|
3666
|
+
"payment_settings[save_default_payment_method]": "on_subscription",
|
|
3667
|
+
"payment_settings[payment_method_types][0]": "card",
|
|
3668
|
+
"expand[0]": "latest_invoice.confirmation_secret",
|
|
3669
|
+
metadata
|
|
3670
|
+
}, `gift-subscription:${intent.id}`);
|
|
3671
|
+
const secret2 = created2.ok ? clientSecret(created2.body) : null;
|
|
3672
|
+
if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
|
|
3673
|
+
return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
|
|
3674
|
+
}
|
|
3675
|
+
const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
|
|
3676
|
+
if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
|
|
3677
|
+
throw new Error("gift price does not match the signed offer");
|
|
3678
|
+
}
|
|
3679
|
+
const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
|
|
3680
|
+
customer: customerId,
|
|
3681
|
+
amount: offer.priceCents,
|
|
3682
|
+
currency: offer.currency,
|
|
3683
|
+
"automatic_payment_methods[enabled]": true,
|
|
3684
|
+
metadata
|
|
3685
|
+
}, `gift-payment:${intent.id}`);
|
|
3686
|
+
const secret = created.ok ? clientSecret(created.body) : null;
|
|
3687
|
+
if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
|
|
3688
|
+
return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
// src/gift-intents.ts
|
|
3692
|
+
var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3693
|
+
async function startGiftIntent(db, input) {
|
|
3694
|
+
let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
|
|
3695
|
+
let giftCode;
|
|
3696
|
+
if (!stored) {
|
|
3697
|
+
const prepared = await prepareGiftCode(input.chapterId);
|
|
3698
|
+
const attrs = {
|
|
3699
|
+
id: input.id,
|
|
3700
|
+
chapterId: input.chapterId,
|
|
3701
|
+
environment: input.environment,
|
|
3702
|
+
purchaserType: input.purchaserType,
|
|
3703
|
+
purchaserEmailNormalized: input.purchaserEmailNormalized,
|
|
3704
|
+
...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
|
|
3705
|
+
...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
|
|
3706
|
+
offerId: input.offer.id,
|
|
3707
|
+
offerSnapshot: input.offer,
|
|
3708
|
+
status: "created",
|
|
3709
|
+
codeHash: prepared.codeHash,
|
|
3710
|
+
codeVersion: prepared.codeVersion,
|
|
3711
|
+
codeIssuedAt: input.now,
|
|
3712
|
+
createdAt: input.now,
|
|
3713
|
+
updatedAt: input.now
|
|
3714
|
+
};
|
|
3715
|
+
const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
|
|
3716
|
+
mutationId: `gift-intent:${input.id}`
|
|
3717
|
+
});
|
|
3718
|
+
stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
|
|
3719
|
+
if (!write.duplicate) giftCode = prepared.code;
|
|
3720
|
+
}
|
|
3721
|
+
if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
|
|
3722
|
+
throw new Error("gift purchase intent does not match this checkout");
|
|
3723
|
+
}
|
|
3724
|
+
if (stored.status === "paid") throw new Error("gift purchase is already paid");
|
|
3725
|
+
const checkout = await startGiftProviderCheckout(
|
|
3726
|
+
input.stripeSecret,
|
|
3727
|
+
stored,
|
|
3728
|
+
input.purchaserEmail,
|
|
3729
|
+
typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
|
|
3730
|
+
);
|
|
3731
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
|
|
3732
|
+
status: "checkout_ready",
|
|
3733
|
+
providerObjectId: checkout.providerObjectId,
|
|
3734
|
+
...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
|
|
3735
|
+
checkoutStartedAt: input.now,
|
|
3736
|
+
updatedAt: input.now
|
|
3737
|
+
} }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
|
|
3738
|
+
return {
|
|
3739
|
+
gift: true,
|
|
3740
|
+
intentId: input.id,
|
|
3741
|
+
clientSecret: checkout.clientSecret,
|
|
3742
|
+
publishableKey: input.publishableKey,
|
|
3743
|
+
lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
|
|
3744
|
+
...giftCode ? { giftCode } : {}
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
|
|
2223
3748
|
// src/worker-routes-payments.ts
|
|
2224
3749
|
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
2225
3750
|
async function firstRow2(db, ns, q) {
|
|
2226
3751
|
const res = await db.query({ [ns]: { $: q } });
|
|
2227
|
-
const
|
|
2228
|
-
return Array.isArray(
|
|
3752
|
+
const rows4 = res[ns];
|
|
3753
|
+
return Array.isArray(rows4) ? rows4[0] : void 0;
|
|
2229
3754
|
}
|
|
2230
3755
|
function lineItems(group) {
|
|
2231
3756
|
const standard = Number(group.standardPriceCents ?? 0);
|
|
@@ -2310,6 +3835,36 @@ async function startSubscription(req, env, ctx) {
|
|
|
2310
3835
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
2311
3836
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
2312
3837
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3838
|
+
if (typeof app.giftOfferId === "string") {
|
|
3839
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3840
|
+
const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3841
|
+
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3842
|
+
return json({ error: "gift offer is unavailable" }, 409);
|
|
3843
|
+
}
|
|
3844
|
+
try {
|
|
3845
|
+
const started = await startGiftIntent(db, {
|
|
3846
|
+
id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
|
|
3847
|
+
chapterId: ctx.chapter.id,
|
|
3848
|
+
environment: env.ODLA_ENV,
|
|
3849
|
+
offer,
|
|
3850
|
+
purchaserType: "application",
|
|
3851
|
+
purchaserEmail: String(app.email ?? ""),
|
|
3852
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
|
|
3853
|
+
purchaserApplicationId: applicationId,
|
|
3854
|
+
stripeSecret: secretKey,
|
|
3855
|
+
publishableKey: group.stripePublishableKey,
|
|
3856
|
+
now: Date.now()
|
|
3857
|
+
});
|
|
3858
|
+
await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
|
|
3859
|
+
giftIntentId: started.intentId,
|
|
3860
|
+
giftCheckoutStartedAt: Date.now(),
|
|
3861
|
+
refundPolicyAckAt: Date.now()
|
|
3862
|
+
} }]);
|
|
3863
|
+
return json(started);
|
|
3864
|
+
} catch (error) {
|
|
3865
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
2313
3868
|
const tier = findTier(
|
|
2314
3869
|
await loadTiers(db, group),
|
|
2315
3870
|
typeof app.tierId === "string" ? app.tierId : null
|
|
@@ -2351,17 +3906,54 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
2351
3906
|
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
2352
3907
|
const { eventId, event } = ingest;
|
|
2353
3908
|
if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
|
|
3909
|
+
if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
|
|
3910
|
+
const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
|
|
3911
|
+
if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
|
|
3912
|
+
return json({ ok: true, matched: false });
|
|
3913
|
+
}
|
|
3914
|
+
if (event.kind === "gift_paid") {
|
|
3915
|
+
if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
|
|
3916
|
+
if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
|
|
3917
|
+
try {
|
|
3918
|
+
const issued = await issueGiftClaim(db, intent, {
|
|
3919
|
+
eventId,
|
|
3920
|
+
objectId: event.providerObjectId
|
|
3921
|
+
}, Date.now());
|
|
3922
|
+
return json({ ok: true, matched: true, claimId: issued.claimId });
|
|
3923
|
+
} catch {
|
|
3924
|
+
return json({ error: "gift issuance failed" }, 503);
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
|
|
3928
|
+
status: event.reason,
|
|
3929
|
+
updatedAt: Date.now()
|
|
3930
|
+
} }], { mutationId: `gift-revoked-intent:${eventId}` });
|
|
3931
|
+
const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
|
|
3932
|
+
if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
|
|
3933
|
+
return json({ ok: true, matched: Boolean(claim) });
|
|
3934
|
+
}
|
|
2354
3935
|
const app = await findApplication(db, event);
|
|
2355
3936
|
if (!app) return json({ ok: true, matched: false });
|
|
2356
3937
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
3938
|
+
let next = app;
|
|
2357
3939
|
if (Object.keys(patch).length) {
|
|
2358
3940
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
2359
|
-
|
|
3941
|
+
next = { ...app, ...patch };
|
|
2360
3942
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
2361
3943
|
}
|
|
2362
3944
|
if (event.kind === "first_payment") {
|
|
2363
3945
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
2364
3946
|
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
3947
|
+
if (typeof next.interviewWaivedAt === "number") {
|
|
3948
|
+
await approveApplication({
|
|
3949
|
+
db,
|
|
3950
|
+
env,
|
|
3951
|
+
ctx,
|
|
3952
|
+
app: next,
|
|
3953
|
+
origin: new URL(req.url).origin,
|
|
3954
|
+
reason: "interview_waiver"
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
2365
3957
|
}
|
|
2366
3958
|
return json({ ok: true });
|
|
2367
3959
|
}
|
|
@@ -2372,6 +3964,144 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
2372
3964
|
return null;
|
|
2373
3965
|
};
|
|
2374
3966
|
|
|
3967
|
+
// src/worker-routes-gifts.ts
|
|
3968
|
+
var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
|
|
3969
|
+
var rows2 = (value) => Array.isArray(value) ? value : [];
|
|
3970
|
+
var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
|
|
3971
|
+
var userFor = async (req, env, ctx) => {
|
|
3972
|
+
const user = await ctx.verifyUser(req, env);
|
|
3973
|
+
return user?.email ? user : null;
|
|
3974
|
+
};
|
|
3975
|
+
var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
|
|
3976
|
+
async function consumeRateLimit(db, chapterId, requester, now) {
|
|
3977
|
+
const bucket = await giftRequesterBucket(chapterId, requester);
|
|
3978
|
+
const id = `gift-rate:${chapterId}:${bucket}`;
|
|
3979
|
+
const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
|
|
3980
|
+
const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
|
|
3981
|
+
const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
|
|
3982
|
+
if (attemptCount > 10) return false;
|
|
3983
|
+
await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
|
|
3984
|
+
id,
|
|
3985
|
+
chapterId,
|
|
3986
|
+
bucket,
|
|
3987
|
+
attemptCount,
|
|
3988
|
+
windowStartedAt: within ? Number(current?.windowStartedAt) : now,
|
|
3989
|
+
updatedAt: now
|
|
3990
|
+
} }]);
|
|
3991
|
+
return true;
|
|
3992
|
+
}
|
|
3993
|
+
var handleGifts = async (req, url, env, ctx) => {
|
|
3994
|
+
if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
|
|
3995
|
+
const db = ctx.makeDb(env);
|
|
3996
|
+
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3997
|
+
const user = await userFor(req, env, ctx);
|
|
3998
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3999
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4000
|
+
const found = await db.query({ giftClaims: { $: { where: {
|
|
4001
|
+
purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
|
|
4002
|
+
chapterId: ctx.chapter.id
|
|
4003
|
+
}, limit: 200 } } });
|
|
4004
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4005
|
+
return json({
|
|
4006
|
+
offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
|
|
4007
|
+
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
4008
|
+
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
4011
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
|
|
4012
|
+
const user = await userFor(req, env, ctx);
|
|
4013
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4014
|
+
let body;
|
|
4015
|
+
try {
|
|
4016
|
+
body = JSON.parse(await req.text());
|
|
4017
|
+
} catch {
|
|
4018
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
4019
|
+
}
|
|
4020
|
+
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
4021
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
4022
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4023
|
+
const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
4024
|
+
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
4025
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4026
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
4027
|
+
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
4028
|
+
const emailNormalized = normalizeAdmissionEmail(user.email);
|
|
4029
|
+
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
4030
|
+
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
4031
|
+
try {
|
|
4032
|
+
return json(await startGiftIntent(db, {
|
|
4033
|
+
id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
|
|
4034
|
+
chapterId: ctx.chapter.id,
|
|
4035
|
+
environment: env.ODLA_ENV,
|
|
4036
|
+
offer,
|
|
4037
|
+
purchaserType: "member",
|
|
4038
|
+
purchaserEmail: user.email,
|
|
4039
|
+
purchaserEmailNormalized: emailNormalized,
|
|
4040
|
+
purchaserUserId: user.userId,
|
|
4041
|
+
...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
|
|
4042
|
+
stripeSecret,
|
|
4043
|
+
publishableKey: group.stripePublishableKey,
|
|
4044
|
+
now: Date.now()
|
|
4045
|
+
}));
|
|
4046
|
+
} catch (error) {
|
|
4047
|
+
return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
|
|
4051
|
+
if (req.method === "POST" && rotateMatch) {
|
|
4052
|
+
let claimId = "";
|
|
4053
|
+
try {
|
|
4054
|
+
claimId = decodeURIComponent(rotateMatch[1]);
|
|
4055
|
+
} catch {
|
|
4056
|
+
return invalid();
|
|
4057
|
+
}
|
|
4058
|
+
if (!claimId.startsWith("gift-claim:")) return invalid();
|
|
4059
|
+
const claim = await loadClaim(db, claimId);
|
|
4060
|
+
if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
|
|
4061
|
+
const user = await userFor(req, env, ctx);
|
|
4062
|
+
let applicationId = "";
|
|
4063
|
+
if (!user) {
|
|
4064
|
+
try {
|
|
4065
|
+
applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
|
|
4066
|
+
} catch {
|
|
4067
|
+
return invalid();
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
4071
|
+
if (!owns) return invalid();
|
|
4072
|
+
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
4073
|
+
return rotated ? json(rotated) : invalid();
|
|
4074
|
+
}
|
|
4075
|
+
if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
|
|
4076
|
+
const user = await userFor(req, env, ctx);
|
|
4077
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4078
|
+
const now = Date.now();
|
|
4079
|
+
if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
|
|
4080
|
+
return json({ error: "too many attempts" }, 429);
|
|
4081
|
+
}
|
|
4082
|
+
let code = "";
|
|
4083
|
+
try {
|
|
4084
|
+
code = String(JSON.parse(await req.text()).code ?? "").trim();
|
|
4085
|
+
} catch {
|
|
4086
|
+
return invalid();
|
|
4087
|
+
}
|
|
4088
|
+
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
|
|
4089
|
+
const codeHash = await hashGiftCode(ctx.chapter.id, code);
|
|
4090
|
+
const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
|
|
4091
|
+
if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
|
|
4092
|
+
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
|
|
4093
|
+
await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
4094
|
+
attemptCount: Number(claim.attemptCount ?? 0) + 1,
|
|
4095
|
+
...claim.status === "pending" ? { status: "expired" } : {}
|
|
4096
|
+
} }]).catch(() => void 0);
|
|
4097
|
+
return invalid();
|
|
4098
|
+
}
|
|
4099
|
+
const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
|
|
4100
|
+
return redeemed ? json({ ok: true, ...redeemed }) : invalid();
|
|
4101
|
+
}
|
|
4102
|
+
return null;
|
|
4103
|
+
};
|
|
4104
|
+
|
|
2375
4105
|
// src/worker-routes-admin.ts
|
|
2376
4106
|
import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
|
|
2377
4107
|
|
|
@@ -2443,8 +4173,8 @@ async function upcomingEvents(env) {
|
|
|
2443
4173
|
const res = await cal.availability.upcoming();
|
|
2444
4174
|
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
2445
4175
|
}
|
|
2446
|
-
function toReconcile(
|
|
2447
|
-
return
|
|
4176
|
+
function toReconcile(rows4) {
|
|
4177
|
+
return rows4.map((m) => ({
|
|
2448
4178
|
id: String(m.id),
|
|
2449
4179
|
applicationId: String(m.applicationId),
|
|
2450
4180
|
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
@@ -2466,12 +4196,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2466
4196
|
const query = await db.query({
|
|
2467
4197
|
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
2468
4198
|
});
|
|
2469
|
-
let
|
|
2470
|
-
if (Number.isFinite(from))
|
|
2471
|
-
if (Number.isFinite(to))
|
|
4199
|
+
let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
|
|
4200
|
+
if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
|
|
4201
|
+
if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
|
|
2472
4202
|
let decisions = [];
|
|
2473
4203
|
try {
|
|
2474
|
-
decisions = reconcileMeetings(toReconcile(
|
|
4204
|
+
decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
|
|
2475
4205
|
} catch {
|
|
2476
4206
|
}
|
|
2477
4207
|
const patched = /* @__PURE__ */ new Map();
|
|
@@ -2498,69 +4228,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
2498
4228
|
};
|
|
2499
4229
|
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
2500
4230
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
2501
|
-
const meetings =
|
|
4231
|
+
const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
2502
4232
|
return json({ meetings, adopted: decisions.length, timezone });
|
|
2503
4233
|
};
|
|
2504
4234
|
|
|
2505
|
-
// src/clerk-roles.ts
|
|
2506
|
-
var CLERK_API = "https://api.clerk.com";
|
|
2507
|
-
var DEFAULT_ROLE = "provisional";
|
|
2508
|
-
var PAGE = 100;
|
|
2509
|
-
function toRecord(u) {
|
|
2510
|
-
if (typeof u.id !== "string") return null;
|
|
2511
|
-
const pm = u.public_metadata ?? {};
|
|
2512
|
-
const privateMetadata = u.private_metadata ?? {};
|
|
2513
|
-
const rawProfile = privateMetadata.profile;
|
|
2514
|
-
const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
|
|
2515
|
-
const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
|
|
2516
|
-
const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
|
|
2517
|
-
const email = u.email_addresses?.[0]?.email_address;
|
|
2518
|
-
return {
|
|
2519
|
-
id: u.id,
|
|
2520
|
-
email: typeof email === "string" ? email : void 0,
|
|
2521
|
-
role,
|
|
2522
|
-
publicMetadata: pm,
|
|
2523
|
-
privateMetadata,
|
|
2524
|
-
profile,
|
|
2525
|
-
...applicationId ? { applicationId } : {}
|
|
2526
|
-
};
|
|
2527
|
-
}
|
|
2528
|
-
async function clerkGet(path, secretKey, fetchImpl) {
|
|
2529
|
-
const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
|
|
2530
|
-
if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
|
|
2531
|
-
return res.json();
|
|
2532
|
-
}
|
|
2533
|
-
async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
|
|
2534
|
-
const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
|
|
2535
|
-
const user = Array.isArray(data) ? data[0] : void 0;
|
|
2536
|
-
return user ? toRecord(user) : null;
|
|
2537
|
-
}
|
|
2538
|
-
async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
|
|
2539
|
-
const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
|
|
2540
|
-
return data ? toRecord(data) : null;
|
|
2541
|
-
}
|
|
2542
|
-
async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
2543
|
-
const out = [];
|
|
2544
|
-
for (let offset = 0; ; offset += PAGE) {
|
|
2545
|
-
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
2546
|
-
const page = Array.isArray(data) ? data : [];
|
|
2547
|
-
for (const u of page) {
|
|
2548
|
-
const record = toRecord(u);
|
|
2549
|
-
if (record) out.push(record);
|
|
2550
|
-
}
|
|
2551
|
-
if (page.length < PAGE) break;
|
|
2552
|
-
}
|
|
2553
|
-
return out;
|
|
2554
|
-
}
|
|
2555
|
-
async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
2556
|
-
const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
|
|
2557
|
-
method: "PATCH",
|
|
2558
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
2559
|
-
body: JSON.stringify({ public_metadata: { role } })
|
|
2560
|
-
});
|
|
2561
|
-
return res.ok;
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
4235
|
// src/worker-routes-admin-people.ts
|
|
2565
4236
|
async function adminGate(req, env, ctx) {
|
|
2566
4237
|
const rawDb = ctx.makeDb(env);
|
|
@@ -2578,16 +4249,16 @@ var crmDeps2 = (db, ctx) => ({
|
|
|
2578
4249
|
});
|
|
2579
4250
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
2580
4251
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
2581
|
-
const
|
|
2582
|
-
if (
|
|
2583
|
-
const result = await backfillCrm(crmDeps2(
|
|
4252
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4253
|
+
if (gate9 instanceof Response) return gate9;
|
|
4254
|
+
const result = await backfillCrm(crmDeps2(gate9.db, ctx));
|
|
2584
4255
|
return json({ ok: true, ...result });
|
|
2585
4256
|
};
|
|
2586
4257
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
2587
4258
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
2588
|
-
const
|
|
2589
|
-
if (
|
|
2590
|
-
const { db } =
|
|
4259
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4260
|
+
if (gate9 instanceof Response) return gate9;
|
|
4261
|
+
const { db } = gate9;
|
|
2591
4262
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
2592
4263
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
2593
4264
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -2631,16 +4302,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
2631
4302
|
});
|
|
2632
4303
|
}
|
|
2633
4304
|
}
|
|
2634
|
-
const
|
|
4305
|
+
const rows4 = [...people.values()].sort(
|
|
2635
4306
|
(x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
|
|
2636
4307
|
);
|
|
2637
|
-
return json({ people:
|
|
4308
|
+
return json({ people: rows4 });
|
|
2638
4309
|
};
|
|
2639
4310
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
2640
4311
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
2641
|
-
const
|
|
2642
|
-
if (
|
|
2643
|
-
const { db } =
|
|
4312
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4313
|
+
if (gate9 instanceof Response) return gate9;
|
|
4314
|
+
const { db } = gate9;
|
|
2644
4315
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
2645
4316
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
2646
4317
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2651,9 +4322,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
2651
4322
|
};
|
|
2652
4323
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
2653
4324
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
2654
|
-
const
|
|
2655
|
-
if (
|
|
2656
|
-
const { db, actor } =
|
|
4325
|
+
const gate9 = await adminGate(req, env, ctx);
|
|
4326
|
+
if (gate9 instanceof Response) return gate9;
|
|
4327
|
+
const { db, actor } = gate9;
|
|
2657
4328
|
let body;
|
|
2658
4329
|
try {
|
|
2659
4330
|
body = await req.json();
|
|
@@ -2800,17 +4471,17 @@ function subAnnualCents(sub) {
|
|
|
2800
4471
|
}
|
|
2801
4472
|
|
|
2802
4473
|
// src/worker-routes-admin-dashboard.ts
|
|
2803
|
-
async function
|
|
4474
|
+
async function gate2(req, env, ctx) {
|
|
2804
4475
|
const rawDb = ctx.makeDb(env);
|
|
2805
4476
|
const u = await ctx.verifyUser(req, env);
|
|
2806
4477
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
2807
4478
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
2808
4479
|
return rawDb;
|
|
2809
4480
|
}
|
|
2810
|
-
var
|
|
4481
|
+
var rows3 = (v) => Array.isArray(v) ? v : [];
|
|
2811
4482
|
var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
2812
4483
|
if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
|
|
2813
|
-
const got = await
|
|
4484
|
+
const got = await gate2(req, env, ctx);
|
|
2814
4485
|
if (got instanceof Response) return got;
|
|
2815
4486
|
const db = got;
|
|
2816
4487
|
const now = Date.now();
|
|
@@ -2822,7 +4493,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2822
4493
|
db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
|
|
2823
4494
|
db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
|
|
2824
4495
|
]);
|
|
2825
|
-
const apps =
|
|
4496
|
+
const apps = rows3(appsRes.applications);
|
|
2826
4497
|
const applications = {
|
|
2827
4498
|
total: apps.length,
|
|
2828
4499
|
last7: apps.filter((a) => a.createdAt >= d7).length,
|
|
@@ -2834,7 +4505,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2834
4505
|
pipeline[s] = 0;
|
|
2835
4506
|
pipelineDelta[s] = 0;
|
|
2836
4507
|
}
|
|
2837
|
-
for (const r of
|
|
4508
|
+
for (const r of rows3(recsRes.crm_record)) {
|
|
2838
4509
|
const s = r.stage;
|
|
2839
4510
|
if (s in pipeline) {
|
|
2840
4511
|
pipeline[s] = (pipeline[s] ?? 0) + 1;
|
|
@@ -2842,7 +4513,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2842
4513
|
if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
|
|
2843
4514
|
}
|
|
2844
4515
|
}
|
|
2845
|
-
const meetings =
|
|
4516
|
+
const meetings = rows3(meetingsRes.meetings);
|
|
2846
4517
|
const appById = new Map(apps.map((a) => [a.id, a]));
|
|
2847
4518
|
const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
|
|
2848
4519
|
const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
|
|
@@ -2867,7 +4538,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2867
4538
|
if (sk) {
|
|
2868
4539
|
const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
|
|
2869
4540
|
if (subsRes.ok) {
|
|
2870
|
-
const subs =
|
|
4541
|
+
const subs = rows3(subsRes.body.data);
|
|
2871
4542
|
const subMs = (s) => (s.created ?? 0) * 1e3;
|
|
2872
4543
|
membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
|
|
2873
4544
|
revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
|
|
@@ -2887,7 +4558,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
|
|
|
2887
4558
|
};
|
|
2888
4559
|
var handleAdminBilling = async (req, url, env, ctx) => {
|
|
2889
4560
|
if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
|
|
2890
|
-
const got = await
|
|
4561
|
+
const got = await gate2(req, env, ctx);
|
|
2891
4562
|
if (got instanceof Response) return got;
|
|
2892
4563
|
const db = got;
|
|
2893
4564
|
const sk = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -2899,9 +4570,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2899
4570
|
]);
|
|
2900
4571
|
const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
|
|
2901
4572
|
if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
|
|
2902
|
-
const subById = new Map(
|
|
4573
|
+
const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
|
|
2903
4574
|
const billingRows = [];
|
|
2904
|
-
for (const a of
|
|
4575
|
+
for (const a of rows3(appsRes.applications)) {
|
|
2905
4576
|
if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
|
|
2906
4577
|
const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
|
|
2907
4578
|
const items = sub?.items?.data ?? [];
|
|
@@ -2945,7 +4616,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
|
|
|
2945
4616
|
|
|
2946
4617
|
// src/worker-routes-admin-lifecycle.ts
|
|
2947
4618
|
import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
|
|
2948
|
-
async function
|
|
4619
|
+
async function gate3(req, env, ctx) {
|
|
2949
4620
|
const rawDb = ctx.makeDb(env);
|
|
2950
4621
|
const u = await ctx.verifyUser(req, env);
|
|
2951
4622
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -2971,7 +4642,7 @@ async function loadGroup(db, id) {
|
|
|
2971
4642
|
var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
2972
4643
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
|
|
2973
4644
|
if (req.method !== "POST" || !m) return null;
|
|
2974
|
-
const got = await
|
|
4645
|
+
const got = await gate3(req, env, ctx);
|
|
2975
4646
|
if (got instanceof Response) return got;
|
|
2976
4647
|
const db = got;
|
|
2977
4648
|
const body = await readJson(req);
|
|
@@ -3012,7 +4683,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
|
|
|
3012
4683
|
var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
3013
4684
|
const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
|
|
3014
4685
|
if (req.method !== "POST" || !m) return null;
|
|
3015
|
-
const got = await
|
|
4686
|
+
const got = await gate3(req, env, ctx);
|
|
3016
4687
|
if (got instanceof Response) return got;
|
|
3017
4688
|
const db = got;
|
|
3018
4689
|
const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
|
|
@@ -3035,43 +4706,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
|
|
|
3035
4706
|
var handleAdminApprove = async (req, url, env, ctx) => {
|
|
3036
4707
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
|
|
3037
4708
|
if (req.method !== "POST" || !m) return null;
|
|
3038
|
-
const got = await
|
|
4709
|
+
const got = await gate3(req, env, ctx);
|
|
3039
4710
|
if (got instanceof Response) return got;
|
|
3040
4711
|
const db = got;
|
|
3041
4712
|
const id = m[1];
|
|
3042
4713
|
const app = await loadApp(db, id);
|
|
3043
4714
|
if (!app) return json({ error: "not found" }, 404);
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
3048
|
-
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
3049
|
-
let rolePromoted = false;
|
|
3050
|
-
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
3051
|
-
if (promoteTo !== false && sk) {
|
|
3052
|
-
let userId = app.clerkUserId || null;
|
|
3053
|
-
if (!userId) {
|
|
3054
|
-
const found = await clerkGetUserByEmail(sk, String(app.email));
|
|
3055
|
-
userId = found?.id ?? null;
|
|
3056
|
-
if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
|
|
3057
|
-
}
|
|
3058
|
-
if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
|
|
3059
|
-
}
|
|
3060
|
-
let emailLogged = false;
|
|
3061
|
-
const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
|
|
3062
|
-
if (send !== false && group) {
|
|
3063
|
-
const res = await sendTemplated(
|
|
3064
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
3065
|
-
{ group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
|
|
3066
|
-
);
|
|
3067
|
-
emailLogged = res.sent;
|
|
3068
|
-
}
|
|
3069
|
-
return json({ ok: true, status: target, rolePromoted, emailLogged });
|
|
4715
|
+
const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
|
|
4716
|
+
if (!result.ok) return json({ error: result.error }, result.status);
|
|
4717
|
+
return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
|
|
3070
4718
|
};
|
|
3071
4719
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
3072
4720
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
3073
4721
|
if (req.method !== "POST" || !m) return null;
|
|
3074
|
-
const got = await
|
|
4722
|
+
const got = await gate3(req, env, ctx);
|
|
3075
4723
|
if (got instanceof Response) return got;
|
|
3076
4724
|
const db = got;
|
|
3077
4725
|
const app = await loadApp(db, m[1]);
|
|
@@ -3103,7 +4751,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
3103
4751
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
3104
4752
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
3105
4753
|
if (req.method !== "PATCH" || !m) return null;
|
|
3106
|
-
const got = await
|
|
4754
|
+
const got = await gate3(req, env, ctx);
|
|
3107
4755
|
if (got instanceof Response) return got;
|
|
3108
4756
|
const db = got;
|
|
3109
4757
|
const id = m[1];
|
|
@@ -3131,7 +4779,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
3131
4779
|
};
|
|
3132
4780
|
|
|
3133
4781
|
// src/worker-routes-admin-comms.ts
|
|
3134
|
-
async function
|
|
4782
|
+
async function gate4(req, env, ctx) {
|
|
3135
4783
|
const rawDb = ctx.makeDb(env);
|
|
3136
4784
|
const u = await ctx.verifyUser(req, env);
|
|
3137
4785
|
if (!u) return json({ error: "unauthorized" }, 401);
|
|
@@ -3153,7 +4801,7 @@ var notifyDeps = (db, env) => ({
|
|
|
3153
4801
|
});
|
|
3154
4802
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
3155
4803
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
3156
|
-
const got = await
|
|
4804
|
+
const got = await gate4(req, env, ctx);
|
|
3157
4805
|
if (got instanceof Response) return got;
|
|
3158
4806
|
const db = got;
|
|
3159
4807
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
@@ -3194,11 +4842,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3194
4842
|
for (const key of EMAIL_TEMPLATE_NAMES) {
|
|
3195
4843
|
const t = templates[key];
|
|
3196
4844
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
3197
|
-
const
|
|
3198
|
-
if (!subject || !
|
|
4845
|
+
const text3 = typeof t?.text === "string" ? t.text : "";
|
|
4846
|
+
if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
|
|
3199
4847
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
|
|
3200
|
-
if (
|
|
3201
|
-
clean[key] = { subject, text:
|
|
4848
|
+
if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
|
|
4849
|
+
clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
|
|
3202
4850
|
}
|
|
3203
4851
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3204
4852
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -3215,7 +4863,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
3215
4863
|
};
|
|
3216
4864
|
var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
3217
4865
|
if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
|
|
3218
|
-
const got = await
|
|
4866
|
+
const got = await gate4(req, env, ctx);
|
|
3219
4867
|
if (got instanceof Response) return got;
|
|
3220
4868
|
const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
|
|
3221
4869
|
const sends = (emailLog ?? []).map((r) => ({
|
|
@@ -3232,7 +4880,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
|
|
|
3232
4880
|
};
|
|
3233
4881
|
var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
3234
4882
|
if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
|
|
3235
|
-
const got = await
|
|
4883
|
+
const got = await gate4(req, env, ctx);
|
|
3236
4884
|
if (got instanceof Response) return got;
|
|
3237
4885
|
const db = got;
|
|
3238
4886
|
let body;
|
|
@@ -3260,7 +4908,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
3260
4908
|
var handleAdminComms = async (req, url, env, ctx) => {
|
|
3261
4909
|
const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
|
|
3262
4910
|
if (req.method !== "GET" || !m) return null;
|
|
3263
|
-
const got = await
|
|
4911
|
+
const got = await gate4(req, env, ctx);
|
|
3264
4912
|
if (got instanceof Response) return got;
|
|
3265
4913
|
const db = got;
|
|
3266
4914
|
const appId = m[1];
|
|
@@ -3293,7 +4941,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
3293
4941
|
|
|
3294
4942
|
// src/worker-routes-network.ts
|
|
3295
4943
|
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
3296
|
-
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as
|
|
4944
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
3297
4945
|
|
|
3298
4946
|
// src/worker-network-fetch.ts
|
|
3299
4947
|
function targetFetcher(env, target) {
|
|
@@ -3312,7 +4960,7 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
3312
4960
|
// src/worker-network-push.ts
|
|
3313
4961
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
3314
4962
|
import { signFederatedRequest } from "@odla-ai/db";
|
|
3315
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4963
|
+
async function pushNetworkRecord(db, env, ctx, target, record3) {
|
|
3316
4964
|
const secret = await getVaultSecret(db, target.secretName);
|
|
3317
4965
|
const crmDeps3 = {
|
|
3318
4966
|
crm: ctx.chapter.crm,
|
|
@@ -3323,7 +4971,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3323
4971
|
if (!secret) {
|
|
3324
4972
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
3325
4973
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3326
|
-
recordId:
|
|
4974
|
+
recordId: record3.id,
|
|
3327
4975
|
targetId: target.id,
|
|
3328
4976
|
status: "failed",
|
|
3329
4977
|
payloadVersion: 2,
|
|
@@ -3333,7 +4981,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3333
4981
|
}
|
|
3334
4982
|
let payload;
|
|
3335
4983
|
try {
|
|
3336
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4984
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
|
|
3337
4985
|
} catch (err) {
|
|
3338
4986
|
return {
|
|
3339
4987
|
id: target.id,
|
|
@@ -3362,7 +5010,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3362
5010
|
if (!res.ok) {
|
|
3363
5011
|
const error = responseBody.error ?? "follower rejected the record";
|
|
3364
5012
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3365
|
-
recordId:
|
|
5013
|
+
recordId: record3.id,
|
|
3366
5014
|
targetId: target.id,
|
|
3367
5015
|
status: "failed",
|
|
3368
5016
|
payloadVersion: 2,
|
|
@@ -3377,7 +5025,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3377
5025
|
};
|
|
3378
5026
|
}
|
|
3379
5027
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3380
|
-
recordId:
|
|
5028
|
+
recordId: record3.id,
|
|
3381
5029
|
targetId: target.id,
|
|
3382
5030
|
status: "delivered",
|
|
3383
5031
|
payloadVersion: 2,
|
|
@@ -3393,7 +5041,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3393
5041
|
} catch (err) {
|
|
3394
5042
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
3395
5043
|
await recordDeliveryAttempt(crmDeps3, {
|
|
3396
|
-
recordId:
|
|
5044
|
+
recordId: record3.id,
|
|
3397
5045
|
targetId: target.id,
|
|
3398
5046
|
status: "failed",
|
|
3399
5047
|
payloadVersion: 2,
|
|
@@ -3404,7 +5052,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
|
|
|
3404
5052
|
}
|
|
3405
5053
|
|
|
3406
5054
|
// src/worker-routes-network.ts
|
|
3407
|
-
async function
|
|
5055
|
+
async function gate5(req, env, ctx) {
|
|
3408
5056
|
const db = ctx.makeDb(env);
|
|
3409
5057
|
const user = await ctx.verifyUser(req, env);
|
|
3410
5058
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3413,7 +5061,7 @@ async function gate4(req, env, ctx) {
|
|
|
3413
5061
|
}
|
|
3414
5062
|
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
3415
5063
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
3416
|
-
const got = await
|
|
5064
|
+
const got = await gate5(req, env, ctx);
|
|
3417
5065
|
if ("response" in got) return got.response;
|
|
3418
5066
|
return json({
|
|
3419
5067
|
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
|
|
@@ -3430,7 +5078,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
3430
5078
|
const db = ctx.makeDb(env);
|
|
3431
5079
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3432
5080
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
3433
|
-
const verified = await
|
|
5081
|
+
const verified = await verifyFederatedRequest5(req, { secret });
|
|
3434
5082
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3435
5083
|
const types = await Promise.all(
|
|
3436
5084
|
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -3489,7 +5137,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
3489
5137
|
}
|
|
3490
5138
|
var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
3491
5139
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
|
|
3492
|
-
const got = await
|
|
5140
|
+
const got = await gate5(req, env, ctx);
|
|
3493
5141
|
if ("response" in got) return got.response;
|
|
3494
5142
|
const targets = await Promise.all(
|
|
3495
5143
|
ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
|
|
@@ -3516,7 +5164,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
|
|
|
3516
5164
|
};
|
|
3517
5165
|
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
3518
5166
|
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
3519
|
-
const got = await
|
|
5167
|
+
const got = await gate5(req, env, ctx);
|
|
3520
5168
|
if ("response" in got) return got.response;
|
|
3521
5169
|
let body;
|
|
3522
5170
|
try {
|
|
@@ -3531,22 +5179,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
3531
5179
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
3532
5180
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
3533
5181
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
3534
|
-
const
|
|
3535
|
-
if (!
|
|
5182
|
+
const record3 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
5183
|
+
if (!record3) return json({ error: "record not found" }, 404);
|
|
3536
5184
|
const results = await Promise.all(
|
|
3537
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
5185
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
|
|
3538
5186
|
);
|
|
3539
5187
|
return json({ ok: results.every((result) => result.ok), results });
|
|
3540
5188
|
};
|
|
3541
5189
|
|
|
3542
5190
|
// src/worker-routes-network-records.ts
|
|
3543
5191
|
import { listRecords } from "@odla-ai/crm";
|
|
3544
|
-
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as
|
|
5192
|
+
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
3545
5193
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3546
5194
|
var MAX_PAGE = 50;
|
|
3547
5195
|
var MAX_OFFSET = 1e4;
|
|
3548
5196
|
var MAX_SEARCH = 200;
|
|
3549
|
-
async function
|
|
5197
|
+
async function gate6(req, env, ctx) {
|
|
3550
5198
|
const db = ctx.makeDb(env);
|
|
3551
5199
|
const user = await ctx.verifyUser(req, env);
|
|
3552
5200
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3559,18 +5207,18 @@ function integer(value, fallback, min, max) {
|
|
|
3559
5207
|
const parsed = Number(value);
|
|
3560
5208
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
3561
5209
|
}
|
|
3562
|
-
function projectRecord(
|
|
5210
|
+
function projectRecord(record3, allowed2) {
|
|
3563
5211
|
const fields = Object.fromEntries(
|
|
3564
|
-
|
|
5212
|
+
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
|
|
3565
5213
|
);
|
|
3566
5214
|
return {
|
|
3567
|
-
id:
|
|
3568
|
-
type:
|
|
3569
|
-
name:
|
|
3570
|
-
...
|
|
5215
|
+
id: record3.id,
|
|
5216
|
+
type: record3.type,
|
|
5217
|
+
name: record3.name,
|
|
5218
|
+
...record3.stage ? { stage: record3.stage } : {},
|
|
3571
5219
|
fields,
|
|
3572
|
-
createdAt:
|
|
3573
|
-
updatedAt:
|
|
5220
|
+
createdAt: record3.createdAt,
|
|
5221
|
+
updatedAt: record3.updatedAt
|
|
3574
5222
|
};
|
|
3575
5223
|
}
|
|
3576
5224
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -3579,15 +5227,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3579
5227
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3580
5228
|
const readers = ctx.chapter.network.readers;
|
|
3581
5229
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3582
|
-
const verified = await
|
|
5230
|
+
const verified = await verifyFederatedRequest6(req, {
|
|
3583
5231
|
secret,
|
|
3584
5232
|
senders: readers.map((reader2) => reader2.id)
|
|
3585
5233
|
});
|
|
3586
5234
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
3587
5235
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
3588
5236
|
const type = url.searchParams.get("type") ?? "";
|
|
3589
|
-
const
|
|
3590
|
-
if (!IDENTIFIER.test(type) || !
|
|
5237
|
+
const allowed2 = reader?.fields[type];
|
|
5238
|
+
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
3591
5239
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
3592
5240
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
3593
5241
|
const search = (url.searchParams.get("search") ?? "").trim();
|
|
@@ -3608,7 +5256,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3608
5256
|
version: 1,
|
|
3609
5257
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
3610
5258
|
type,
|
|
3611
|
-
records: page.records.map((
|
|
5259
|
+
records: page.records.map((record3) => projectRecord(record3, allowed2)),
|
|
3612
5260
|
total: page.total,
|
|
3613
5261
|
limit: page.limit,
|
|
3614
5262
|
offset: page.offset
|
|
@@ -3649,7 +5297,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
3649
5297
|
}
|
|
3650
5298
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
3651
5299
|
if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
|
|
3652
|
-
const got = await
|
|
5300
|
+
const got = await gate6(req, env, ctx);
|
|
3653
5301
|
if ("response" in got) return got.response;
|
|
3654
5302
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3655
5303
|
const type = url.searchParams.get("type") ?? "";
|
|
@@ -3676,8 +5324,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
3676
5324
|
// src/worker-routes-network-notes.ts
|
|
3677
5325
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3678
5326
|
var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3679
|
-
var
|
|
3680
|
-
async function
|
|
5327
|
+
var MAX_NOTE2 = 4e3;
|
|
5328
|
+
async function gate7(req, env, ctx) {
|
|
3681
5329
|
const db = ctx.makeDb(env);
|
|
3682
5330
|
const user = await ctx.verifyUser(req, env);
|
|
3683
5331
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3711,7 +5359,7 @@ function asNote(row) {
|
|
|
3711
5359
|
}
|
|
3712
5360
|
var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
3713
5361
|
if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3714
|
-
const got = await
|
|
5362
|
+
const got = await gate7(req, env, ctx);
|
|
3715
5363
|
if ("response" in got) return got.response;
|
|
3716
5364
|
const identity = noteIdentity(url, ctx);
|
|
3717
5365
|
if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
|
|
@@ -3728,7 +5376,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3728
5376
|
return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
|
|
3729
5377
|
}
|
|
3730
5378
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
3731
|
-
if (contentLength >
|
|
5379
|
+
if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
|
|
3732
5380
|
let input;
|
|
3733
5381
|
try {
|
|
3734
5382
|
input = await req.json();
|
|
@@ -3736,7 +5384,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
3736
5384
|
return json({ error: "invalid JSON body" }, 400);
|
|
3737
5385
|
}
|
|
3738
5386
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3739
|
-
if (!body || body.length >
|
|
5387
|
+
if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
|
|
3740
5388
|
const id = crypto.randomUUID();
|
|
3741
5389
|
const note = {
|
|
3742
5390
|
id,
|
|
@@ -3763,13 +5411,13 @@ import {
|
|
|
3763
5411
|
CRM_NS,
|
|
3764
5412
|
getRecord as getRecord2
|
|
3765
5413
|
} from "@odla-ai/crm";
|
|
3766
|
-
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as
|
|
5414
|
+
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
|
|
3767
5415
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
3768
5416
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
3769
5417
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
3770
|
-
var
|
|
5418
|
+
var MAX_NOTE3 = 4e3;
|
|
3771
5419
|
var NETWORK_META = "chapterNetwork";
|
|
3772
|
-
async function
|
|
5420
|
+
async function gate8(req, env, ctx) {
|
|
3773
5421
|
const db = ctx.makeDb(env);
|
|
3774
5422
|
const user = await ctx.verifyUser(req, env);
|
|
3775
5423
|
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
@@ -3815,14 +5463,14 @@ function sourceLabel(sourceId) {
|
|
|
3815
5463
|
}
|
|
3816
5464
|
var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3817
5465
|
if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3818
|
-
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) >
|
|
5466
|
+
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
|
|
3819
5467
|
return json({ error: "note is too large" }, 413);
|
|
3820
5468
|
}
|
|
3821
5469
|
const db = ctx.makeDb(env);
|
|
3822
5470
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
3823
5471
|
const readers = ctx.chapter.network.readers;
|
|
3824
5472
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
3825
|
-
const verified = await
|
|
5473
|
+
const verified = await verifyFederatedRequest7(req, {
|
|
3826
5474
|
secret,
|
|
3827
5475
|
senders: readers.map((reader2) => reader2.id)
|
|
3828
5476
|
});
|
|
@@ -3833,8 +5481,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3833
5481
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
3834
5482
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
3835
5483
|
}
|
|
3836
|
-
const
|
|
3837
|
-
if (!
|
|
5484
|
+
const record3 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
|
|
5485
|
+
if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
|
|
3838
5486
|
if (req.method === "GET") {
|
|
3839
5487
|
return json({
|
|
3840
5488
|
version: 1,
|
|
@@ -3850,8 +5498,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3850
5498
|
}
|
|
3851
5499
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
3852
5500
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
3853
|
-
if (!body || body.length >
|
|
3854
|
-
return json({ error: `body must be 1\u2013${
|
|
5501
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
|
|
5502
|
+
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
3855
5503
|
}
|
|
3856
5504
|
const createdAt = Date.now();
|
|
3857
5505
|
const id = `network:${verified.sender}:${mutationId}`;
|
|
@@ -3893,7 +5541,7 @@ function targetFor2(ctx, id) {
|
|
|
3893
5541
|
}
|
|
3894
5542
|
var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
3895
5543
|
if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
|
|
3896
|
-
const got = await
|
|
5544
|
+
const got = await gate8(req, env, ctx);
|
|
3897
5545
|
if ("response" in got) return got.response;
|
|
3898
5546
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
3899
5547
|
const recordType = url.searchParams.get("type") ?? "";
|
|
@@ -3911,7 +5559,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
3911
5559
|
return json({ error: "invalid JSON body" }, 400);
|
|
3912
5560
|
}
|
|
3913
5561
|
const note = typeof input.body === "string" ? input.body.trim() : "";
|
|
3914
|
-
if (!note || note.length >
|
|
5562
|
+
if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
|
|
3915
5563
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
3916
5564
|
}
|
|
3917
5565
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
@@ -3988,15 +5636,15 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
3988
5636
|
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
3989
5637
|
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
3990
5638
|
}
|
|
3991
|
-
const
|
|
3992
|
-
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !
|
|
5639
|
+
const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
5640
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
|
|
3993
5641
|
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
3994
5642
|
for (const field of formation.required) {
|
|
3995
5643
|
const value = body[field];
|
|
3996
5644
|
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
3997
5645
|
}
|
|
3998
5646
|
const input = {};
|
|
3999
|
-
for (const field of
|
|
5647
|
+
for (const field of allowed2) {
|
|
4000
5648
|
const value = body[field];
|
|
4001
5649
|
if (value === void 0) continue;
|
|
4002
5650
|
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
@@ -4064,6 +5712,56 @@ async function deliverSignupControl(options) {
|
|
|
4064
5712
|
return result;
|
|
4065
5713
|
}
|
|
4066
5714
|
|
|
5715
|
+
// src/member-content-delivery.ts
|
|
5716
|
+
import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
|
|
5717
|
+
async function deliverMemberContent(options) {
|
|
5718
|
+
const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
|
|
5719
|
+
const body = JSON.stringify(options.envelope);
|
|
5720
|
+
const signed = await signFederatedRequest6({
|
|
5721
|
+
secret: options.secret,
|
|
5722
|
+
sender: options.sender,
|
|
5723
|
+
method: "POST",
|
|
5724
|
+
url: destination,
|
|
5725
|
+
body
|
|
5726
|
+
});
|
|
5727
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5728
|
+
method: "POST",
|
|
5729
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5730
|
+
body
|
|
5731
|
+
});
|
|
5732
|
+
const result = await response.json();
|
|
5733
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5734
|
+
const failure = result;
|
|
5735
|
+
throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5736
|
+
}
|
|
5737
|
+
return result;
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
// src/admission-grants-delivery.ts
|
|
5741
|
+
import { signFederatedRequest as signFederatedRequest7 } from "@odla-ai/db";
|
|
5742
|
+
async function deliverAdmissionGrants(options) {
|
|
5743
|
+
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5744
|
+
const body = JSON.stringify(options.envelope);
|
|
5745
|
+
const signed = await signFederatedRequest7({
|
|
5746
|
+
secret: options.secret,
|
|
5747
|
+
sender: options.sender,
|
|
5748
|
+
method: "POST",
|
|
5749
|
+
url: destination,
|
|
5750
|
+
body
|
|
5751
|
+
});
|
|
5752
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5753
|
+
method: "POST",
|
|
5754
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5755
|
+
body
|
|
5756
|
+
});
|
|
5757
|
+
const result = await response.json();
|
|
5758
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5759
|
+
const failure = result;
|
|
5760
|
+
throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5761
|
+
}
|
|
5762
|
+
return result;
|
|
5763
|
+
}
|
|
5764
|
+
|
|
4067
5765
|
// src/signup-control-theme.ts
|
|
4068
5766
|
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
4069
5767
|
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
@@ -4203,11 +5901,15 @@ var BUILTIN_ROUTES = [
|
|
|
4203
5901
|
handleCrm,
|
|
4204
5902
|
handleNetworkShared,
|
|
4205
5903
|
handleSignupControl,
|
|
5904
|
+
handleMemberContentControl,
|
|
5905
|
+
handleAdmissionGrants,
|
|
4206
5906
|
handleNetworkSnapshot,
|
|
4207
5907
|
handleNetworkRecords,
|
|
4208
5908
|
handleNetworkSharedNotes,
|
|
4209
5909
|
handleFormation,
|
|
4210
5910
|
handleMember,
|
|
5911
|
+
handleMemberContent,
|
|
5912
|
+
handleGifts,
|
|
4211
5913
|
handleSchedule,
|
|
4212
5914
|
handlePayments,
|
|
4213
5915
|
handleAdminMeetings,
|
|
@@ -4217,10 +5919,12 @@ var BUILTIN_ROUTES = [
|
|
|
4217
5919
|
handleAdminPeopleAccess,
|
|
4218
5920
|
handleAdminPeopleRole,
|
|
4219
5921
|
handleAdminCrmSync,
|
|
5922
|
+
handleAdminAdmissionGrants,
|
|
4220
5923
|
handleAdminClerkPrivateProfileSync,
|
|
4221
5924
|
// Aggregation
|
|
4222
5925
|
handleAdminDashboard,
|
|
4223
5926
|
handleAdminBilling,
|
|
5927
|
+
handleAdminMemberContent,
|
|
4224
5928
|
// Lifecycle actions
|
|
4225
5929
|
handleAdminMeetingReschedule,
|
|
4226
5930
|
handleAdminMeetingCancel,
|
|
@@ -4261,6 +5965,8 @@ function chapterWorker(options) {
|
|
|
4261
5965
|
export {
|
|
4262
5966
|
chapterWorker,
|
|
4263
5967
|
createWorkerContext,
|
|
5968
|
+
deliverAdmissionGrants,
|
|
5969
|
+
deliverMemberContent,
|
|
4264
5970
|
deliverSignupControl,
|
|
4265
5971
|
renderActiveSignupControlHtml,
|
|
4266
5972
|
withActiveSignupControlTheme
|