@odla-ai/chapter 0.10.0 → 0.11.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.
@@ -154,6 +154,12 @@ interface ChapterApplication {
154
154
  defaultMaxLen?: number;
155
155
  /** Max JSON request body in bytes. Default 32768. */
156
156
  bodyCap?: number;
157
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
158
+ * writing a row with no consent record. Default `false` for back-compat —
159
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
160
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
+ * and surfaces on the first test submit, not intermittently in production. */
162
+ requireDisclaimerAck?: boolean;
157
163
  }
158
164
  /** The fully-resolved application config carried on the {@link Chapter}. */
159
165
  interface ResolvedApplication {
@@ -162,6 +168,7 @@ interface ResolvedApplication {
162
168
  maxLen: Record<string, number>;
163
169
  defaultMaxLen: number;
164
170
  bodyCap: number;
171
+ requireDisclaimerAck: boolean;
165
172
  }
166
173
  /** The `defineChapter()` config a site fills in. */
167
174
  interface ChapterConfig {
@@ -154,6 +154,12 @@ interface ChapterApplication {
154
154
  defaultMaxLen?: number;
155
155
  /** Max JSON request body in bytes. Default 32768. */
156
156
  bodyCap?: number;
157
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
158
+ * writing a row with no consent record. Default `false` for back-compat —
159
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
160
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
+ * and surfaces on the first test submit, not intermittently in production. */
162
+ requireDisclaimerAck?: boolean;
157
163
  }
158
164
  /** The fully-resolved application config carried on the {@link Chapter}. */
159
165
  interface ResolvedApplication {
@@ -162,6 +168,7 @@ interface ResolvedApplication {
162
168
  maxLen: Record<string, number>;
163
169
  defaultMaxLen: number;
164
170
  bodyCap: number;
171
+ requireDisclaimerAck: boolean;
165
172
  }
166
173
  /** The `defineChapter()` config a site fills in. */
167
174
  interface ChapterConfig {
@@ -157,6 +157,20 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
157
157
  }
158
158
 
159
159
  // src/member.ts
160
+ function hasDisclaimerAck(fields) {
161
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
162
+ }
163
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
164
+ function applicantProfile(chapter, fields) {
165
+ const profile = {};
166
+ for (const f of [...chapter.application.required, ...chapter.application.optional]) {
167
+ if (IDENTITY_FIELDS.has(f)) continue;
168
+ const v = fields[f];
169
+ if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
170
+ }
171
+ if (fields.focus !== void 0) profile.focus = fields.focus;
172
+ return Object.keys(profile).length > 0 ? profile : void 0;
173
+ }
160
174
  async function submitApplication(db, chapter, fields, opts) {
161
175
  const app = chapter.application;
162
176
  for (const f of app.required) {
@@ -168,6 +182,10 @@ async function submitApplication(db, chapter, fields, opts) {
168
182
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
169
183
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
170
184
  }
185
+ const acked = hasDisclaimerAck(fields);
186
+ if (app.requireDisclaimerAck && !acked) {
187
+ return { ok: false, error: "disclaimerAck is required" };
188
+ }
171
189
  const id = opts.newId();
172
190
  const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
173
191
  for (const f of [...app.required, ...app.optional]) {
@@ -175,11 +193,12 @@ async function submitApplication(db, chapter, fields, opts) {
175
193
  }
176
194
  if (fields.focus !== void 0) row.focus = fields.focus;
177
195
  if (opts.groupId) row.groupId = opts.groupId;
196
+ if (acked) row.disclaimerAckAt = opts.now;
178
197
  const { duplicate } = await db.transact(
179
198
  [{ t: "update", ns: "applications", id, attrs: row }],
180
199
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
181
200
  );
182
- return { ok: true, id, duplicate, status: chapter.pipeline.initial };
201
+ return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
183
202
  }
184
203
  function joinConfig(group, paymentsReady) {
185
204
  return {
@@ -488,8 +507,13 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
488
507
  try {
489
508
  const secret = await getVaultSecret(db, "clerk_secret_key");
490
509
  if (secret) {
491
- if (chapter.account === "create") await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName) });
492
- else await createClerkInvitation(secret, { email });
510
+ const profile = applicantProfile(chapter, fields);
511
+ const publicMetadata = { applicationId, ...profile ? { profile } : {} };
512
+ if (chapter.account === "create") {
513
+ await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
514
+ } else {
515
+ await createClerkInvitation(secret, { email, publicMetadata });
516
+ }
493
517
  }
494
518
  } catch {
495
519
  }
@@ -630,7 +654,14 @@ var handleMember = async (req, url, env, ctx) => {
630
654
  }
631
655
  await provisionApplicant(db, chapter, result.id, parsed);
632
656
  }
633
- return json({ id: result.id, duplicate: result.duplicate, status: result.status });
657
+ return json({
658
+ id: result.id,
659
+ duplicate: result.duplicate,
660
+ status: result.status,
661
+ // Echoed so a site can see (and assert in an integration test) whether its
662
+ // join page actually posted the ack. Absent consent is otherwise invisible.
663
+ disclaimerAckAt: result.disclaimerAckAt
664
+ });
634
665
  }
635
666
  return null;
636
667
  };