@odla-ai/chapter 0.37.0 → 0.38.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 +30 -2
- package/dist/index.cjs +305 -229
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -8
- package/dist/index.d.ts +55 -8
- package/dist/index.js +305 -229
- package/dist/index.js.map +1 -1
- package/dist/ui/member/index.d.ts +35 -1
- package/dist/worker/index.cjs +293 -225
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.js +293 -225
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import * as preact from 'preact';
|
|
|
2
2
|
import { ComponentChildren, JSX } from 'preact';
|
|
3
3
|
import { d as MemberContentProjection, e as MemberContentPageProjection, b as ChapterCopy, f as ChapterBrand } from '../../copy-context-Pbs3keLx.js';
|
|
4
4
|
export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-Pbs3keLx.js';
|
|
5
|
-
import '@odla-ai/crm';
|
|
5
|
+
import { FieldType, FieldConditions } from '@odla-ai/crm';
|
|
6
6
|
|
|
7
7
|
/** A bookable slot: a start instant in epoch milliseconds. */
|
|
8
8
|
interface Slot {
|
|
@@ -302,6 +302,38 @@ interface GiftOffer {
|
|
|
302
302
|
/** Provider-free gift offer shape rendered by Chapter and exact CMS previews. */
|
|
303
303
|
type PublicGiftOffer = Omit<GiftOffer, "stripePriceId" | "target">;
|
|
304
304
|
|
|
305
|
+
/** Current public schema version for Chapter flow-field contracts. */
|
|
306
|
+
declare const CHAPTER_FLOW_FIELDS_SCHEMA_VERSION: 1;
|
|
307
|
+
/** Configurable Chapter intake journeys that publish field contracts. */
|
|
308
|
+
type ChapterFieldFlow = "join" | "formation";
|
|
309
|
+
/** Email comparison policy advertised by an email field. */
|
|
310
|
+
type ChapterEmailIdentity = "literal";
|
|
311
|
+
/**
|
|
312
|
+
* One effective field in a Chapter intake journey.
|
|
313
|
+
*
|
|
314
|
+
* IDs, value types, and options come from the follower-declared schema. A hub
|
|
315
|
+
* or CMS may select, order, and configure only those declared fields; it cannot
|
|
316
|
+
* use this presentation document to add storage or widen follower authority.
|
|
317
|
+
*/
|
|
318
|
+
interface ChapterFlowField {
|
|
319
|
+
id: string;
|
|
320
|
+
label: string;
|
|
321
|
+
type: FieldType;
|
|
322
|
+
required: boolean;
|
|
323
|
+
order: number;
|
|
324
|
+
maxLength?: number;
|
|
325
|
+
options?: readonly string[];
|
|
326
|
+
conditions?: FieldConditions;
|
|
327
|
+
/** Present only for an email identity field. Aliases such as `+tag` remain significant. */
|
|
328
|
+
emailIdentity?: ChapterEmailIdentity;
|
|
329
|
+
}
|
|
330
|
+
/** Ordered, versioned effective field state for one Chapter journey. */
|
|
331
|
+
interface ChapterFlowFieldContract {
|
|
332
|
+
schemaVersion: typeof CHAPTER_FLOW_FIELDS_SCHEMA_VERSION;
|
|
333
|
+
flow: ChapterFieldFlow;
|
|
334
|
+
fields: readonly ChapterFlowField[];
|
|
335
|
+
}
|
|
336
|
+
|
|
305
337
|
/** The public join config (the shape `GET /api/join-config` returns). */
|
|
306
338
|
interface JoinConfig {
|
|
307
339
|
id: string;
|
|
@@ -314,6 +346,8 @@ interface JoinConfig {
|
|
|
314
346
|
/** Resolved copy for the packaged join flow. */
|
|
315
347
|
copy?: ChapterCopy["join"];
|
|
316
348
|
giftOffers?: readonly PublicGiftOffer[];
|
|
349
|
+
/** Exact ordered field state accepted by the effective server-side join flow. */
|
|
350
|
+
fieldContract?: ChapterFlowFieldContract;
|
|
317
351
|
}
|
|
318
352
|
/** Server-validated state used to resume a join journey after a redirect. */
|
|
319
353
|
type JoinFlowState = {
|
package/dist/worker/index.cjs
CHANGED
|
@@ -199,204 +199,10 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
199
199
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// src/
|
|
203
|
-
|
|
204
|
-
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
205
|
-
|
|
206
|
-
// src/signup-control-canonical.ts
|
|
207
|
-
var encoder = new TextEncoder();
|
|
208
|
-
function canonicalValue(value) {
|
|
209
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
210
|
-
return JSON.stringify(value);
|
|
211
|
-
}
|
|
212
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
213
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
214
|
-
const record3 = value;
|
|
215
|
-
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
216
|
-
const item = record3[key];
|
|
217
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
218
|
-
});
|
|
219
|
-
return `{${entries.join(",")}}`;
|
|
220
|
-
}
|
|
221
|
-
function canonicalSignupControl(value) {
|
|
222
|
-
return canonicalValue(value);
|
|
223
|
-
}
|
|
224
|
-
async function signupControlDigest(document) {
|
|
225
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
226
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// src/admission-grants.ts
|
|
230
|
-
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
231
|
-
var MAX_GRANTS = 2e3;
|
|
232
|
-
var MAX_NOTE = 2e3;
|
|
233
|
-
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
234
|
-
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
235
|
-
function normalizeAdmissionEmail(email) {
|
|
202
|
+
// src/email-identity.ts
|
|
203
|
+
function normalizeChapterEmail(email) {
|
|
236
204
|
return email.trim().toLowerCase();
|
|
237
205
|
}
|
|
238
|
-
function validateAdmissionGrantDocument(input) {
|
|
239
|
-
const errors = [];
|
|
240
|
-
const root = input && typeof input === "object" ? input : {};
|
|
241
|
-
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
242
|
-
const revision = Number(root.revision);
|
|
243
|
-
const generatedAt = Number(root.generatedAt);
|
|
244
|
-
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
245
|
-
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
246
|
-
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
247
|
-
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
248
|
-
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
249
|
-
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
250
|
-
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
251
|
-
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
252
|
-
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
253
|
-
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
254
|
-
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
255
|
-
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
256
|
-
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
257
|
-
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
258
|
-
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
259
|
-
const grants = [];
|
|
260
|
-
const seenIds = /* @__PURE__ */ new Set();
|
|
261
|
-
const seenEmails = /* @__PURE__ */ new Set();
|
|
262
|
-
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
263
|
-
const grant = raw && typeof raw === "object" ? raw : {};
|
|
264
|
-
const id = typeof grant.id === "string" ? grant.id : "";
|
|
265
|
-
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
266
|
-
const normalized = normalizeAdmissionEmail(email);
|
|
267
|
-
const decision = grant.decision;
|
|
268
|
-
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
269
|
-
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
270
|
-
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
271
|
-
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
272
|
-
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
273
|
-
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
274
|
-
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
275
|
-
errors.push(`document.grants[${index}].decision is invalid`);
|
|
276
|
-
}
|
|
277
|
-
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
278
|
-
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
279
|
-
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
280
|
-
}
|
|
281
|
-
seenIds.add(id);
|
|
282
|
-
seenEmails.add(normalized);
|
|
283
|
-
grants.push({
|
|
284
|
-
id,
|
|
285
|
-
email,
|
|
286
|
-
decision,
|
|
287
|
-
...note ? { note } : {},
|
|
288
|
-
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
if (errors.length) return { ok: false, errors };
|
|
292
|
-
return {
|
|
293
|
-
ok: true,
|
|
294
|
-
value: {
|
|
295
|
-
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
296
|
-
revision,
|
|
297
|
-
generatedAt,
|
|
298
|
-
source: { appId: sourceId, actor: sourceActor },
|
|
299
|
-
target: { chapterId, environment },
|
|
300
|
-
grants
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
var rows = (value) => Array.isArray(value) ? value : [];
|
|
305
|
-
function asGrant(row) {
|
|
306
|
-
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
307
|
-
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
308
|
-
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
309
|
-
return row;
|
|
310
|
-
}
|
|
311
|
-
async function findAdmissionGrant(db, targetId, email, now) {
|
|
312
|
-
const emailNormalized = normalizeAdmissionEmail(email);
|
|
313
|
-
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
314
|
-
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
315
|
-
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
316
|
-
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
317
|
-
)).sort((a, b) => {
|
|
318
|
-
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
319
|
-
return scope || b.createdAt - a.createdAt;
|
|
320
|
-
});
|
|
321
|
-
return active[0] ?? null;
|
|
322
|
-
}
|
|
323
|
-
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
324
|
-
const where = { sourceId, targetId, environment };
|
|
325
|
-
if (db.aggregate) {
|
|
326
|
-
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
327
|
-
}
|
|
328
|
-
const found = await db.query({ admissionGrantRevisions: {} });
|
|
329
|
-
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
330
|
-
}
|
|
331
|
-
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
332
|
-
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
333
|
-
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
334
|
-
where: { sourceId, targetId, environment },
|
|
335
|
-
order: { revision: "desc" },
|
|
336
|
-
limit: 1
|
|
337
|
-
} } });
|
|
338
|
-
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
339
|
-
return row ? row : null;
|
|
340
|
-
}
|
|
341
|
-
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
342
|
-
const { document, digest } = envelope;
|
|
343
|
-
const current = rows((await db.query({ admissionGrants: { $: {
|
|
344
|
-
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
345
|
-
limit: MAX_GRANTS
|
|
346
|
-
} } })).admissionGrants);
|
|
347
|
-
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
348
|
-
const ops = [];
|
|
349
|
-
for (const row of current) {
|
|
350
|
-
const id = String(row.id ?? "");
|
|
351
|
-
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
352
|
-
active: false,
|
|
353
|
-
revokedAt: receivedAt,
|
|
354
|
-
revokedBy: document.source.actor,
|
|
355
|
-
updatedAt: receivedAt,
|
|
356
|
-
updatedBy: document.source.actor
|
|
357
|
-
} });
|
|
358
|
-
}
|
|
359
|
-
for (const grant of document.grants) {
|
|
360
|
-
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
361
|
-
const existing = current.find((row) => row.id === id);
|
|
362
|
-
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
363
|
-
id,
|
|
364
|
-
sourceGrantId: grant.id,
|
|
365
|
-
sourceScope: "parent",
|
|
366
|
-
sourceId: document.source.appId,
|
|
367
|
-
targetId: document.target.chapterId,
|
|
368
|
-
email: grant.email,
|
|
369
|
-
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
370
|
-
decision: grant.decision,
|
|
371
|
-
...grant.note ? { note: grant.note } : {},
|
|
372
|
-
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
373
|
-
active: true,
|
|
374
|
-
sourceRevision: document.revision,
|
|
375
|
-
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
376
|
-
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
377
|
-
updatedAt: receivedAt,
|
|
378
|
-
updatedBy: document.source.actor
|
|
379
|
-
} });
|
|
380
|
-
const retract = [
|
|
381
|
-
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
382
|
-
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
383
|
-
];
|
|
384
|
-
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
385
|
-
}
|
|
386
|
-
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
387
|
-
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
388
|
-
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
389
|
-
id: revisionId,
|
|
390
|
-
sourceId: document.source.appId,
|
|
391
|
-
targetId: document.target.chapterId,
|
|
392
|
-
environment: document.target.environment,
|
|
393
|
-
revision: document.revision,
|
|
394
|
-
digest,
|
|
395
|
-
active: true,
|
|
396
|
-
receivedAt
|
|
397
|
-
} });
|
|
398
|
-
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
399
|
-
}
|
|
400
206
|
|
|
401
207
|
// src/member.ts
|
|
402
208
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
@@ -449,9 +255,9 @@ function resolveApplication(a) {
|
|
|
449
255
|
validateEmail: a?.validateEmail ?? true
|
|
450
256
|
};
|
|
451
257
|
}
|
|
452
|
-
var
|
|
258
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
453
259
|
function isValidEmail(value) {
|
|
454
|
-
return typeof value === "string" &&
|
|
260
|
+
return typeof value === "string" && EMAIL_RE.test(value);
|
|
455
261
|
}
|
|
456
262
|
function clampArray(value, max) {
|
|
457
263
|
if (!Array.isArray(value)) return value;
|
|
@@ -486,7 +292,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
486
292
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
487
293
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
488
294
|
}
|
|
489
|
-
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
|
|
295
|
+
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
|
|
490
296
|
return { ok: false, error: "email must be a valid email address" };
|
|
491
297
|
}
|
|
492
298
|
const acked = hasDisclaimerAck(fields);
|
|
@@ -502,7 +308,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
502
308
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
503
309
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
504
310
|
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
505
|
-
row.emailNormalized =
|
|
311
|
+
row.emailNormalized = normalizeChapterEmail(fields.email);
|
|
506
312
|
}
|
|
507
313
|
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
508
314
|
row.admissionGrantId = opts.admissionGrant.id;
|
|
@@ -730,6 +536,281 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
730
536
|
};
|
|
731
537
|
}
|
|
732
538
|
|
|
539
|
+
// src/admission-grants-contract.ts
|
|
540
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
541
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
542
|
+
|
|
543
|
+
// src/signup-control-canonical.ts
|
|
544
|
+
var encoder = new TextEncoder();
|
|
545
|
+
function canonicalValue(value) {
|
|
546
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
547
|
+
return JSON.stringify(value);
|
|
548
|
+
}
|
|
549
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
550
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
551
|
+
const record3 = value;
|
|
552
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
553
|
+
const item = record3[key];
|
|
554
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
555
|
+
});
|
|
556
|
+
return `{${entries.join(",")}}`;
|
|
557
|
+
}
|
|
558
|
+
function canonicalSignupControl(value) {
|
|
559
|
+
return canonicalValue(value);
|
|
560
|
+
}
|
|
561
|
+
async function signupControlDigest(document) {
|
|
562
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
563
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/admission-grants.ts
|
|
567
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
568
|
+
var MAX_GRANTS = 2e3;
|
|
569
|
+
var MAX_NOTE = 2e3;
|
|
570
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
571
|
+
var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
|
|
572
|
+
function normalizeAdmissionEmail(email) {
|
|
573
|
+
return normalizeChapterEmail(email);
|
|
574
|
+
}
|
|
575
|
+
function validateAdmissionGrantDocument(input) {
|
|
576
|
+
const errors = [];
|
|
577
|
+
const root = input && typeof input === "object" ? input : {};
|
|
578
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
579
|
+
const revision = Number(root.revision);
|
|
580
|
+
const generatedAt = Number(root.generatedAt);
|
|
581
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
582
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
583
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
584
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
585
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
586
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
587
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
588
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
589
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
590
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
591
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
592
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
593
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
594
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
595
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
596
|
+
const grants = [];
|
|
597
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
598
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
599
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
600
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
601
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
602
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
603
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
604
|
+
const decision = grant.decision;
|
|
605
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
606
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
607
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
608
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
609
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
610
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
611
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
612
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
613
|
+
}
|
|
614
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
615
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
616
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
617
|
+
}
|
|
618
|
+
seenIds.add(id);
|
|
619
|
+
seenEmails.add(normalized);
|
|
620
|
+
grants.push({
|
|
621
|
+
id,
|
|
622
|
+
email,
|
|
623
|
+
decision,
|
|
624
|
+
...note ? { note } : {},
|
|
625
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (errors.length) return { ok: false, errors };
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
value: {
|
|
632
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
633
|
+
revision,
|
|
634
|
+
generatedAt,
|
|
635
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
636
|
+
target: { chapterId, environment },
|
|
637
|
+
grants
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
642
|
+
function asGrant(row) {
|
|
643
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
644
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
645
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
646
|
+
return row;
|
|
647
|
+
}
|
|
648
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
649
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
650
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
651
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
652
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
653
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
654
|
+
)).sort((a, b) => {
|
|
655
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
656
|
+
return scope || b.createdAt - a.createdAt;
|
|
657
|
+
});
|
|
658
|
+
return active[0] ?? null;
|
|
659
|
+
}
|
|
660
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
661
|
+
const where = { sourceId, targetId, environment };
|
|
662
|
+
if (db.aggregate) {
|
|
663
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
664
|
+
}
|
|
665
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
666
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
667
|
+
}
|
|
668
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
669
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
670
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
671
|
+
where: { sourceId, targetId, environment },
|
|
672
|
+
order: { revision: "desc" },
|
|
673
|
+
limit: 1
|
|
674
|
+
} } });
|
|
675
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
676
|
+
return row ? row : null;
|
|
677
|
+
}
|
|
678
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
679
|
+
const { document, digest } = envelope;
|
|
680
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
681
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
682
|
+
limit: MAX_GRANTS
|
|
683
|
+
} } })).admissionGrants);
|
|
684
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
685
|
+
const ops = [];
|
|
686
|
+
for (const row of current) {
|
|
687
|
+
const id = String(row.id ?? "");
|
|
688
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
689
|
+
active: false,
|
|
690
|
+
revokedAt: receivedAt,
|
|
691
|
+
revokedBy: document.source.actor,
|
|
692
|
+
updatedAt: receivedAt,
|
|
693
|
+
updatedBy: document.source.actor
|
|
694
|
+
} });
|
|
695
|
+
}
|
|
696
|
+
for (const grant of document.grants) {
|
|
697
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
698
|
+
const existing = current.find((row) => row.id === id);
|
|
699
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
700
|
+
id,
|
|
701
|
+
sourceGrantId: grant.id,
|
|
702
|
+
sourceScope: "parent",
|
|
703
|
+
sourceId: document.source.appId,
|
|
704
|
+
targetId: document.target.chapterId,
|
|
705
|
+
email: grant.email,
|
|
706
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
707
|
+
decision: grant.decision,
|
|
708
|
+
...grant.note ? { note: grant.note } : {},
|
|
709
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
710
|
+
active: true,
|
|
711
|
+
sourceRevision: document.revision,
|
|
712
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
713
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
714
|
+
updatedAt: receivedAt,
|
|
715
|
+
updatedBy: document.source.actor
|
|
716
|
+
} });
|
|
717
|
+
const retract = [
|
|
718
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
719
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
720
|
+
];
|
|
721
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
722
|
+
}
|
|
723
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
724
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
725
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
726
|
+
id: revisionId,
|
|
727
|
+
sourceId: document.source.appId,
|
|
728
|
+
targetId: document.target.chapterId,
|
|
729
|
+
environment: document.target.environment,
|
|
730
|
+
revision: document.revision,
|
|
731
|
+
digest,
|
|
732
|
+
active: true,
|
|
733
|
+
receivedAt
|
|
734
|
+
} });
|
|
735
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/flow-fields.ts
|
|
739
|
+
var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
|
|
740
|
+
var APPLICATION_LABELS = {
|
|
741
|
+
firstName: "First name",
|
|
742
|
+
lastName: "Last name",
|
|
743
|
+
email: "Email",
|
|
744
|
+
referral: "Referral source",
|
|
745
|
+
referralName: "Referral name",
|
|
746
|
+
whoYouAre: "Who you are",
|
|
747
|
+
linkedin: "LinkedIn",
|
|
748
|
+
message: "Intro message",
|
|
749
|
+
phone: "Phone",
|
|
750
|
+
state: "State"
|
|
751
|
+
};
|
|
752
|
+
function humanize(id) {
|
|
753
|
+
const text3 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
|
|
754
|
+
return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id;
|
|
755
|
+
}
|
|
756
|
+
function applicationDefs(chapter) {
|
|
757
|
+
try {
|
|
758
|
+
return chapter.crm.type("person").fields;
|
|
759
|
+
} catch {
|
|
760
|
+
return {};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function applicationType(id, def) {
|
|
764
|
+
if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
|
|
765
|
+
return id === "email" ? "email" : id === "linkedin" ? "url" : "string";
|
|
766
|
+
}
|
|
767
|
+
function applicationFlowFields(chapter, application = chapter.application) {
|
|
768
|
+
const defs = applicationDefs(chapter);
|
|
769
|
+
return [...application.required, ...application.optional].map((id, order) => {
|
|
770
|
+
const def = defs[id];
|
|
771
|
+
const conditions = application.conditions[id];
|
|
772
|
+
const type = applicationType(id, def);
|
|
773
|
+
return {
|
|
774
|
+
id,
|
|
775
|
+
label: def?.label ?? APPLICATION_LABELS[id] ?? humanize(id),
|
|
776
|
+
type,
|
|
777
|
+
required: application.required.includes(id),
|
|
778
|
+
order,
|
|
779
|
+
maxLength: application.maxLen[id] ?? application.defaultMaxLen,
|
|
780
|
+
...type === "enum" && def?.options ? { options: [...def.options] } : {},
|
|
781
|
+
...conditions ? { conditions: { ...conditions } } : {},
|
|
782
|
+
...type === "email" ? { emailIdentity: "literal" } : {}
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function resolveFormationFlowFields(crm, formation) {
|
|
787
|
+
if (!formation.enabled) return [];
|
|
788
|
+
const def = crm.type(formation.type);
|
|
789
|
+
return [...formation.required, ...formation.optional].map((id, order) => {
|
|
790
|
+
const field = def.fields[id];
|
|
791
|
+
return {
|
|
792
|
+
id,
|
|
793
|
+
label: field.label ?? humanize(id),
|
|
794
|
+
type: field.type,
|
|
795
|
+
required: formation.required.includes(id),
|
|
796
|
+
order,
|
|
797
|
+
maxLength: formation.maxLen[id] ?? formation.defaultMaxLen,
|
|
798
|
+
...field.options ? { options: [...field.options] } : {},
|
|
799
|
+
...id === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
function formationFlowFields(chapter, formation = chapter.formation) {
|
|
804
|
+
return resolveFormationFlowFields(chapter.crm, formation);
|
|
805
|
+
}
|
|
806
|
+
function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
|
|
807
|
+
return {
|
|
808
|
+
schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
|
|
809
|
+
flow,
|
|
810
|
+
fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
733
814
|
// src/gift-offers.ts
|
|
734
815
|
var GIFT_OFFER_CATALOG_VERSION = 1;
|
|
735
816
|
var MAX_GIFT_OFFERS = 20;
|
|
@@ -1735,7 +1816,7 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1735
1816
|
// src/worker-routes.ts
|
|
1736
1817
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1737
1818
|
let apps = (await db.query({ applications: { $: {
|
|
1738
|
-
where: { emailNormalized:
|
|
1819
|
+
where: { emailNormalized: normalizeChapterEmail(email) },
|
|
1739
1820
|
limit: 20
|
|
1740
1821
|
} } })).applications;
|
|
1741
1822
|
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
@@ -1834,6 +1915,7 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1834
1915
|
const application = controlledApplication(chapter.application, activeControl?.document);
|
|
1835
1916
|
return json({
|
|
1836
1917
|
...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
|
|
1918
|
+
fieldContract: chapterFlowFieldContract(chapter, "join", application),
|
|
1837
1919
|
copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
|
|
1838
1920
|
...activeControl ? { signupControl: {
|
|
1839
1921
|
revision: activeControl.revision,
|
|
@@ -3077,7 +3159,7 @@ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
3077
3159
|
return json({ error: "invalid JSON body" }, 400);
|
|
3078
3160
|
}
|
|
3079
3161
|
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
3080
|
-
const emailNormalized =
|
|
3162
|
+
const emailNormalized = normalizeChapterEmail(email);
|
|
3081
3163
|
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
3082
3164
|
const decision = body.decision ?? "waive_interview";
|
|
3083
3165
|
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
@@ -3579,7 +3661,7 @@ async function rotateGiftClaim(db, claim, now) {
|
|
|
3579
3661
|
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
3580
3662
|
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
3581
3663
|
const chapterId = String(claim.chapterId);
|
|
3582
|
-
const emailNormalized =
|
|
3664
|
+
const emailNormalized = normalizeChapterEmail(beneficiary.email);
|
|
3583
3665
|
const target = claim.target;
|
|
3584
3666
|
const ops = [];
|
|
3585
3667
|
let result;
|
|
@@ -3876,7 +3958,7 @@ async function startSubscription(req, env, ctx) {
|
|
|
3876
3958
|
offer,
|
|
3877
3959
|
purchaserType: "application",
|
|
3878
3960
|
purchaserEmail: String(app.email ?? ""),
|
|
3879
|
-
purchaserEmailNormalized:
|
|
3961
|
+
purchaserEmailNormalized: normalizeChapterEmail(String(app.email ?? "")),
|
|
3880
3962
|
purchaserApplicationId: applicationId,
|
|
3881
3963
|
stripeSecret: secretKey,
|
|
3882
3964
|
publishableKey: group.stripePublishableKey,
|
|
@@ -4025,7 +4107,7 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
4025
4107
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4026
4108
|
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4027
4109
|
const found = await db.query({ giftClaims: { $: { where: {
|
|
4028
|
-
purchaserEmailNormalized:
|
|
4110
|
+
purchaserEmailNormalized: normalizeChapterEmail(user.email),
|
|
4029
4111
|
chapterId: ctx.chapter.id
|
|
4030
4112
|
}, limit: 200 } } });
|
|
4031
4113
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
@@ -4052,7 +4134,7 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
4052
4134
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4053
4135
|
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
4054
4136
|
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
4055
|
-
const emailNormalized =
|
|
4137
|
+
const emailNormalized = normalizeChapterEmail(user.email);
|
|
4056
4138
|
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
4057
4139
|
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
4058
4140
|
try {
|
|
@@ -4094,7 +4176,7 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
4094
4176
|
return invalid();
|
|
4095
4177
|
}
|
|
4096
4178
|
}
|
|
4097
|
-
const owns = user ? claim.purchaserEmailNormalized ===
|
|
4179
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeChapterEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
4098
4180
|
if (!owns) return invalid();
|
|
4099
4181
|
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
4100
4182
|
return rotated ? json(rotated) : invalid();
|
|
@@ -5618,31 +5700,17 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5618
5700
|
|
|
5619
5701
|
// src/worker-routes-formation.ts
|
|
5620
5702
|
var import_crm9 = require("@odla-ai/crm");
|
|
5621
|
-
|
|
5622
|
-
// src/formation.ts
|
|
5623
|
-
function formationFields(crm, formation) {
|
|
5624
|
-
const def = crm.type(formation.type);
|
|
5625
|
-
return [...formation.required, ...formation.optional].map((id) => {
|
|
5626
|
-
const field = def.fields[id];
|
|
5627
|
-
return {
|
|
5628
|
-
id,
|
|
5629
|
-
label: field.label ?? id,
|
|
5630
|
-
type: field.type,
|
|
5631
|
-
required: formation.required.includes(id),
|
|
5632
|
-
...field.options ? { options: field.options } : {}
|
|
5633
|
-
};
|
|
5634
|
-
});
|
|
5635
|
-
}
|
|
5636
|
-
|
|
5637
|
-
// src/worker-routes-formation.ts
|
|
5638
5703
|
var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
|
|
5639
5704
|
var handleFormation = async (req, url, env, ctx) => {
|
|
5640
5705
|
const formation = ctx.chapter.formation;
|
|
5641
5706
|
if (!formation.enabled) return null;
|
|
5642
5707
|
if (req.method === "GET" && url.pathname === "/api/formation/config") {
|
|
5708
|
+
const fieldContract = chapterFlowFieldContract(ctx.chapter, "formation");
|
|
5643
5709
|
return json({
|
|
5644
5710
|
type: formation.type,
|
|
5645
|
-
|
|
5711
|
+
fieldContract,
|
|
5712
|
+
// Backward compatibility for hosts that consumed the original array.
|
|
5713
|
+
fields: fieldContract.fields
|
|
5646
5714
|
});
|
|
5647
5715
|
}
|
|
5648
5716
|
if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
|