@odla-ai/chapter 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -64,6 +64,8 @@ var applications = {
64
64
  phone: attr("string", { optional: true }),
65
65
  state: attr("string", { optional: true }),
66
66
  groupId: attr("string", { indexed: true, optional: true }),
67
+ // The tier this application joined on. Absent on rows that predate tiers.
68
+ tierId: attr("string", { indexed: true, optional: true }),
67
69
  stripeCustomerId: attr("string", { indexed: true, optional: true }),
68
70
  stripeSubscriptionId: attr("string", { indexed: true, optional: true }),
69
71
  renewalAt: attr("number", { optional: true }),
@@ -95,6 +97,19 @@ var groups = {
95
97
  createdAt: attr("number", { indexed: true })
96
98
  }
97
99
  };
100
+ var tiers = {
101
+ attrs: {
102
+ id: id(),
103
+ groupId: attr("string", { indexed: true }),
104
+ name: attr("string"),
105
+ priceCents: attr("number"),
106
+ stripePriceId: attr("string", { optional: true }),
107
+ blurb: attr("string", { optional: true }),
108
+ sortOrder: attr("number", { indexed: true }),
109
+ active: attr("boolean", { indexed: true }),
110
+ createdAt: attr("number", { indexed: true })
111
+ }
112
+ };
98
113
  var meetings = {
99
114
  attrs: {
100
115
  id: id(),
@@ -136,6 +151,7 @@ function chapterDb(mode, auth, includeNetworkNotes = false) {
136
151
  if (mode === "chapter") {
137
152
  entities.applications = applications;
138
153
  entities.groups = groups;
154
+ entities.tiers = tiers;
139
155
  entities.meetings = meetings;
140
156
  entities.emailLog = emailLog;
141
157
  }
@@ -396,6 +412,9 @@ function canApprove(status, p) {
396
412
  return p.approvableFrom.includes(status);
397
413
  }
398
414
 
415
+ // src/member.ts
416
+ import { assertFieldCondition, resolveFieldStates } from "@odla-ai/crm";
417
+
399
418
  // src/application-id.ts
400
419
  var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
401
420
  async function applicationIdForSubmission(submissionId) {
@@ -424,9 +443,21 @@ function resolveApplication(a) {
424
443
  if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
425
444
  throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
426
445
  }
446
+ const conditions = a?.conditions ?? {};
447
+ for (const [field, declared] of Object.entries(conditions)) {
448
+ for (const key of ["visibleWhen", "requiredWhen"]) {
449
+ const expression = declared?.[key];
450
+ if (expression === void 0) continue;
451
+ if (typeof expression !== "string" || !expression.trim()) {
452
+ throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
453
+ }
454
+ assertFieldCondition(expression, `defineChapter.application.conditions.${field}.${key}`);
455
+ }
456
+ }
427
457
  return {
428
458
  required,
429
459
  optional,
460
+ conditions,
430
461
  maxLen: a?.maxLen ?? {},
431
462
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
432
463
  bodyCap: a?.bodyCap ?? 32768,
@@ -463,7 +494,9 @@ function applicantProfile(chapter, fields) {
463
494
  }
464
495
  async function submitApplication(db, chapter, fields, opts) {
465
496
  const app = chapter.application;
466
- for (const f of app.required) {
497
+ const fieldStates = resolveFieldStates(app.conditions ?? {}, fields, app.required);
498
+ for (const [f, state] of Object.entries(fieldStates)) {
499
+ if (!state.required) continue;
467
500
  const v = fields[f];
468
501
  if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
469
502
  }
@@ -482,10 +515,17 @@ async function submitApplication(db, chapter, fields, opts) {
482
515
  const id2 = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
483
516
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
484
517
  for (const f of [...app.required, ...app.optional]) {
518
+ if (fieldStates[f]?.visible === false) continue;
485
519
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
486
520
  }
487
521
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
488
522
  if (opts.groupId) row.groupId = opts.groupId;
523
+ if (typeof fields.tierId === "string" && fields.tierId) {
524
+ if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
525
+ return { ok: false, error: "tierId is not an offered tier" };
526
+ }
527
+ row.tierId = fields.tierId;
528
+ }
489
529
  if (acked) row.disclaimerAckAt = opts.now;
490
530
  const { duplicate } = await db.transact(
491
531
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
@@ -493,12 +533,15 @@ async function submitApplication(db, chapter, fields, opts) {
493
533
  );
494
534
  return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
495
535
  }
496
- function joinConfig(group, paymentsReady2) {
536
+ function joinConfig(group, paymentsReady2, tiers2 = [], conditions = {}) {
497
537
  return {
498
538
  id: group.id,
499
539
  name: group.name,
500
540
  standardPriceCents: group.standardPriceCents ?? 0,
501
541
  foundingDiscountCents: group.foundingDiscountCents ?? 0,
542
+ tiers: [...tiers2],
543
+ // The browser evaluates the same conditions the server enforces.
544
+ conditions,
502
545
  disclaimerText: group.disclaimerText ?? "",
503
546
  refundPolicyText: group.refundPolicyText ?? "",
504
547
  trustCopy: group.trustCopy ?? "",
@@ -1293,6 +1336,62 @@ function leaderCrmConfig(options = {}) {
1293
1336
 
1294
1337
  // src/descriptor.ts
1295
1338
  import { createCrmIntegration } from "@odla-ai/crm";
1339
+
1340
+ // src/tiers.ts
1341
+ function buildTierSeeds(groupId, configured = [], now = 0) {
1342
+ return configured.map((tier, index) => ({
1343
+ id: tier.id,
1344
+ groupId,
1345
+ name: tier.name,
1346
+ priceCents: tier.priceCents,
1347
+ ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
1348
+ ...tier.blurb ? { blurb: tier.blurb } : {},
1349
+ sortOrder: tier.sortOrder ?? index,
1350
+ active: tier.active !== false,
1351
+ createdAt: now
1352
+ }));
1353
+ }
1354
+ function tierIsFree(tier) {
1355
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
1356
+ }
1357
+ function tierPayable(tier, group, hasSecretKey) {
1358
+ if (tierIsFree(tier)) return true;
1359
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
1360
+ }
1361
+ function byOrder(a, b) {
1362
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
1363
+ }
1364
+ function resolveTiers(group, rows = []) {
1365
+ const stored = rows.filter((tier) => tier.active);
1366
+ if (stored.length) return [...stored].sort(byOrder);
1367
+ return [{
1368
+ id: "standard",
1369
+ groupId: String(group.id ?? ""),
1370
+ name: "Membership",
1371
+ priceCents: group.standardPriceCents ?? 0,
1372
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
1373
+ sortOrder: 0,
1374
+ active: true
1375
+ }];
1376
+ }
1377
+ function offerableTiers(tiers2, group, hasSecretKey) {
1378
+ return tiers2.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
1379
+ }
1380
+ function findTier(tiers2, tierId) {
1381
+ if (!tierId) return tiers2.length === 1 ? tiers2[0] : null;
1382
+ return tiers2.find((tier) => tier.id === tierId) ?? null;
1383
+ }
1384
+ function joinConfigTiers(tiers2) {
1385
+ return tiers2.map((tier) => ({
1386
+ id: tier.id,
1387
+ name: tier.name,
1388
+ priceCents: tier.priceCents,
1389
+ blurb: tier.blurb ?? "",
1390
+ free: tierIsFree(tier)
1391
+ }));
1392
+ }
1393
+
1394
+ // src/descriptor.ts
1296
1395
  function createChapterIntegration(chapter, options = {}) {
1297
1396
  const basePath = options.basePath ?? "/api/crm";
1298
1397
  const now = options.now ?? Date.now();
@@ -1308,6 +1407,14 @@ function createChapterIntegration(chapter, options = {}) {
1308
1407
  const group = chapter.groupSeed();
1309
1408
  if (group) {
1310
1409
  seeds.push({ id: "group", ns: "groups", key: { attr: "id", value: chapter.id }, attrs: { ...group, createdAt: now } });
1410
+ for (const tier of buildTierSeeds(chapter.id, chapter.config.tiers, now)) {
1411
+ seeds.push({
1412
+ id: `tier:${String(tier.id)}`,
1413
+ ns: "tiers",
1414
+ key: { attr: "id", value: String(tier.id) },
1415
+ attrs: tier
1416
+ });
1417
+ }
1311
1418
  }
1312
1419
  const isLeader = (chapter.config.network?.targets?.length ?? 0) > 0;
1313
1420
  const runbooks = isLeader ? {
@@ -2384,6 +2491,38 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
2384
2491
  ...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
2385
2492
  };
2386
2493
  }
2494
+
2495
+ // src/tiers-store.ts
2496
+ function rowToTier(row, groupId) {
2497
+ return {
2498
+ id: String(row.id ?? ""),
2499
+ groupId: String(row.groupId ?? groupId),
2500
+ name: String(row.name ?? ""),
2501
+ priceCents: typeof row.priceCents === "number" ? row.priceCents : 0,
2502
+ ...typeof row.stripePriceId === "string" && row.stripePriceId ? { stripePriceId: row.stripePriceId } : {},
2503
+ ...typeof row.blurb === "string" ? { blurb: row.blurb } : {},
2504
+ sortOrder: typeof row.sortOrder === "number" ? row.sortOrder : 0,
2505
+ // A row that never set `active` is offered; only an explicit false retires
2506
+ // a tier, so a half-seeded row cannot silently hide a membership.
2507
+ active: row.active !== false
2508
+ };
2509
+ }
2510
+ async function loadTiers(db, group) {
2511
+ const groupId = String(group.id ?? "");
2512
+ let rows;
2513
+ try {
2514
+ rows = (await db.query({
2515
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
2516
+ })).tiers;
2517
+ } catch {
2518
+ rows = void 0;
2519
+ }
2520
+ const list = Array.isArray(rows) ? rows.map((row) => rowToTier(row, groupId)) : [];
2521
+ return resolveTiers(group, list);
2522
+ }
2523
+ async function loadOfferableTiers(db, group, hasSecretKey) {
2524
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2525
+ }
2387
2526
  export {
2388
2527
  DEFAULT_CHAPTER_COPY,
2389
2528
  DEFAULT_SHARE_FIELDS,
@@ -2396,6 +2535,7 @@ export {
2396
2535
  brandTokens,
2397
2536
  bucketSeries,
2398
2537
  buildGroupSeed,
2538
+ buildTierSeeds,
2399
2539
  canApprove,
2400
2540
  canBook,
2401
2541
  canChangeRole,
@@ -2420,6 +2560,7 @@ export {
2420
2560
  emailGroupFrom,
2421
2561
  endForSlot,
2422
2562
  findApplicationRef,
2563
+ findTier,
2423
2564
  firstPaymentPatch,
2424
2565
  formatChapterCopy,
2425
2566
  formationFields,
@@ -2432,7 +2573,10 @@ export {
2432
2573
  isSlotAvailable,
2433
2574
  isValidEmail,
2434
2575
  joinConfig,
2576
+ joinConfigTiers,
2435
2577
  leaderCrmConfig,
2578
+ loadOfferableTiers,
2579
+ loadTiers,
2436
2580
  meetingCreateRow,
2437
2581
  meetingRescheduleUpdate,
2438
2582
  memberApplication,
@@ -2440,6 +2584,7 @@ export {
2440
2584
  networkSourceTag,
2441
2585
  normalizeSharedRecord,
2442
2586
  normalizeWebhookEvent,
2587
+ offerableTiers,
2443
2588
  paymentsReady,
2444
2589
  personInputFromApp,
2445
2590
  planDelivery,
@@ -2457,6 +2602,7 @@ export {
2457
2602
  resolveLeaderFormation,
2458
2603
  resolvePipeline,
2459
2604
  resolveScheduling,
2605
+ resolveTiers,
2460
2606
  roleFromClaim,
2461
2607
  sendTemplated,
2462
2608
  sharedPersonInput,
@@ -2469,6 +2615,8 @@ export {
2469
2615
  submitApplication,
2470
2616
  subscriptionIdempotencyKey,
2471
2617
  syncApplicationToCrm,
2618
+ tierIsFree,
2619
+ tierPayable,
2472
2620
  updateClerkUserMetadata,
2473
2621
  updateClerkUserMetadataByEmail,
2474
2622
  validateScheduling,