@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/dist/index.js CHANGED
@@ -1,9 +1,29 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import * as jwt from 'jsonwebtoken';
3
3
 
4
+ // src/internal/api-endpoints.ts
5
+ var ApiEndpoints = class {
6
+ constructor(paths) {
7
+ this.paths = paths;
8
+ }
9
+ paths;
10
+ /** The relative path (slug) for `endpoint`, appended to the base API URL. */
11
+ pathFor(endpoint) {
12
+ return this.paths[endpoint];
13
+ }
14
+ };
15
+ function joinUrl(...parts) {
16
+ return parts.filter((part) => part.length > 0).join("/");
17
+ }
18
+
4
19
  // src/internal/peek/gateway-endpoints.ts
5
20
  var SALES_ENDPOINT = "sales";
6
21
  var V2_EXTENDABLE_SLUG = "peek_backoffice_api-v1";
22
+ function peekApiEndpoints(useRegistrySlug) {
23
+ return new ApiEndpoints({
24
+ [SALES_ENDPOINT]: useRegistrySlug ? V2_EXTENDABLE_SLUG : SALES_ENDPOINT
25
+ });
26
+ }
7
27
 
8
28
  // src/internal/peek/account-users/account-user-converter.ts
9
29
  var ACTIVE_STATUS = "ACTIVE";
@@ -1070,6 +1090,9 @@ var QUESTION_TYPE_SELECT_ONE = "SELECT_ONE";
1070
1090
  var QUESTION_TYPE_LOCATION = "LOCATION";
1071
1091
  var QUESTION_ID_REGEX = /^cq_[a-z0-9]+$/;
1072
1092
  var OPTION_ID_REGEX = /^cqao_[a-z0-9]+$/;
1093
+ function isCustomQuestionId(idOrText) {
1094
+ return QUESTION_ID_REGEX.test(idOrText);
1095
+ }
1073
1096
  var CHECKBOX_TRUE = /* @__PURE__ */ new Set(["yes", "true"]);
1074
1097
  var CHECKBOX_VALID = /* @__PURE__ */ new Set(["yes", "no", "true", "false"]);
1075
1098
  var CHECKBOX_TEXT_TRUE = "Yes";
@@ -1087,10 +1110,13 @@ var errMissingRequired = (text, id) => `Custom question "${text}" (${id}) is req
1087
1110
  function normalize(value) {
1088
1111
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1089
1112
  }
1090
- function resolveQuestion(idOrText, questions) {
1113
+ function resolveQuestion(idOrText, questions, acceptAllIds) {
1091
1114
  if (QUESTION_ID_REGEX.test(idOrText)) {
1092
1115
  const byId = questions.find((question) => question.id === idOrText);
1093
- if (!byId) throw new Error(errUnknownQuestionId(idOrText));
1116
+ if (!byId) {
1117
+ if (acceptAllIds) return null;
1118
+ throw new Error(errUnknownQuestionId(idOrText));
1119
+ }
1094
1120
  return byId;
1095
1121
  }
1096
1122
  const target = normalize(idOrText);
@@ -1115,8 +1141,11 @@ function resolveOption(value, question) {
1115
1141
  if (matches.length > 1) throw new Error(errAmbiguousOption(value, question.id));
1116
1142
  return matches[0];
1117
1143
  }
1118
- function resolveOne(answer, questions) {
1119
- const question = resolveQuestion(answer.questionIdOrText, questions);
1144
+ function resolveOne(answer, questions, acceptAllIds) {
1145
+ const question = resolveQuestion(answer.questionIdOrText, questions, acceptAllIds);
1146
+ if (!question) {
1147
+ return { questionId: answer.questionIdOrText, questionAnswerText: answer.value };
1148
+ }
1120
1149
  if (question.perGuest) {
1121
1150
  throw new Error(errPerGuestUnsupported(question.id));
1122
1151
  }
@@ -1149,7 +1178,8 @@ function resolveOne(answer, questions) {
1149
1178
  }
1150
1179
  }
1151
1180
  function resolveCustomQuestionAnswers(answers, questions, options) {
1152
- const resolved = answers.map((answer) => resolveOne(answer, questions));
1181
+ const acceptAllIds = options?.acceptAllIds ?? false;
1182
+ const resolved = answers.map((answer) => resolveOne(answer, questions, acceptAllIds));
1153
1183
  if (options?.requireRequired) {
1154
1184
  assertRequiredAnswered(resolved, questions);
1155
1185
  }
@@ -1757,19 +1787,22 @@ ${note}`;
1757
1787
  * Resolves the input's custom-question answers into the quote's
1758
1788
  * `questionAnswers` payload (each tagged with a fresh `refid`), or `null` when
1759
1789
  * none were supplied. Fetches the activity's custom questions to validate
1760
- * against; throws on the first unmatched/ambiguous/invalid answer.
1790
+ * against; throws on the first unmatched/ambiguous/invalid answer. When
1791
+ * `acceptAllCustomQuestionIds` is set the fetch is skipped if every answer is
1792
+ * given by id (a by-name answer still needs the list to resolve).
1761
1793
  */
1762
1794
  async buildQuestionAnswers(input) {
1763
1795
  const answers = input.customQuestionAnswers ?? [];
1764
1796
  const requireRequired = input.requireRequiredQuestions ?? false;
1797
+ const acceptAllIds = input.acceptAllCustomQuestionIds ?? false;
1765
1798
  if (answers.length === 0 && !requireRequired) {
1766
1799
  return null;
1767
1800
  }
1768
- const questions = await this.deps.productService.getCustomQuestions(
1769
- input.activityId
1770
- );
1801
+ const needsQuestions = !acceptAllIds || requireRequired || answers.some((answer) => !isCustomQuestionId(answer.questionIdOrText));
1802
+ const questions = needsQuestions ? await this.deps.productService.getCustomQuestions(input.activityId) : [];
1771
1803
  const resolved = resolveCustomQuestionAnswers(answers, questions, {
1772
- requireRequired
1804
+ requireRequired,
1805
+ acceptAllIds
1773
1806
  });
1774
1807
  if (resolved.length === 0) {
1775
1808
  return null;
@@ -2077,6 +2110,21 @@ function requireNonEmpty(value, name, serviceName) {
2077
2110
  throw new Error(`${serviceName}: "${name}" is required`);
2078
2111
  }
2079
2112
  }
2113
+ var EXTENDABLE_SLUG_RE = /^([a-z0-9]+)[-_]backoffice[-_]api[-_]v1$/i;
2114
+ function resolveBaseApiUrl(apiUrl, platformSlug, serviceName) {
2115
+ const trimmed = apiUrl.replace(/\/+$/, "");
2116
+ const lastSegment = trimmed.slice(trimmed.lastIndexOf("/") + 1);
2117
+ const found = EXTENDABLE_SLUG_RE.exec(lastSegment);
2118
+ if (!found) return trimmed;
2119
+ const foundPlatform = found[1].toLowerCase();
2120
+ const expectedPlatform = EXTENDABLE_SLUG_RE.exec(platformSlug)[1].toLowerCase();
2121
+ if (foundPlatform !== expectedPlatform) {
2122
+ throw new Error(
2123
+ `${serviceName}: "apiUrl" carries a "${foundPlatform}" backoffice slug but this service targets "${expectedPlatform}" \u2014 pass the install's ${expectedPlatform} app endpoint`
2124
+ );
2125
+ }
2126
+ return trimmed.slice(0, trimmed.lastIndexOf("/"));
2127
+ }
2080
2128
  function createTokenManager(config) {
2081
2129
  return new TokenManager({
2082
2130
  secret: config.jwtSecret,
@@ -2127,7 +2175,7 @@ async function requestWithRetry(http, url, init, label, onResponse) {
2127
2175
  // src/internal/sdk-headers.ts
2128
2176
  var SDK_HEADER_NAME = "x-peek-sdk";
2129
2177
  var SDK_PLATFORM = "js";
2130
- var SDK_VERSION = "0.9.0";
2178
+ var SDK_VERSION = "0.9.2";
2131
2179
  var SDK_HEADER_VALUE = `${SDK_PLATFORM}-${SDK_VERSION}`;
2132
2180
 
2133
2181
  // src/internal/peek/graphql-client.ts
@@ -2175,10 +2223,8 @@ var GraphQLClient = class {
2175
2223
  );
2176
2224
  }
2177
2225
  endpoint(endpointName) {
2178
- const { apiUrl, baseUrl, appId, endpointPathPrefix } = this.options;
2179
- if (apiUrl) return apiUrl;
2180
- const prefix = endpointPathPrefix ? `${endpointPathPrefix}/` : "";
2181
- return `${baseUrl}/${appId}/${prefix}${endpointName}`;
2226
+ const { baseApiUrl, endpoints } = this.options;
2227
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpointName));
2182
2228
  }
2183
2229
  buildHeaders() {
2184
2230
  const headers = {
@@ -2554,6 +2600,23 @@ function fromReviewNode(node) {
2554
2600
  comment: node.comment ?? null
2555
2601
  };
2556
2602
  }
2603
+ function fromActivityReviewSummaryNode(node) {
2604
+ const meta = node.reviewMeta;
2605
+ return {
2606
+ productId: node.id,
2607
+ productName: node.name,
2608
+ avgRating: meta?.avgRating ?? null,
2609
+ countTotal: meta?.count ?? 0,
2610
+ countOneStar: meta?.oneStar ?? 0,
2611
+ countTwoStar: meta?.twoStar ?? 0,
2612
+ countThreeStar: meta?.threeStar ?? 0,
2613
+ countFourStar: meta?.fourStar ?? 0,
2614
+ countFiveStar: meta?.fiveStar ?? 0
2615
+ };
2616
+ }
2617
+ function fromActivityReviewSummaryNodes(nodes) {
2618
+ return nodes.map(fromActivityReviewSummaryNode);
2619
+ }
2557
2620
 
2558
2621
  // src/internal/peek/reviews/review-cursor.ts
2559
2622
  function encodeCursor(offset, pageSize) {
@@ -2589,6 +2652,23 @@ function buildReviewsQuery(fullCustomerAccess) {
2589
2652
  }
2590
2653
  `;
2591
2654
  }
2655
+ var REVIEW_SUMMARIES_QUERY = `
2656
+ query ReviewSummaries {
2657
+ activities {
2658
+ id
2659
+ name
2660
+ reviewMeta {
2661
+ avgRating
2662
+ count
2663
+ fiveStar
2664
+ fourStar
2665
+ threeStar
2666
+ twoStar
2667
+ oneStar
2668
+ }
2669
+ }
2670
+ }
2671
+ `;
2592
2672
  function buildReviewsVariables(params) {
2593
2673
  return {
2594
2674
  first: params.first,
@@ -2648,6 +2728,29 @@ var ReviewService = class {
2648
2728
  const edges = body.data?.reviews?.edges ?? [];
2649
2729
  return edges.map((edge) => fromReviewNode(edge.node));
2650
2730
  }
2731
+ /**
2732
+ * Returns an aggregate review summary for **every** activity — its average
2733
+ * rating and the count of reviews at each star level. This reads the same
2734
+ * activity list as `getAllProducts`; it carries no per-review detail or PII, so
2735
+ * it is unaffected by `fullCustomerAccess`. An activity with no reviews comes
2736
+ * back with a `null` `avgRating` and zero counts.
2737
+ *
2738
+ * @example
2739
+ * ```ts
2740
+ * const summaries = await peek.getReviewService().getAllReviewSummaries();
2741
+ * for (const s of summaries) {
2742
+ * console.log(s.productName, s.avgRating, s.countTotal);
2743
+ * }
2744
+ * ```
2745
+ */
2746
+ async getAllReviewSummaries() {
2747
+ const body = await this.client.request(
2748
+ SALES_ENDPOINT,
2749
+ REVIEW_SUMMARIES_QUERY,
2750
+ {}
2751
+ );
2752
+ return fromActivityReviewSummaryNodes(body.data?.activities ?? []);
2753
+ }
2651
2754
  validate(productId, reviewCount, reviewOffset) {
2652
2755
  if (!productId) {
2653
2756
  throw new Error(ERROR_PRODUCT_ID_REQUIRED);
@@ -3801,17 +3904,17 @@ var PeekAccessService = class {
3801
3904
  this.jwtSecret = config.jwtSecret;
3802
3905
  const logger = config.logger ?? noopLogger;
3803
3906
  const tokens = createTokenManager(config);
3907
+ const useRegistrySlug = hasApiUrl || isV2;
3804
3908
  const defaultBaseUrl = isV2 ? DEFAULT_V2_BASE_URL : DEFAULT_BASE_URL;
3909
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, V2_EXTENDABLE_SLUG, "PeekAccessService") : `${config.baseUrl ?? defaultBaseUrl}/${config.appId}`;
3805
3910
  this.client = new GraphQLClient({
3806
- apiUrl: config.apiUrl,
3807
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? defaultBaseUrl,
3808
- appId: hasApiUrl ? void 0 : config.appId,
3911
+ baseApiUrl,
3912
+ endpoints: peekApiEndpoints(useRegistrySlug),
3809
3913
  gatewayKey: config.gatewayKey,
3810
3914
  getToken: () => tokens.getToken(),
3811
3915
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
3812
3916
  logger,
3813
- fetchFn: config.fetch ?? globalThis.fetch,
3814
- endpointPathPrefix: !hasApiUrl && isV2 ? V2_EXTENDABLE_SLUG : void 0
3917
+ fetchFn: config.fetch ?? globalThis.fetch
3815
3918
  });
3816
3919
  this.productServiceOptions = {
3817
3920
  itemOptionsPageSize: config.itemOptionsPageSize
@@ -4165,7 +4268,11 @@ var PeekAccessService = class {
4165
4268
 
4166
4269
  // src/internal/cng/endpoints.ts
4167
4270
  var CNG_EXTENDABLE_SLUG = "cng_backoffice_api-v1";
4271
+ var CNG_PRODUCTS_ENDPOINT = "products";
4168
4272
  var PRODUCTS_PATH = "api/v2/app-registry/products?active=1";
4273
+ var CNG_API_ENDPOINTS = new ApiEndpoints({
4274
+ [CNG_PRODUCTS_ENDPOINT]: CNG_EXTENDABLE_SLUG
4275
+ });
4169
4276
 
4170
4277
  // src/models/cng/product.ts
4171
4278
  var ACTIVITY_PRODUCT_TYPE2 = "ACTIVITY";
@@ -4199,7 +4306,10 @@ var CngProductService = class {
4199
4306
  * ```
4200
4307
  */
4201
4308
  async getAllActivities() {
4202
- const body = await this.client.get(PRODUCTS_PATH);
4309
+ const body = await this.client.get(
4310
+ CNG_PRODUCTS_ENDPOINT,
4311
+ PRODUCTS_PATH
4312
+ );
4203
4313
  return fromProductNodes(body?.data ?? []);
4204
4314
  }
4205
4315
  };
@@ -4221,9 +4331,9 @@ var RestClient = class {
4221
4331
  * @throws {CngPermissionError} on HTTP 403 (app missing a permission)
4222
4332
  * @throws {CngApiError} on any other non-2xx response
4223
4333
  */
4224
- async get(path) {
4334
+ async get(endpoint, path) {
4225
4335
  const { logger } = this.options;
4226
- const url = this.endpoint(path);
4336
+ const url = this.endpoint(endpoint, path);
4227
4337
  logger.info("Making CNG request", { url, path });
4228
4338
  return requestWithRetry(
4229
4339
  this.options,
@@ -4245,10 +4355,9 @@ var RestClient = class {
4245
4355
  }
4246
4356
  );
4247
4357
  }
4248
- endpoint(path) {
4249
- const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4250
- if (apiUrl) return `${apiUrl}/${path}`;
4251
- return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4358
+ endpoint(endpoint, path) {
4359
+ const { baseApiUrl, endpoints } = this.options;
4360
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpoint), path);
4252
4361
  }
4253
4362
  buildHeaders() {
4254
4363
  return {
@@ -4278,11 +4387,10 @@ var CngAccessService = class {
4278
4387
  if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "CngAccessService");
4279
4388
  const logger = config.logger ?? noopLogger;
4280
4389
  const tokens = createTokenManager(config);
4390
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, CNG_EXTENDABLE_SLUG, "CngAccessService") : `${config.baseUrl ?? DEFAULT_BASE_URL2}/${config.appId}`;
4281
4391
  this.client = new RestClient({
4282
- apiUrl: config.apiUrl,
4283
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL2,
4284
- appId: hasApiUrl ? void 0 : config.appId,
4285
- extendableSlug: CNG_EXTENDABLE_SLUG,
4392
+ baseApiUrl,
4393
+ endpoints: CNG_API_ENDPOINTS,
4286
4394
  getToken: () => tokens.getToken(),
4287
4395
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
4288
4396
  logger,
@@ -4307,7 +4415,11 @@ var CngAccessService = class {
4307
4415
 
4308
4416
  // src/internal/acme/endpoints.ts
4309
4417
  var ACME_EXTENDABLE_SLUG = "acme_backoffice_api-v1";
4418
+ var ACME_TEMPLATES_ENDPOINT = "templates";
4310
4419
  var TEMPLATES_PATH = "v2/b2b/event/templates/names?pageSize=-1&page=1";
4420
+ var ACME_API_ENDPOINTS = new ApiEndpoints({
4421
+ [ACME_TEMPLATES_ENDPOINT]: ACME_EXTENDABLE_SLUG
4422
+ });
4311
4423
 
4312
4424
  // src/models/acme/product.ts
4313
4425
  var ACME_ACTIVITY_TYPE = "standard";
@@ -4345,6 +4457,7 @@ var AcmeProductService = class {
4345
4457
  */
4346
4458
  async getAllActivities() {
4347
4459
  const body = await this.client.get(
4460
+ ACME_TEMPLATES_ENDPOINT,
4348
4461
  TEMPLATES_PATH
4349
4462
  );
4350
4463
  const nodes = Array.isArray(body) ? body : body?.list ?? [];
@@ -4367,9 +4480,9 @@ var RestClient2 = class {
4367
4480
  * @throws {RateLimitError} on HTTP 429 after retries are exhausted
4368
4481
  * @throws {AcmeApiError} on any other non-2xx response
4369
4482
  */
4370
- async get(path) {
4483
+ async get(endpoint, path) {
4371
4484
  const { logger } = this.options;
4372
- const url = this.endpoint(path);
4485
+ const url = this.endpoint(endpoint, path);
4373
4486
  logger.info("Making ACME request", { url, path });
4374
4487
  return requestWithRetry(
4375
4488
  this.options,
@@ -4386,10 +4499,9 @@ var RestClient2 = class {
4386
4499
  }
4387
4500
  );
4388
4501
  }
4389
- endpoint(path) {
4390
- const { apiUrl, baseUrl, appId, extendableSlug } = this.options;
4391
- if (apiUrl) return `${apiUrl}/${path}`;
4392
- return `${baseUrl}/${appId}/${extendableSlug}/${path}`;
4502
+ endpoint(endpoint, path) {
4503
+ const { baseApiUrl, endpoints } = this.options;
4504
+ return joinUrl(baseApiUrl, endpoints.pathFor(endpoint), path);
4393
4505
  }
4394
4506
  buildHeaders() {
4395
4507
  return {
@@ -4413,11 +4525,10 @@ var AcmeAccessService = class {
4413
4525
  if (!hasApiUrl) requireNonEmpty(config.appId ?? "", "appId", "AcmeAccessService");
4414
4526
  const logger = config.logger ?? noopLogger;
4415
4527
  const tokens = createTokenManager(config);
4528
+ const baseApiUrl = hasApiUrl ? resolveBaseApiUrl(config.apiUrl, ACME_EXTENDABLE_SLUG, "AcmeAccessService") : `${config.baseUrl ?? DEFAULT_BASE_URL3}/${config.appId}`;
4416
4529
  this.client = new RestClient2({
4417
- apiUrl: config.apiUrl,
4418
- baseUrl: hasApiUrl ? void 0 : config.baseUrl ?? DEFAULT_BASE_URL3,
4419
- appId: hasApiUrl ? void 0 : config.appId,
4420
- extendableSlug: ACME_EXTENDABLE_SLUG,
4530
+ baseApiUrl,
4531
+ endpoints: ACME_API_ENDPOINTS,
4421
4532
  getToken: () => tokens.getToken(),
4422
4533
  retryDelaysMs: config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS,
4423
4534
  logger,