@reform-society/agera-core 0.1.0 → 0.2.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.d.ts CHANGED
@@ -204,6 +204,8 @@ declare function calculateSectionResults(questions: QuizQuestionInput[], questio
204
204
  declare function scoreQuiz(questions: QuizQuestionInput[], config?: QuizConfig): QuizResults;
205
205
  //#endregion
206
206
  //#region src/basin-client.d.ts
207
+ type JwtPayload = Record<string, unknown>;
208
+ declare function decodeJwtPayload(jwt: string): JwtPayload | null;
207
209
  /** Check if JWT is expired (with 60s buffer) */
208
210
  declare function isJwtExpired(jwt: string): boolean;
209
211
  type StorageAccessor = {
@@ -211,7 +213,10 @@ type StorageAccessor = {
211
213
  setItem(key: string, value: string): void;
212
214
  removeItem(key: string): void;
213
215
  };
214
- declare function fetchBasinJWT(endpoint: string, storage: StorageAccessor, storageKey: string): Promise<string>;
216
+ type FetchBasinJWTOptions = {
217
+ isCachedJwtValid?: (jwt: string) => boolean;
218
+ };
219
+ declare function fetchBasinJWT(endpoint: string, storage: StorageAccessor, storageKey: string, options?: FetchBasinJWTOptions): Promise<string>;
215
220
  //#endregion
216
221
  //#region src/crm-types.d.ts
217
222
  type CounterUpdateConfig = {
@@ -262,11 +267,55 @@ type CrmV2Request = {
262
267
  type CrmV2PatchRequest = CrmV2Request & {
263
268
  response: Record<string, unknown>;
264
269
  };
270
+ type CrmV2Payload = CrmV2Request & {
271
+ response?: Record<string, unknown>;
272
+ };
265
273
  type CrmFetchResult = {
266
274
  ok: boolean;
267
275
  status: number;
268
276
  data: Record<string, unknown> | null;
269
277
  };
278
+ type CrmCommand = {
279
+ client: string;
280
+ payload: CrmV2Payload;
281
+ method?: "POST" | "PUT" | "PATCH";
282
+ delivery?: "wait" | "background";
283
+ };
284
+ type CrmDispatch = {
285
+ client: string;
286
+ method: "POST" | "PUT" | "PATCH";
287
+ delivery: "wait" | "background";
288
+ payload: CrmV2Payload;
289
+ signal?: AbortSignal;
290
+ };
291
+ type CrmGatewayResult = {
292
+ kind: "response";
293
+ response: CrmFetchResult;
294
+ } | {
295
+ kind: "accepted";
296
+ };
297
+ type CrmGateway = (dispatch: CrmDispatch) => Promise<CrmGatewayResult>;
298
+ type CrmOutcome = {
299
+ status: "succeeded";
300
+ httpStatus: number;
301
+ response: Record<string, unknown> | null;
302
+ } | {
303
+ status: "accepted";
304
+ } | {
305
+ status: "rejected";
306
+ httpStatus: number;
307
+ response: Record<string, unknown> | null;
308
+ } | {
309
+ status: "failed";
310
+ cause: unknown;
311
+ } | {
312
+ status: "aborted";
313
+ };
314
+ //#endregion
315
+ //#region src/crm-submission.d.ts
316
+ declare function executeCrm(gateway: CrmGateway, command: CrmCommand, options?: {
317
+ signal?: AbortSignal;
318
+ }): Promise<CrmOutcome>;
270
319
  //#endregion
271
320
  //#region src/utm.d.ts
272
321
  declare const UTM_KEYS: readonly ["source", "campaign", "medium", "content", "term", "wec"];
@@ -356,6 +405,22 @@ type CrmFetchOptions = {
356
405
  };
357
406
  declare function crmFetch(url: string, payload: Record<string, unknown> | object, options?: CrmFetchOptions): Promise<CrmFetchResult>;
358
407
  //#endregion
408
+ //#region src/crm-fields.d.ts
409
+ declare const CRM_CONTACT_KEYS: readonly ["given-name", "family-name", "email", "tel", "street-address", "address-level2", "postal-code", "country"];
410
+ type CrmContactKey = (typeof CRM_CONTACT_KEYS)[number];
411
+ declare function isCrmContactKey(key: string): key is CrmContactKey;
412
+ type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
413
+ readonly [key: string]: JsonValue;
414
+ };
415
+ type CrmGroupedAnswer = {
416
+ group: string;
417
+ name: string;
418
+ value: JsonValue;
419
+ };
420
+ type CrmJsonGroups = Record<string, Record<string, JsonValue>>;
421
+ declare function groupCrmAnswers(answers: readonly CrmGroupedAnswer[]): CrmJsonGroups;
422
+ declare function serializeCrmJsonGroups(groups: CrmJsonGroups): string | undefined;
423
+ //#endregion
359
424
  //#region src/crm-request.d.ts
360
425
  type AssembleCrmRequestParams = {
361
426
  body: Record<string, unknown>;
@@ -365,7 +430,7 @@ type AssembleCrmRequestParams = {
365
430
  queryParams: Record<string, string>;
366
431
  sourceUrl: string;
367
432
  counters?: CounterUpdateConfig[];
368
- jsonGroups?: Record<string, Record<string, string>>;
433
+ jsonGroups?: CrmJsonGroups;
369
434
  };
370
435
  declare function assembleCrmRequest({
371
436
  body: rawBody,
@@ -381,5 +446,5 @@ declare function assembleCrmRequest({
381
446
  //#region src/polling.d.ts
382
447
  declare function matchesFormat(example: unknown, value: unknown): boolean;
383
448
  //#endregion
384
- export { type AssembleCrmRequestParams, type BandQuizResults, type CompositePredicate, type CounterDisplayConfig, type CounterPayload, type CounterResponse, type CounterUpdateConfig, type CrmConfig, type CrmFetchOptions, type CrmFetchResult, type CrmFrontendKey, type CrmV2PatchRequest, type CrmV2Request, type DeviceInput, type DeviceType, type ErrorMessages, type InsertDirective, type MemoryCache, type MemoryCacheOptions, type MemoryCacheSetOptions, type PassQuizResults, type PlacesMessageKey, type PlacesMessages, type Predicate, type PredicateOp, type ProgressInfo, type ProgressModeInfo, type QuestionMetrics, type QuizBandDefinition, type QuizConfig, type QuizOutcomeModel, type QuizPassFailConfig, type QuizQuestionInput, type QuizQuestionModel, type QuizQuestionResult, type QuizQuestionState, type QuizResults, type QuizScoringModel, type QuizSectionConfig, type QuizSectionResults, type ResolveValueSources, type ScoreQuizResults, type SessionState, type ShareConfig, type SimplePredicate, type SplitConfig, type StepState, type StorageAccessor, type SurveyOptions, type Utms, type ValidationRule, type Validators, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, inferCounterName, isCounterResponse, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, updateUrlWithParams, validators };
449
+ export { type AssembleCrmRequestParams, type BandQuizResults, CRM_CONTACT_KEYS, type CompositePredicate, type CounterDisplayConfig, type CounterPayload, type CounterResponse, type CounterUpdateConfig, type CrmCommand, type CrmConfig, type CrmContactKey, type CrmDispatch, type CrmFetchOptions, type CrmFetchResult, type CrmFrontendKey, type CrmGateway, type CrmGatewayResult, type CrmGroupedAnswer, type CrmJsonGroups, type CrmOutcome, type CrmV2PatchRequest, type CrmV2Payload, type CrmV2Request, type DeviceInput, type DeviceType, type ErrorMessages, type FetchBasinJWTOptions, type InsertDirective, type JsonValue, type JwtPayload, type MemoryCache, type MemoryCacheOptions, type MemoryCacheSetOptions, type PassQuizResults, type PlacesMessageKey, type PlacesMessages, type Predicate, type PredicateOp, type ProgressInfo, type ProgressModeInfo, type QuestionMetrics, type QuizBandDefinition, type QuizConfig, type QuizOutcomeModel, type QuizPassFailConfig, type QuizQuestionInput, type QuizQuestionModel, type QuizQuestionResult, type QuizQuestionState, type QuizResults, type QuizScoringModel, type QuizSectionConfig, type QuizSectionResults, type ResolveValueSources, type ScoreQuizResults, type SessionState, type ShareConfig, type SimplePredicate, type SplitConfig, type StepState, type StorageAccessor, type SurveyOptions, type Utms, type ValidationRule, type Validators, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, updateUrlWithParams, validators };
385
450
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -111,9 +111,26 @@ function evaluatePredicate(predicate, answers, sources) {
111
111
  }
112
112
  return false;
113
113
  }
114
+ /**
115
+ * Parse predicate JSON, tolerating single-quoted JSON.
116
+ *
117
+ * Webflow's Designer API (setCustomAttribute) forbids double quotes in
118
+ * attribute values, so predicates authored via the chat/Logic tab use single
119
+ * quotes — e.g. {'field':'plan','op':'eq','value':'pro'}. Strict JSON is tried
120
+ * first (fully backwards compatible); single-quote normalization is a fallback.
121
+ */
122
+ function parsePredicateJson(json) {
123
+ try {
124
+ return JSON.parse(json);
125
+ } catch {
126
+ const sentinel = String.fromCharCode(0);
127
+ const normalized = json.replace(/\\'/g, sentinel).replace(/'/g, "\"").split(sentinel).join("'");
128
+ return JSON.parse(normalized);
129
+ }
130
+ }
114
131
  function evaluateShowIf(predicateJson, answers, sources) {
115
132
  try {
116
- return evaluatePredicate(JSON.parse(predicateJson), answers, sources);
133
+ return evaluatePredicate(parsePredicateJson(predicateJson), answers, sources);
117
134
  } catch (error) {
118
135
  log.error("[conditions] Invalid predicate JSON:", error);
119
136
  return false;
@@ -121,7 +138,7 @@ function evaluateShowIf(predicateJson, answers, sources) {
121
138
  }
122
139
  function evaluateNextMap(nextMapJson, answers, sources) {
123
140
  try {
124
- const nextMap = JSON.parse(nextMapJson);
141
+ const nextMap = parsePredicateJson(nextMapJson);
125
142
  for (const rule of nextMap) if (evaluatePredicate(rule.if, answers, sources)) return rule.then;
126
143
  return null;
127
144
  } catch (error) {
@@ -664,21 +681,55 @@ function scoreQuiz(questions, config = {}) {
664
681
  }
665
682
  //#endregion
666
683
  //#region src/basin-client.ts
667
- /** Check if JWT is expired (with 60s buffer) */
668
- function isJwtExpired(jwt) {
684
+ function decodeBase64Url(value) {
685
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
686
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
687
+ return atob(padded);
688
+ }
689
+ function decodeJwtPayload(jwt) {
669
690
  try {
670
691
  const payload = jwt.split(".")[1];
671
- if (!payload) return true;
672
- const { exp } = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
673
- return typeof exp === "number" && exp <= Date.now() / 1e3 + 60;
692
+ if (!payload) return null;
693
+ const parsed = JSON.parse(decodeBase64Url(payload));
694
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
695
+ return parsed;
674
696
  } catch {
675
- return true;
697
+ return null;
676
698
  }
677
699
  }
678
- async function fetchBasinJWT(endpoint, storage, storageKey) {
679
- const cached = storage.getItem(storageKey);
680
- if (cached && !isJwtExpired(cached)) return cached;
681
- if (cached) storage.removeItem(storageKey);
700
+ /** Check if JWT is expired (with 60s buffer) */
701
+ function isJwtExpired(jwt) {
702
+ const payload = decodeJwtPayload(jwt);
703
+ if (!payload) return true;
704
+ if (typeof payload.exp !== "number") return true;
705
+ return payload.exp <= Date.now() / 1e3 + 60;
706
+ }
707
+ function getStoredJwt(storage, storageKey) {
708
+ try {
709
+ return storage.getItem(storageKey);
710
+ } catch (error) {
711
+ log.debug("[basin] Failed to read cached JWT:", error);
712
+ return null;
713
+ }
714
+ }
715
+ function removeStoredJwt(storage, storageKey) {
716
+ try {
717
+ storage.removeItem(storageKey);
718
+ } catch (error) {
719
+ log.debug("[basin] Failed to remove cached JWT:", error);
720
+ }
721
+ }
722
+ function setStoredJwt(storage, storageKey, jwt) {
723
+ try {
724
+ storage.setItem(storageKey, jwt);
725
+ } catch (error) {
726
+ log.debug("[basin] Failed to cache JWT:", error);
727
+ }
728
+ }
729
+ async function fetchBasinJWT(endpoint, storage, storageKey, options = {}) {
730
+ const cached = getStoredJwt(storage, storageKey);
731
+ if (cached && !isJwtExpired(cached) && (options.isCachedJwtValid?.(cached) ?? true)) return cached;
732
+ if (cached) removeStoredJwt(storage, storageKey);
682
733
  try {
683
734
  const res = await fetch(`${endpoint}/generate_jwt`, {
684
735
  method: "GET",
@@ -693,7 +744,7 @@ async function fetchBasinJWT(endpoint, storage, storageKey) {
693
744
  }
694
745
  const data = await res.json();
695
746
  if (data.jwt) {
696
- storage.setItem(storageKey, data.jwt);
747
+ setStoredJwt(storage, storageKey, data.jwt);
697
748
  return data.jwt;
698
749
  }
699
750
  return "";
@@ -703,6 +754,41 @@ async function fetchBasinJWT(endpoint, storage, storageKey) {
703
754
  }
704
755
  }
705
756
  //#endregion
757
+ //#region src/crm-submission.ts
758
+ function isAbortError(error) {
759
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
760
+ }
761
+ async function executeCrm(gateway, command, options = {}) {
762
+ const { signal } = options;
763
+ if (signal?.aborted) return { status: "aborted" };
764
+ try {
765
+ const result = await gateway({
766
+ client: command.client,
767
+ method: command.method ?? "POST",
768
+ delivery: command.delivery ?? "wait",
769
+ payload: command.payload,
770
+ signal
771
+ });
772
+ if (result.kind === "accepted") return { status: "accepted" };
773
+ const { ok, status, data } = result.response;
774
+ return ok ? {
775
+ status: "succeeded",
776
+ httpStatus: status,
777
+ response: data
778
+ } : {
779
+ status: "rejected",
780
+ httpStatus: status,
781
+ response: data
782
+ };
783
+ } catch (error) {
784
+ if (signal?.aborted || isAbortError(error)) return { status: "aborted" };
785
+ return {
786
+ status: "failed",
787
+ cause: error
788
+ };
789
+ }
790
+ }
791
+ //#endregion
706
792
  //#region src/utm.ts
707
793
  const UTM_KEYS = [
708
794
  "source",
@@ -1076,14 +1162,45 @@ async function crmFetch(url, payload, options = {}) {
1076
1162
  }
1077
1163
  }
1078
1164
  //#endregion
1165
+ //#region src/crm-fields.ts
1166
+ const CRM_CONTACT_KEYS = [
1167
+ "given-name",
1168
+ "family-name",
1169
+ "email",
1170
+ "tel",
1171
+ "street-address",
1172
+ "address-level2",
1173
+ "postal-code",
1174
+ "country"
1175
+ ];
1176
+ const crmContactKeySet = new Set(CRM_CONTACT_KEYS);
1177
+ function isCrmContactKey(key) {
1178
+ return crmContactKeySet.has(key);
1179
+ }
1180
+ function groupCrmAnswers(answers) {
1181
+ const groups = /* @__PURE__ */ new Map();
1182
+ for (const { group, name, value } of answers) {
1183
+ if (group === "" || name === "") continue;
1184
+ const fields = groups.get(group) ?? /* @__PURE__ */ new Map();
1185
+ fields.set(name, value);
1186
+ groups.set(group, fields);
1187
+ }
1188
+ return Object.fromEntries([...groups].map(([group, fields]) => [group, Object.fromEntries(fields)]));
1189
+ }
1190
+ function serializeCrmJsonGroups(groups) {
1191
+ const groupEntries = Object.entries(groups);
1192
+ if (groupEntries.length === 0) return void 0;
1193
+ return JSON.stringify(groupEntries.map(([key, fields]) => ({ [key]: fields })));
1194
+ }
1195
+ //#endregion
1079
1196
  //#region src/crm-request.ts
1080
1197
  function assembleCrmRequest({ body: rawBody, config, frontendKeys, utms, queryParams, sourceUrl, counters, jsonGroups }) {
1081
1198
  const configuredCounters = Array.isArray(config.counter_update) ? config.counter_update : void 0;
1082
1199
  const resolvedCounters = counters ?? configuredCounters;
1083
1200
  const body = { ...rawBody };
1084
1201
  if (jsonGroups) {
1085
- const groupEntries = Object.entries(jsonGroups);
1086
- if (groupEntries.length > 0) body["json-groups"] = JSON.stringify(groupEntries.map(([key, fields]) => ({ [key]: fields })));
1202
+ const serialized = serializeCrmJsonGroups(jsonGroups);
1203
+ if (serialized !== void 0) body["json-groups"] = serialized;
1087
1204
  }
1088
1205
  return {
1089
1206
  body,
@@ -1117,6 +1234,6 @@ function matchesFormat(example, value) {
1117
1234
  return example === value;
1118
1235
  }
1119
1236
  //#endregion
1120
- export { aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, inferCounterName, isCounterResponse, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, updateUrlWithParams, validators };
1237
+ export { CRM_CONTACT_KEYS, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, updateUrlWithParams, validators };
1121
1238
 
1122
1239
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reform-society/agera-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DOM-free core logic for Agera",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "git+https://github.com/reform-society/agera-survey-app.git",
20
+ "url": "git+https://github.com/reform-society/agera.git",
21
21
  "directory": "packages/agera-core"
22
22
  },
23
23
  "author": "Reform Society",
@@ -34,7 +34,9 @@
34
34
  "test": "vitest run"
35
35
  },
36
36
  "devDependencies": {
37
+ "@typescript/native": "npm:typescript@^7.0.2",
37
38
  "tsdown": "^0.21.8",
39
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
38
40
  "vite": "^7.3.2",
39
41
  "vitest": "^4.1.3"
40
42
  },