@peektravel/app-utilities 0.9.0 → 0.9.2

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 CHANGED
@@ -317,9 +317,12 @@ event data**; the fields it shares with the token (`installId` / `accountId` /
317
317
  the body omits them. The token authenticates the whole delivery, so the body is
318
318
  trusted within a verified request. This webhook is the only source of
319
319
  `accountId`, `timezone`, and `apiUrl` in the package, since no GraphQL read
320
- returns them — **persist them per install**. `apiUrl` is the install's app
321
- endpoint: use it **as given** (don't reconstruct it from `appId`) — pass it as the
322
- access service's `apiUrl`, or hand the whole install to
320
+ returns them — **persist them per install**. `apiUrl` is the install's **base API
321
+ URL**: pass it straight through (don't reconstruct it from `appId`) as the access
322
+ service's `apiUrl` — each service class appends its own backoffice slug (e.g.
323
+ `peek_backoffice_api-v1`) when it makes a call, stripping the slug back off if the
324
+ URL you pass already carries it and throwing if it carries a different platform's
325
+ slug — or hand the whole install to
323
326
  `createAccessServiceForInstall(install, { jwtSecret, issuer })`, which picks the
324
327
  service by `platform` and wires the endpoint for you. Every event is a **full
325
328
  snapshot** — an `update_installed` carries the same fields as the original
package/dist/index.cjs CHANGED
@@ -23,9 +23,29 @@ function _interopNamespace(e) {
23
23
 
24
24
  var jwt__namespace = /*#__PURE__*/_interopNamespace(jwt);
25
25
 
26
+ // src/internal/api-endpoints.ts
27
+ var ApiEndpoints = class {
28
+ constructor(paths) {
29
+ this.paths = paths;
30
+ }
31
+ paths;
32
+ /** The relative path (slug) for `endpoint`, appended to the base API URL. */
33
+ pathFor(endpoint) {
34
+ return this.paths[endpoint];
35
+ }
36
+ };
37
+ function joinUrl(...parts) {
38
+ return parts.filter((part) => part.length > 0).join("/");
39
+ }
40
+
26
41
  // src/internal/peek/gateway-endpoints.ts
27
42
  var SALES_ENDPOINT = "sales";
28
43
  var V2_EXTENDABLE_SLUG = "peek_backoffice_api-v1";
44
+ function peekApiEndpoints(useRegistrySlug) {
45
+ return new ApiEndpoints({
46
+ [SALES_ENDPOINT]: useRegistrySlug ? V2_EXTENDABLE_SLUG : SALES_ENDPOINT
47
+ });
48
+ }
29
49
 
30
50
  // src/internal/peek/account-users/account-user-converter.ts
31
51
  var ACTIVE_STATUS = "ACTIVE";
@@ -1092,6 +1112,9 @@ var QUESTION_TYPE_SELECT_ONE = "SELECT_ONE";
1092
1112
  var QUESTION_TYPE_LOCATION = "LOCATION";
1093
1113
  var QUESTION_ID_REGEX = /^cq_[a-z0-9]+$/;
1094
1114
  var OPTION_ID_REGEX = /^cqao_[a-z0-9]+$/;
1115
+ function isCustomQuestionId(idOrText) {
1116
+ return QUESTION_ID_REGEX.test(idOrText);
1117
+ }
1095
1118
  var CHECKBOX_TRUE = /* @__PURE__ */ new Set(["yes", "true"]);
1096
1119
  var CHECKBOX_VALID = /* @__PURE__ */ new Set(["yes", "no", "true", "false"]);
1097
1120
  var CHECKBOX_TEXT_TRUE = "Yes";
@@ -1109,10 +1132,13 @@ var errMissingRequired = (text, id) => `Custom question "${text}" (${id}) is req
1109
1132
  function normalize(value) {
1110
1133
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1111
1134
  }
1112
- function resolveQuestion(idOrText, questions) {
1135
+ function resolveQuestion(idOrText, questions, acceptAllIds) {
1113
1136
  if (QUESTION_ID_REGEX.test(idOrText)) {
1114
1137
  const byId = questions.find((question) => question.id === idOrText);
1115
- if (!byId) throw new Error(errUnknownQuestionId(idOrText));
1138
+ if (!byId) {
1139
+ if (acceptAllIds) return null;
1140
+ throw new Error(errUnknownQuestionId(idOrText));
1141
+ }
1116
1142
  return byId;
1117
1143
  }
1118
1144
  const target = normalize(idOrText);
@@ -1137,8 +1163,11 @@ function resolveOption(value, question) {
1137
1163
  if (matches.length > 1) throw new Error(errAmbiguousOption(value, question.id));
1138
1164
  return matches[0];
1139
1165
  }
1140
- function resolveOne(answer, questions) {
1141
- const question = resolveQuestion(answer.questionIdOrText, questions);
1166
+ function resolveOne(answer, questions, acceptAllIds) {
1167
+ const question = resolveQuestion(answer.questionIdOrText, questions, acceptAllIds);
1168
+ if (!question) {
1169
+ return { questionId: answer.questionIdOrText, questionAnswerText: answer.value };
1170
+ }
1142
1171
  if (question.perGuest) {
1143
1172
  throw new Error(errPerGuestUnsupported(question.id));
1144
1173
  }
@@ -1171,7 +1200,8 @@ function resolveOne(answer, questions) {
1171
1200
  }
1172
1201
  }
1173
1202
  function resolveCustomQuestionAnswers(answers, questions, options) {
1174
- const resolved = answers.map((answer) => resolveOne(answer, questions));
1203
+ const acceptAllIds = options?.acceptAllIds ?? false;
1204
+ const resolved = answers.map((answer) => resolveOne(answer, questions, acceptAllIds));
1175
1205
  if (options?.requireRequired) {
1176
1206
  assertRequiredAnswered(resolved, questions);
1177
1207
  }
@@ -1779,19 +1809,22 @@ ${note}`;
1779
1809
  * Resolves the input's custom-question answers into the quote's
1780
1810
  * `questionAnswers` payload (each tagged with a fresh `refid`), or `null` when
1781
1811
  * none were supplied. Fetches the activity's custom questions to validate
1782
- * against; throws on the first unmatched/ambiguous/invalid answer.
1812
+ * against; throws on the first unmatched/ambiguous/invalid answer. When
1813
+ * `acceptAllCustomQuestionIds` is set the fetch is skipped if every answer is
1814
+ * given by id (a by-name answer still needs the list to resolve).
1783
1815
  */
1784
1816
  async buildQuestionAnswers(input) {
1785
1817
  const answers = input.customQuestionAnswers ?? [];
1786
1818
  const requireRequired = input.requireRequiredQuestions ?? false;
1819
+ const acceptAllIds = input.acceptAllCustomQuestionIds ?? false;
1787
1820
  if (answers.length === 0 && !requireRequired) {
1788
1821
  return null;
1789
1822
  }
1790
- const questions = await this.deps.productService.getCustomQuestions(
1791
- input.activityId
1792
- );
1823
+ const needsQuestions = !acceptAllIds || requireRequired || answers.some((answer) => !isCustomQuestionId(answer.questionIdOrText));
1824
+ const questions = needsQuestions ? await this.deps.productService.getCustomQuestions(input.activityId) : [];
1793
1825
  const resolved = resolveCustomQuestionAnswers(answers, questions, {
1794
- requireRequired
1826
+ requireRequired,
1827
+ acceptAllIds
1795
1828
  });
1796
1829
  if (resolved.length === 0) {
1797
1830
  return null;
@@ -2099,6 +2132,21 @@ function requireNonEmpty(value, name, serviceName) {
2099
2132
  throw new Error(`${serviceName}: "${name}" is required`);
2100
2133
  }
2101
2134
  }
2135
+ var EXTENDABLE_SLUG_RE = /^([a-z0-9]+)[-_]backoffice[-_]api[-_]v1$/i;
2136
+ function resolveBaseApiUrl(apiUrl, platformSlug, serviceName) {
2137
+ const trimmed = apiUrl.replace(/\/+$/, "");
2138
+ const lastSegment = trimmed.slice(trimmed.lastIndexOf("/") + 1);
2139
+ const found = EXTENDABLE_SLUG_RE.exec(lastSegment);
2140
+ if (!found) return trimmed;
2141
+ const foundPlatform = found[1].toLowerCase();
2142
+ const expectedPlatform = EXTENDABLE_SLUG_RE.exec(platformSlug)[1].toLowerCase();
2143
+ if (foundPlatform !== expectedPlatform) {
2144
+ throw new Error(
2145
+ `${serviceName}: "apiUrl" carries a "${foundPlatform}" backoffice slug but this service targets "${expectedPlatform}" \u2014 pass the install's ${expectedPlatform} app endpoint`
2146
+ );
2147
+ }
2148
+ return trimmed.slice(0, trimmed.lastIndexOf("/"));
2149
+ }
2102
2150
  function createTokenManager(config) {
2103
2151
  return new TokenManager({
2104
2152
  secret: config.jwtSecret,
@@ -2149,7 +2197,7 @@ async function requestWithRetry(http, url, init, label, onResponse) {
2149
2197
  // src/internal/sdk-headers.ts
2150
2198
  var SDK_HEADER_NAME = "x-peek-sdk";
2151
2199
  var SDK_PLATFORM = "js";
2152
- var SDK_VERSION = "0.9.0";
2200
+ var SDK_VERSION = "0.9.2";
2153
2201
  var SDK_HEADER_VALUE = `${SDK_PLATFORM}-${SDK_VERSION}`;
2154
2202
 
2155
2203
  // src/internal/peek/graphql-client.ts
@@ -2197,10 +2245,8 @@ var GraphQLClient = class {
2197
2245
  );
2198
2246
  }
2199
2247
  endpoint(endpointName) {
2200
- const { apiUrl, baseUrl, appId, endpointPathPrefix } = this.options;
2201
- if (apiUrl) return apiUrl;
2202
- const prefix = endpointPathPrefix ? `${endpointPathPrefix}/` : "";
2203
- return `${baseUrl}/${appId}/${prefix}${endpointName}`;
2248
+ const { baseApiUrl, endpoints } = this.options;
2249
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpointName));
2204
2250
  }
2205
2251
  buildHeaders() {
2206
2252
  const headers = {
@@ -2576,6 +2622,23 @@ function fromReviewNode(node) {
2576
2622
  comment: node.comment ?? null
2577
2623
  };
2578
2624
  }
2625
+ function fromActivityReviewSummaryNode(node) {
2626
+ const meta = node.reviewMeta;
2627
+ return {
2628
+ productId: node.id,
2629
+ productName: node.name,
2630
+ avgRating: meta?.avgRating ?? null,
2631
+ countTotal: meta?.count ?? 0,
2632
+ countOneStar: meta?.oneStar ?? 0,
2633
+ countTwoStar: meta?.twoStar ?? 0,
2634
+ countThreeStar: meta?.threeStar ?? 0,
2635
+ countFourStar: meta?.fourStar ?? 0,
2636
+ countFiveStar: meta?.fiveStar ?? 0
2637
+ };
2638
+ }
2639
+ function fromActivityReviewSummaryNodes(nodes) {
2640
+ return nodes.map(fromActivityReviewSummaryNode);
2641
+ }
2579
2642
 
2580
2643
  // src/internal/peek/reviews/review-cursor.ts
2581
2644
  function encodeCursor(offset, pageSize) {
@@ -2611,6 +2674,23 @@ function buildReviewsQuery(fullCustomerAccess) {
2611
2674
  }
2612
2675
  `;
2613
2676
  }
2677
+ var REVIEW_SUMMARIES_QUERY = `
2678
+ query ReviewSummaries {
2679
+ activities {
2680
+ id
2681
+ name
2682
+ reviewMeta {
2683
+ avgRating
2684
+ count
2685
+ fiveStar
2686
+ fourStar
2687
+ threeStar
2688
+ twoStar
2689
+ oneStar
2690
+ }
2691
+ }
2692
+ }
2693
+ `;
2614
2694
  function buildReviewsVariables(params) {
2615
2695
  return {
2616
2696
  first: params.first,
@@ -2670,6 +2750,29 @@ var ReviewService = class {
2670
2750
  const edges = body.data?.reviews?.edges ?? [];
2671
2751
  return edges.map((edge) => fromReviewNode(edge.node));
2672
2752
  }
2753
+ /**
2754
+ * Returns an aggregate review summary for **every** activity — its average
2755
+ * rating and the count of reviews at each star level. This reads the same
2756
+ * activity list as `getAllProducts`; it carries no per-review detail or PII, so
2757
+ * it is unaffected by `fullCustomerAccess`. An activity with no reviews comes
2758
+ * back with a `null` `avgRating` and zero counts.
2759
+ *
2760
+ * @example
2761
+ * ```ts
2762
+ * const summaries = await peek.getReviewService().getAllReviewSummaries();
2763
+ * for (const s of summaries) {
2764
+ * console.log(s.productName, s.avgRating, s.countTotal);
2765
+ * }
2766
+ * ```
2767
+ */
2768
+ async getAllReviewSummaries() {
2769
+ const body = await this.client.request(
2770
+ SALES_ENDPOINT,
2771
+ REVIEW_SUMMARIES_QUERY,
2772
+ {}
2773
+ );
2774
+ return fromActivityReviewSummaryNodes(body.data?.activities ?? []);
2775
+ }
2673
2776
  validate(productId, reviewCount, reviewOffset) {
2674
2777
  if (!productId) {
2675
2778
  throw new Error(ERROR_PRODUCT_ID_REQUIRED);
@@ -3823,17 +3926,17 @@ var PeekAccessService = class {
3823
3926
  this.jwtSecret = config.jwtSecret;
3824
3927
  const logger = config.logger ?? noopLogger;
3825
3928
  const tokens = createTokenManager(config);
3929
+ const useRegistrySlug = hasApiUrl || isV2;
3826
3930
  const defaultBaseUrl = isV2 ? DEFAULT_V2_BASE_URL : DEFAULT_BASE_URL;
3931
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, V2_EXTENDABLE_SLUG, "PeekAccessService") : `${config.baseUrl ?? defaultBaseUrl}/${config.appId}`;
3827
3932
  this.client = new GraphQLClient({
3828
- apiUrl: config.apiUrl,
3829
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? defaultBaseUrl,
3830
- appId: hasApiUrl ? void 0 : config.appId,
3933
+ baseApiUrl,
3934
+ endpoints: peekApiEndpoints(useRegistrySlug),
3831
3935
  gatewayKey: config.gatewayKey,
3832
3936
  getToken: () => tokens.getToken(),
3833
3937
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
3834
3938
  logger,
3835
- fetchFn: config.fetch ?? globalThis.fetch,
3836
- endpointPathPrefix: !hasApiUrl && isV2 ? V2_EXTENDABLE_SLUG : void 0
3939
+ fetchFn: config.fetch ?? globalThis.fetch
3837
3940
  });
3838
3941
  this.productServiceOptions = {
3839
3942
  itemOptionsPageSize: config.itemOptionsPageSize
@@ -4187,7 +4290,11 @@ var PeekAccessService = class {
4187
4290
 
4188
4291
  // src/internal/cng/endpoints.ts
4189
4292
  var CNG_EXTENDABLE_SLUG = "cng_backoffice_api-v1";
4293
+ var CNG_PRODUCTS_ENDPOINT = "products";
4190
4294
  var PRODUCTS_PATH = "api/v2/app-registry/products?active=1";
4295
+ var CNG_API_ENDPOINTS = new ApiEndpoints({
4296
+ [CNG_PRODUCTS_ENDPOINT]: CNG_EXTENDABLE_SLUG
4297
+ });
4191
4298
 
4192
4299
  // src/models/cng/product.ts
4193
4300
  var ACTIVITY_PRODUCT_TYPE2 = "ACTIVITY";
@@ -4221,7 +4328,10 @@ var CngProductService = class {
4221
4328
  * ```
4222
4329
  */
4223
4330
  async getAllActivities() {
4224
- const body = await this.client.get(PRODUCTS_PATH);
4331
+ const body = await this.client.get(
4332
+ CNG_PRODUCTS_ENDPOINT,
4333
+ PRODUCTS_PATH
4334
+ );
4225
4335
  return fromProductNodes(body?.data ?? []);
4226
4336
  }
4227
4337
  };
@@ -4243,9 +4353,9 @@ var RestClient = class {
4243
4353
  * @throws {CngPermissionError} on HTTP 403 (app missing a permission)
4244
4354
  * @throws {CngApiError} on any other non-2xx response
4245
4355
  */
4246
- async get(path) {
4356
+ async get(endpoint, path) {
4247
4357
  const { logger } = this.options;
4248
- const url = this.endpoint(path);
4358
+ const url = this.endpoint(endpoint, path);
4249
4359
  logger.info("Making CNG request", { url, path });
4250
4360
  return requestWithRetry(
4251
4361
  this.options,
@@ -4267,10 +4377,9 @@ var RestClient = class {
4267
4377
  }
4268
4378
  );
4269
4379
  }
4270
- endpoint(path) {
4271
- const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4272
- if (apiUrl) return `${apiUrl}/${path}`;
4273
- return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4380
+ endpoint(endpoint, path) {
4381
+ const { baseApiUrl, endpoints } = this.options;
4382
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpoint), path);
4274
4383
  }
4275
4384
  buildHeaders() {
4276
4385
  return {
@@ -4300,11 +4409,10 @@ var CngAccessService = class {
4300
4409
  if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "CngAccessService");
4301
4410
  const logger = config.logger ?? noopLogger;
4302
4411
  const tokens = createTokenManager(config);
4412
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, CNG_EXTENDABLE_SLUG, "CngAccessService") : `${config.baseUrl ?? DEFAULT_BASE_URL2}/${config.appId}`;
4303
4413
  this.client = new RestClient({
4304
- apiUrl: config.apiUrl,
4305
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL2,
4306
- appId: hasApiUrl ? void 0 : config.appId,
4307
- extendableSlug: CNG_EXTENDABLE_SLUG,
4414
+ baseApiUrl,
4415
+ endpoints: CNG_API_ENDPOINTS,
4308
4416
  getToken: () => tokens.getToken(),
4309
4417
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
4310
4418
  logger,
@@ -4329,7 +4437,11 @@ var CngAccessService = class {
4329
4437
 
4330
4438
  // src/internal/acme/endpoints.ts
4331
4439
  var ACME_EXTENDABLE_SLUG = "acme_backoffice_api-v1";
4440
+ var ACME_TEMPLATES_ENDPOINT = "templates";
4332
4441
  var TEMPLATES_PATH = "v2/b2b/event/templates/names?pageSize=-1&page=1";
4442
+ var ACME_API_ENDPOINTS = new ApiEndpoints({
4443
+ [ACME_TEMPLATES_ENDPOINT]: ACME_EXTENDABLE_SLUG
4444
+ });
4333
4445
 
4334
4446
  // src/models/acme/product.ts
4335
4447
  var ACME_ACTIVITY_TYPE = "standard";
@@ -4367,6 +4479,7 @@ var AcmeProductService = class {
4367
4479
  */
4368
4480
  async getAllActivities() {
4369
4481
  const body = await this.client.get(
4482
+ ACME_TEMPLATES_ENDPOINT,
4370
4483
  TEMPLATES_PATH
4371
4484
  );
4372
4485
  const nodes = Array.isArray(body) ? body : body?.list ?? [];
@@ -4389,9 +4502,9 @@ var RestClient2 = class {
4389
4502
  * @throws {RateLimitError} on HTTP 429 after retries are exhausted
4390
4503
  * @throws {AcmeApiError} on any other non-2xx response
4391
4504
  */
4392
- async get(path) {
4505
+ async get(endpoint, path) {
4393
4506
  const { logger } = this.options;
4394
- const url = this.endpoint(path);
4507
+ const url = this.endpoint(endpoint, path);
4395
4508
  logger.info("Making ACME request", { url, path });
4396
4509
  return requestWithRetry(
4397
4510
  this.options,
@@ -4408,10 +4521,9 @@ var RestClient2 = class {
4408
4521
  }
4409
4522
  );
4410
4523
  }
4411
- endpoint(path) {
4412
- const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4413
- if (apiUrl) return `${apiUrl}/${path}`;
4414
- return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4524
+ endpoint(endpoint, path) {
4525
+ const { baseApiUrl, endpoints } = this.options;
4526
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpoint), path);
4415
4527
  }
4416
4528
  buildHeaders() {
4417
4529
  return {
@@ -4435,11 +4547,10 @@ var AcmeAccessService = class {
4435
4547
  if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "AcmeAccessService");
4436
4548
  const logger = config.logger ?? noopLogger;
4437
4549
  const tokens = createTokenManager(config);
4550
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, ACME_EXTENDABLE_SLUG, "AcmeAccessService") : `${config.baseUrl ?? DEFAULT_BASE_URL3}/${config.appId}`;
4438
4551
  this.client = new RestClient2({
4439
- apiUrl: config.apiUrl,
4440
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL3,
4441
- appId: hasApiUrl ? void 0 : config.appId,
4442
- extendableSlug: ACME_EXTENDABLE_SLUG,
4552
+ baseApiUrl,
4553
+ endpoints: ACME_API_ENDPOINTS,
4443
4554
  getToken: () => tokens.getToken(),
4444
4555
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
4445
4556
  logger,