@odla-ai/chapter 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 ? {
@@ -2051,18 +2158,22 @@ var clerkIntegration = {
2051
2158
  ],
2052
2159
  secrets: [
2053
2160
  {
2054
- key: "CLERK_WEBHOOK_SECRET",
2161
+ // odla-db reads this exact name (CLERK_WEBHOOK_SECRET_NAME in the
2162
+ // platform's clerk-webhook.ts); it is also what `odla-ai secrets set`
2163
+ // writes. It read "CLERK_WEBHOOK_SECRET" until 0.30.0, which sent
2164
+ // adopters to store the value under a name nothing looks up.
2165
+ name: "clerk_webhook_secret",
2055
2166
  description: "Svix signing secret for Clerk user webhooks \u2014 verifies $users sync events.",
2056
2167
  pattern: "whsec_",
2057
2168
  mode: "full",
2058
- vault: true
2169
+ required: false
2059
2170
  },
2060
2171
  {
2061
- key: "clerk_secret_key",
2172
+ name: "clerk_secret_key",
2062
2173
  description: "Clerk backend key (sk_*) \u2014 powers account creation, the public role claim, and backend-only private profile signals; it also lets odla-db resolve user email/name via the Clerk API.",
2063
2174
  pattern: "sk_",
2064
2175
  mode: "full",
2065
- vault: true
2176
+ required: false
2066
2177
  }
2067
2178
  ],
2068
2179
  syncs: [
@@ -2380,6 +2491,38 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
2380
2491
  ...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
2381
2492
  };
2382
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
+ }
2383
2526
  export {
2384
2527
  DEFAULT_CHAPTER_COPY,
2385
2528
  DEFAULT_SHARE_FIELDS,
@@ -2392,6 +2535,7 @@ export {
2392
2535
  brandTokens,
2393
2536
  bucketSeries,
2394
2537
  buildGroupSeed,
2538
+ buildTierSeeds,
2395
2539
  canApprove,
2396
2540
  canBook,
2397
2541
  canChangeRole,
@@ -2416,6 +2560,7 @@ export {
2416
2560
  emailGroupFrom,
2417
2561
  endForSlot,
2418
2562
  findApplicationRef,
2563
+ findTier,
2419
2564
  firstPaymentPatch,
2420
2565
  formatChapterCopy,
2421
2566
  formationFields,
@@ -2428,7 +2573,10 @@ export {
2428
2573
  isSlotAvailable,
2429
2574
  isValidEmail,
2430
2575
  joinConfig,
2576
+ joinConfigTiers,
2431
2577
  leaderCrmConfig,
2578
+ loadOfferableTiers,
2579
+ loadTiers,
2432
2580
  meetingCreateRow,
2433
2581
  meetingRescheduleUpdate,
2434
2582
  memberApplication,
@@ -2436,6 +2584,7 @@ export {
2436
2584
  networkSourceTag,
2437
2585
  normalizeSharedRecord,
2438
2586
  normalizeWebhookEvent,
2587
+ offerableTiers,
2439
2588
  paymentsReady,
2440
2589
  personInputFromApp,
2441
2590
  planDelivery,
@@ -2453,6 +2602,7 @@ export {
2453
2602
  resolveLeaderFormation,
2454
2603
  resolvePipeline,
2455
2604
  resolveScheduling,
2605
+ resolveTiers,
2456
2606
  roleFromClaim,
2457
2607
  sendTemplated,
2458
2608
  sharedPersonInput,
@@ -2465,6 +2615,8 @@ export {
2465
2615
  submitApplication,
2466
2616
  subscriptionIdempotencyKey,
2467
2617
  syncApplicationToCrm,
2618
+ tierIsFree,
2619
+ tierPayable,
2468
2620
  updateClerkUserMetadata,
2469
2621
  updateClerkUserMetadataByEmail,
2470
2622
  validateScheduling,