@odla-ai/chapter 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -8
- package/dist/index.cjs +50 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -8
- package/dist/index.d.ts +63 -8
- package/dist/index.js +50 -9
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +48 -10
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +32 -0
- package/dist/worker/index.d.ts +32 -0
- package/dist/worker/index.js +48 -10
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -154,6 +154,32 @@ 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;
|
|
163
|
+
/** Allowlist of fields that reach the Clerk account's client-readable
|
|
164
|
+
* `public_metadata.profile`. Default (unset) projects every non-identity
|
|
165
|
+
* configured field — convenient, but it also exposes free-text and
|
|
166
|
+
* third-party fields (`message`, `referral`). Set this to a curated list
|
|
167
|
+
* (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
|
|
168
|
+
* Expected to become required-in-spirit at 1.0. */
|
|
169
|
+
profileFields?: readonly string[];
|
|
170
|
+
/** Extra application fields carried into the one-way CRM projection, on top of
|
|
171
|
+
* the built-in identity/contact set. Each MUST be declared on your crm person
|
|
172
|
+
* type or the enrichment is dropped (the base person still projects). Default
|
|
173
|
+
* none. */
|
|
174
|
+
crmFields?: readonly string[];
|
|
175
|
+
/** Cap on the element count of array-valued fields (e.g. `focus`), so a client
|
|
176
|
+
* cannot post a 10k-element array into a row or into Clerk metadata.
|
|
177
|
+
* Non-primitive elements are dropped. Default 100. */
|
|
178
|
+
maxArrayLen?: number;
|
|
179
|
+
/** Validate that a field literally named `email` looks like an email address,
|
|
180
|
+
* returning a 400 rather than accepting input the downstream Clerk create will
|
|
181
|
+
* reject anyway. Default `true`; a valid application is never newly rejected. */
|
|
182
|
+
validateEmail?: boolean;
|
|
157
183
|
}
|
|
158
184
|
/** The fully-resolved application config carried on the {@link Chapter}. */
|
|
159
185
|
interface ResolvedApplication {
|
|
@@ -162,6 +188,12 @@ interface ResolvedApplication {
|
|
|
162
188
|
maxLen: Record<string, number>;
|
|
163
189
|
defaultMaxLen: number;
|
|
164
190
|
bodyCap: number;
|
|
191
|
+
requireDisclaimerAck: boolean;
|
|
192
|
+
/** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
|
|
193
|
+
profileFields: readonly string[] | null;
|
|
194
|
+
crmFields: readonly string[];
|
|
195
|
+
maxArrayLen: number;
|
|
196
|
+
validateEmail: boolean;
|
|
165
197
|
}
|
|
166
198
|
/** The `defineChapter()` config a site fills in. */
|
|
167
199
|
interface ChapterConfig {
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -154,6 +154,32 @@ 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;
|
|
163
|
+
/** Allowlist of fields that reach the Clerk account's client-readable
|
|
164
|
+
* `public_metadata.profile`. Default (unset) projects every non-identity
|
|
165
|
+
* configured field — convenient, but it also exposes free-text and
|
|
166
|
+
* third-party fields (`message`, `referral`). Set this to a curated list
|
|
167
|
+
* (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
|
|
168
|
+
* Expected to become required-in-spirit at 1.0. */
|
|
169
|
+
profileFields?: readonly string[];
|
|
170
|
+
/** Extra application fields carried into the one-way CRM projection, on top of
|
|
171
|
+
* the built-in identity/contact set. Each MUST be declared on your crm person
|
|
172
|
+
* type or the enrichment is dropped (the base person still projects). Default
|
|
173
|
+
* none. */
|
|
174
|
+
crmFields?: readonly string[];
|
|
175
|
+
/** Cap on the element count of array-valued fields (e.g. `focus`), so a client
|
|
176
|
+
* cannot post a 10k-element array into a row or into Clerk metadata.
|
|
177
|
+
* Non-primitive elements are dropped. Default 100. */
|
|
178
|
+
maxArrayLen?: number;
|
|
179
|
+
/** Validate that a field literally named `email` looks like an email address,
|
|
180
|
+
* returning a 400 rather than accepting input the downstream Clerk create will
|
|
181
|
+
* reject anyway. Default `true`; a valid application is never newly rejected. */
|
|
182
|
+
validateEmail?: boolean;
|
|
157
183
|
}
|
|
158
184
|
/** The fully-resolved application config carried on the {@link Chapter}. */
|
|
159
185
|
interface ResolvedApplication {
|
|
@@ -162,6 +188,12 @@ interface ResolvedApplication {
|
|
|
162
188
|
maxLen: Record<string, number>;
|
|
163
189
|
defaultMaxLen: number;
|
|
164
190
|
bodyCap: number;
|
|
191
|
+
requireDisclaimerAck: boolean;
|
|
192
|
+
/** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
|
|
193
|
+
profileFields: readonly string[] | null;
|
|
194
|
+
crmFields: readonly string[];
|
|
195
|
+
maxArrayLen: number;
|
|
196
|
+
validateEmail: boolean;
|
|
165
197
|
}
|
|
166
198
|
/** The `defineChapter()` config a site fills in. */
|
|
167
199
|
interface ChapterConfig {
|
package/dist/worker/index.js
CHANGED
|
@@ -157,15 +157,28 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// src/member.ts
|
|
160
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
161
|
+
function isValidEmail(value) {
|
|
162
|
+
return typeof value === "string" && EMAIL_RE.test(value);
|
|
163
|
+
}
|
|
164
|
+
function clampArray(value, max) {
|
|
165
|
+
if (!Array.isArray(value)) return value;
|
|
166
|
+
return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
|
|
167
|
+
}
|
|
168
|
+
function hasDisclaimerAck(fields) {
|
|
169
|
+
return fields.disclaimerAck === true || fields.disclaimerAck === "true";
|
|
170
|
+
}
|
|
160
171
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
161
172
|
function applicantProfile(chapter, fields) {
|
|
173
|
+
const app = chapter.application;
|
|
174
|
+
const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
|
|
162
175
|
const profile = {};
|
|
163
|
-
for (const f of [...
|
|
164
|
-
if (IDENTITY_FIELDS.has(f)) continue;
|
|
176
|
+
for (const f of [...app.required, ...app.optional]) {
|
|
177
|
+
if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
|
|
165
178
|
const v = fields[f];
|
|
166
179
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
167
180
|
}
|
|
168
|
-
if (fields.focus !== void 0) profile.focus = fields.focus;
|
|
181
|
+
if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
169
182
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
170
183
|
}
|
|
171
184
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -179,19 +192,26 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
179
192
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
180
193
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
181
194
|
}
|
|
195
|
+
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
|
|
196
|
+
return { ok: false, error: "email must be a valid email address" };
|
|
197
|
+
}
|
|
198
|
+
const acked = hasDisclaimerAck(fields);
|
|
199
|
+
if (app.requireDisclaimerAck && !acked) {
|
|
200
|
+
return { ok: false, error: "disclaimerAck is required" };
|
|
201
|
+
}
|
|
182
202
|
const id = opts.newId();
|
|
183
203
|
const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
|
|
184
204
|
for (const f of [...app.required, ...app.optional]) {
|
|
185
205
|
if (typeof fields[f] === "string") row[f] = fields[f].trim();
|
|
186
206
|
}
|
|
187
|
-
if (fields.focus !== void 0) row.focus = fields.focus;
|
|
207
|
+
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
188
208
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
189
|
-
if (
|
|
209
|
+
if (acked) row.disclaimerAckAt = opts.now;
|
|
190
210
|
const { duplicate } = await db.transact(
|
|
191
211
|
[{ t: "update", ns: "applications", id, attrs: row }],
|
|
192
212
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
193
213
|
);
|
|
194
|
-
return { ok: true, id, duplicate, status: chapter.pipeline.initial };
|
|
214
|
+
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
195
215
|
}
|
|
196
216
|
function joinConfig(group, paymentsReady) {
|
|
197
217
|
return {
|
|
@@ -237,7 +257,7 @@ async function projectSharedRecord(deps, person) {
|
|
|
237
257
|
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
238
258
|
}
|
|
239
259
|
async function projectApplicant(deps, applicant) {
|
|
240
|
-
const
|
|
260
|
+
const base = sharedPersonInput({
|
|
241
261
|
email: applicant.email,
|
|
242
262
|
firstName: applicant.firstName,
|
|
243
263
|
lastName: applicant.lastName,
|
|
@@ -245,7 +265,14 @@ async function projectApplicant(deps, applicant) {
|
|
|
245
265
|
linkedin: applicant.linkedin,
|
|
246
266
|
hubRecordId: applicant.applicationId
|
|
247
267
|
});
|
|
248
|
-
|
|
268
|
+
const mutationId = `apply:${applicant.applicationId}`;
|
|
269
|
+
const extra = applicant.extra ?? {};
|
|
270
|
+
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
271
|
+
try {
|
|
272
|
+
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
273
|
+
} catch {
|
|
274
|
+
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
275
|
+
}
|
|
249
276
|
}
|
|
250
277
|
|
|
251
278
|
// src/scheduling.ts
|
|
@@ -489,10 +516,14 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
|
489
516
|
const email = typeof fields.email === "string" ? fields.email : "";
|
|
490
517
|
if (!email) return;
|
|
491
518
|
const s = (v) => typeof v === "string" ? v : void 0;
|
|
519
|
+
const extra = {};
|
|
520
|
+
for (const f of chapter.application.crmFields) {
|
|
521
|
+
if (fields[f] !== void 0) extra[f] = fields[f];
|
|
522
|
+
}
|
|
492
523
|
try {
|
|
493
524
|
await projectApplicant(
|
|
494
525
|
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
495
|
-
{ applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
|
|
526
|
+
{ applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin), extra }
|
|
496
527
|
);
|
|
497
528
|
} catch {
|
|
498
529
|
}
|
|
@@ -647,7 +678,14 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
647
678
|
}
|
|
648
679
|
await provisionApplicant(db, chapter, result.id, parsed);
|
|
649
680
|
}
|
|
650
|
-
return json({
|
|
681
|
+
return json({
|
|
682
|
+
id: result.id,
|
|
683
|
+
duplicate: result.duplicate,
|
|
684
|
+
status: result.status,
|
|
685
|
+
// Echoed so a site can see (and assert in an integration test) whether its
|
|
686
|
+
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
687
|
+
disclaimerAckAt: result.disclaimerAckAt
|
|
688
|
+
});
|
|
651
689
|
}
|
|
652
690
|
return null;
|
|
653
691
|
};
|