@odla-ai/chapter 0.11.0 → 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 +27 -5
- package/dist/index.cjs +39 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -7
- package/dist/index.d.ts +47 -7
- package/dist/index.js +39 -7
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +31 -7
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +25 -0
- package/dist/worker/index.d.ts +25 -0
- package/dist/worker/index.js +31 -7
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -160,6 +160,26 @@ interface ChapterApplication {
|
|
|
160
160
|
* otherwise silent, permanent and unreconstructible. Failure is deterministic
|
|
161
161
|
* and surfaces on the first test submit, not intermittently in production. */
|
|
162
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;
|
|
163
183
|
}
|
|
164
184
|
/** The fully-resolved application config carried on the {@link Chapter}. */
|
|
165
185
|
interface ResolvedApplication {
|
|
@@ -169,6 +189,11 @@ interface ResolvedApplication {
|
|
|
169
189
|
defaultMaxLen: number;
|
|
170
190
|
bodyCap: number;
|
|
171
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;
|
|
172
197
|
}
|
|
173
198
|
/** The `defineChapter()` config a site fills in. */
|
|
174
199
|
interface ChapterConfig {
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -160,6 +160,26 @@ interface ChapterApplication {
|
|
|
160
160
|
* otherwise silent, permanent and unreconstructible. Failure is deterministic
|
|
161
161
|
* and surfaces on the first test submit, not intermittently in production. */
|
|
162
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;
|
|
163
183
|
}
|
|
164
184
|
/** The fully-resolved application config carried on the {@link Chapter}. */
|
|
165
185
|
interface ResolvedApplication {
|
|
@@ -169,6 +189,11 @@ interface ResolvedApplication {
|
|
|
169
189
|
defaultMaxLen: number;
|
|
170
190
|
bodyCap: number;
|
|
171
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;
|
|
172
197
|
}
|
|
173
198
|
/** The `defineChapter()` config a site fills in. */
|
|
174
199
|
interface ChapterConfig {
|
package/dist/worker/index.js
CHANGED
|
@@ -157,18 +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
|
+
}
|
|
160
168
|
function hasDisclaimerAck(fields) {
|
|
161
169
|
return fields.disclaimerAck === true || fields.disclaimerAck === "true";
|
|
162
170
|
}
|
|
163
171
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
164
172
|
function applicantProfile(chapter, fields) {
|
|
173
|
+
const app = chapter.application;
|
|
174
|
+
const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
|
|
165
175
|
const profile = {};
|
|
166
|
-
for (const f of [...
|
|
167
|
-
if (IDENTITY_FIELDS.has(f)) continue;
|
|
176
|
+
for (const f of [...app.required, ...app.optional]) {
|
|
177
|
+
if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
|
|
168
178
|
const v = fields[f];
|
|
169
179
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
170
180
|
}
|
|
171
|
-
if (fields.focus !== void 0) profile.focus = fields.focus;
|
|
181
|
+
if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
172
182
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
173
183
|
}
|
|
174
184
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -182,6 +192,9 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
182
192
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
183
193
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
184
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
|
+
}
|
|
185
198
|
const acked = hasDisclaimerAck(fields);
|
|
186
199
|
if (app.requireDisclaimerAck && !acked) {
|
|
187
200
|
return { ok: false, error: "disclaimerAck is required" };
|
|
@@ -191,7 +204,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
191
204
|
for (const f of [...app.required, ...app.optional]) {
|
|
192
205
|
if (typeof fields[f] === "string") row[f] = fields[f].trim();
|
|
193
206
|
}
|
|
194
|
-
if (fields.focus !== void 0) row.focus = fields.focus;
|
|
207
|
+
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
195
208
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
196
209
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
197
210
|
const { duplicate } = await db.transact(
|
|
@@ -244,7 +257,7 @@ async function projectSharedRecord(deps, person) {
|
|
|
244
257
|
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
245
258
|
}
|
|
246
259
|
async function projectApplicant(deps, applicant) {
|
|
247
|
-
const
|
|
260
|
+
const base = sharedPersonInput({
|
|
248
261
|
email: applicant.email,
|
|
249
262
|
firstName: applicant.firstName,
|
|
250
263
|
lastName: applicant.lastName,
|
|
@@ -252,7 +265,14 @@ async function projectApplicant(deps, applicant) {
|
|
|
252
265
|
linkedin: applicant.linkedin,
|
|
253
266
|
hubRecordId: applicant.applicationId
|
|
254
267
|
});
|
|
255
|
-
|
|
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
|
+
}
|
|
256
276
|
}
|
|
257
277
|
|
|
258
278
|
// src/scheduling.ts
|
|
@@ -496,10 +516,14 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
|
496
516
|
const email = typeof fields.email === "string" ? fields.email : "";
|
|
497
517
|
if (!email) return;
|
|
498
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
|
+
}
|
|
499
523
|
try {
|
|
500
524
|
await projectApplicant(
|
|
501
525
|
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
502
|
-
{ 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 }
|
|
503
527
|
);
|
|
504
528
|
} catch {
|
|
505
529
|
}
|