@peektravel/app-utilities 0.8.0 → 0.8.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.cjs CHANGED
@@ -207,6 +207,13 @@ function resolveAccessOptions(options) {
207
207
  }
208
208
 
209
209
  // src/errors.ts
210
+ var FORBIDDEN_STATUS = 403;
211
+ var PERMISSION_MESSAGE_PREFIX = "CNG request forbidden: the app is missing the required permission";
212
+ var PERMISSION_MESSAGE_UNKNOWN = "CNG request forbidden: the app is missing a required permission";
213
+ function buildPermissionMessage(permissions) {
214
+ if (permissions.length === 0) return PERMISSION_MESSAGE_UNKNOWN;
215
+ return `${PERMISSION_MESSAGE_PREFIX}${permissions.length > 1 ? "s" : ""}: ${permissions.join(", ")}`;
216
+ }
210
217
  var AdminAccountRequiredError = class extends Error {
211
218
  /** The HTTP status that triggered this error. */
212
219
  statusCode = 418;
@@ -270,6 +277,23 @@ var CngApiError = class extends Error {
270
277
  this.body = body;
271
278
  }
272
279
  };
280
+ var CngPermissionError = class extends Error {
281
+ /** The HTTP status that triggered this error. */
282
+ statusCode = FORBIDDEN_STATUS;
283
+ /**
284
+ * The permissions the gateway reported as missing, e.g. `["products:read"]`.
285
+ * Empty when the response body did not name any.
286
+ */
287
+ permissions;
288
+ /** The raw response body (parsed JSON when possible, otherwise text). */
289
+ body;
290
+ constructor(permissions, body, message) {
291
+ super(message ?? buildPermissionMessage(permissions));
292
+ this.name = "CngPermissionError";
293
+ this.permissions = permissions;
294
+ this.body = body;
295
+ }
296
+ };
273
297
  var AcmeApiError = class extends Error {
274
298
  /** The HTTP status that triggered this error. */
275
299
  statusCode;
@@ -1081,6 +1105,7 @@ var errInvalidCheckbox = (v, id) => `Checkbox answer for question "${id}" must b
1081
1105
  var errUnknownOptionId = (v, id) => `No option matches id "${v}" for question "${id}"`;
1082
1106
  var errNoOptionMatch = (v, id) => `No option matches "${v}" for question "${id}"`;
1083
1107
  var errAmbiguousOption = (v, id) => `Option "${v}" matches more than one option for question "${id}"`;
1108
+ var errMissingRequired = (text, id) => `Custom question "${text}" (${id}) is required but was not answered`;
1084
1109
  function normalize(value) {
1085
1110
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1086
1111
  }
@@ -1145,8 +1170,20 @@ function resolveOne(answer, questions) {
1145
1170
  throw new Error(errUnsupportedType(question.questionType, question.id));
1146
1171
  }
1147
1172
  }
1148
- function resolveCustomQuestionAnswers(answers, questions) {
1149
- return answers.map((answer) => resolveOne(answer, questions));
1173
+ function resolveCustomQuestionAnswers(answers, questions, options) {
1174
+ const resolved = answers.map((answer) => resolveOne(answer, questions));
1175
+ if (options?.requireRequired) {
1176
+ assertRequiredAnswered(resolved, questions);
1177
+ }
1178
+ return resolved;
1179
+ }
1180
+ function assertRequiredAnswered(resolved, questions) {
1181
+ const answered = new Set(resolved.map((answer) => answer.questionId));
1182
+ for (const question of questions) {
1183
+ if (question.isRequired && !question.perGuest && !answered.has(question.id)) {
1184
+ throw new Error(errMissingRequired(question.questionText, question.id));
1185
+ }
1186
+ }
1150
1187
  }
1151
1188
 
1152
1189
  // src/internal/peek/bookings/booking-service.ts
@@ -1663,8 +1700,9 @@ ${note}`;
1663
1700
  *
1664
1701
  * @throws {Error} when `activityId`, `availabilityTimeId`, a ticket
1665
1702
  * `resourceOptionId`/positive `quantity`, or the guest `name` is missing; when
1666
- * `markAsPaid` is set without an `idempotencyKey`; or when the quote/order
1667
- * mutations fail.
1703
+ * `markAsPaid` is set without an `idempotencyKey`; when
1704
+ * `requireRequiredQuestions` is set and a required custom question is left
1705
+ * unanswered; or when the quote/order mutations fail.
1668
1706
  */
1669
1707
  async create(input) {
1670
1708
  validateCreateInput(input);
@@ -1744,15 +1782,22 @@ ${note}`;
1744
1782
  * against; throws on the first unmatched/ambiguous/invalid answer.
1745
1783
  */
1746
1784
  async buildQuestionAnswers(input) {
1747
- const answers = input.customQuestionAnswers;
1748
- if (!answers || answers.length === 0) {
1785
+ const answers = input.customQuestionAnswers ?? [];
1786
+ const requireRequired = input.requireRequiredQuestions ?? false;
1787
+ if (answers.length === 0 && !requireRequired) {
1749
1788
  return null;
1750
1789
  }
1751
1790
  const questions = await this.deps.productService.getCustomQuestions(
1752
1791
  input.activityId
1753
1792
  );
1754
- return resolveCustomQuestionAnswers(answers, questions).map((resolved) => ({
1755
- ...resolved,
1793
+ const resolved = resolveCustomQuestionAnswers(answers, questions, {
1794
+ requireRequired
1795
+ });
1796
+ if (resolved.length === 0) {
1797
+ return null;
1798
+ }
1799
+ return resolved.map((answer) => ({
1800
+ ...answer,
1756
1801
  refid: crypto.randomUUID()
1757
1802
  }));
1758
1803
  }
@@ -4135,7 +4180,7 @@ var PeekAccessService = class {
4135
4180
 
4136
4181
  // src/internal/cng/endpoints.ts
4137
4182
  var CNG_EXTENDABLE_SLUG = "cng_backoffice_api-v1";
4138
- var PRODUCTS_PATH = "api/v2/commerce-config/products";
4183
+ var PRODUCTS_PATH = "api/v2/app-registry/products?active=1";
4139
4184
 
4140
4185
  // src/models/cng/product.ts
4141
4186
  var ACTIVITY_PRODUCT_TYPE2 = "ACTIVITY";
@@ -4146,14 +4191,11 @@ function fromProductNodes(nodes) {
4146
4191
  }
4147
4192
  function fromProductNode(node) {
4148
4193
  return {
4149
- productId: node.id || "",
4194
+ productId: node.id == null ? "" : String(node.id),
4150
4195
  name: node.name || "",
4151
- type: node.product_type || ACTIVITY_PRODUCT_TYPE2,
4152
- color: node.color_hex || "",
4153
- tickets: (node.tickets ?? []).map((ticket) => ({
4154
- id: ticket.id,
4155
- name: ticket.name
4156
- }))
4196
+ type: ACTIVITY_PRODUCT_TYPE2,
4197
+ color: node.access_control_color_hex || "",
4198
+ tickets: []
4157
4199
  };
4158
4200
  }
4159
4201
 
@@ -4172,15 +4214,13 @@ var CngProductService = class {
4172
4214
  * ```
4173
4215
  */
4174
4216
  async getAllActivities() {
4175
- const body = await this.client.get(
4176
- PRODUCTS_PATH
4177
- );
4178
- const nodes = Array.isArray(body) ? body : body?.products ?? [];
4179
- return fromProductNodes(nodes ?? []);
4217
+ const body = await this.client.get(PRODUCTS_PATH);
4218
+ return fromProductNodes(body?.data ?? []);
4180
4219
  }
4181
4220
  };
4182
4221
 
4183
4222
  // src/internal/cng/rest-client.ts
4223
+ var PERMISSION_SEPARATOR = ": ";
4184
4224
  var RestClient = class {
4185
4225
  constructor(options) {
4186
4226
  this.options = options;
@@ -4193,6 +4233,7 @@ var RestClient = class {
4193
4233
  *
4194
4234
  * @throws {AdminAccountRequiredError} on HTTP 418
4195
4235
  * @throws {RateLimitError} on HTTP 429 after retries are exhausted
4236
+ * @throws {CngPermissionError} on HTTP 403 (app missing a permission)
4196
4237
  * @throws {CngApiError} on any other non-2xx response
4197
4238
  */
4198
4239
  async get(path) {
@@ -4206,6 +4247,11 @@ var RestClient = class {
4206
4247
  path,
4207
4248
  async (response) => {
4208
4249
  const body = await parseBody(response);
4250
+ if (response.status === FORBIDDEN_STATUS) {
4251
+ const permissions = extractMissingPermissions(body);
4252
+ logger.warn(`Missing permission for ${path} (HTTP 403)`, { url, permissions });
4253
+ throw new CngPermissionError(permissions, body);
4254
+ }
4209
4255
  if (!response.ok) {
4210
4256
  logger.error(`CNG request failed with HTTP ${response.status}`, { url });
4211
4257
  throw new CngApiError(response.status, body);
@@ -4226,6 +4272,12 @@ var RestClient = class {
4226
4272
  };
4227
4273
  }
4228
4274
  };
4275
+ function extractMissingPermissions(body) {
4276
+ if (typeof body !== "object" || body === null) return [];
4277
+ const raw = body.errors?.permission;
4278
+ if (!Array.isArray(raw)) return [];
4279
+ return raw.filter((entry) => typeof entry === "string").map((entry) => entry.split(PERMISSION_SEPARATOR).pop().trim()).filter((permission) => permission.length > 0);
4280
+ }
4229
4281
 
4230
4282
  // src/cng-access-service.ts
4231
4283
  var DEFAULT_BASE_URL2 = "https://app-registry.peeklabs.com/installations-api";
@@ -4581,6 +4633,7 @@ exports.AvailabilityService = AvailabilityService;
4581
4633
  exports.BookingService = BookingService;
4582
4634
  exports.CngAccessService = CngAccessService;
4583
4635
  exports.CngApiError = CngApiError;
4636
+ exports.CngPermissionError = CngPermissionError;
4584
4637
  exports.CngProductService = CngProductService;
4585
4638
  exports.DailyNoteService = DailyNoteService;
4586
4639
  exports.INSTALL_STATUSES = INSTALL_STATUSES;