@odla-ai/chapter 0.35.0 → 0.36.0

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