@internetarchive/ia-book-actions 1.4.4-alpha9 → 1.4.6-alpha1

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/index.html CHANGED
@@ -360,7 +360,33 @@
360
360
  });
361
361
  });
362
362
 
363
-
363
+ // WEBDEV-8322 repro: fail only create_token (via ?failAction=create_token),
364
+ // leaving renew_loan (and every other action) succeeding normally —
365
+ // see actions-handler-service.js's shouldReturnError.
366
+ document.querySelectorAll('.failCreateToken input[type=checkbox]').forEach((item) => {
367
+ if (window.location.href.indexOf('?failAction=create_token') !== -1) {
368
+ item.checked = true;
369
+ }
370
+ item.addEventListener('click', (e) => {
371
+ console.log(`[Demo] fail-create_token-only mode ${e.target.checked ? 'enabled' : 'disabled'}`);
372
+
373
+ var searchParams = new URLSearchParams(window.location.search);
374
+ var separate = '';
375
+ if (e.target.checked) {
376
+ separate = '?';
377
+ searchParams.set('failAction', 'create_token');
378
+ } else {
379
+ separate = '';
380
+ searchParams.delete('failAction');
381
+ }
382
+
383
+ if (history.pushState) {
384
+ var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + separate + searchParams;
385
+ window.history.pushState({path:newurl},'',newurl);
386
+ }
387
+ });
388
+ });
389
+
364
390
  document.querySelectorAll('.userState input[type=checkbox]').forEach((item) => {
365
391
  item.addEventListener('click', (e) => {
366
392
  if (e.target.checked) {
@@ -726,6 +752,17 @@
726
752
  </span>
727
753
  </td>
728
754
  </tr>
755
+ <tr colspan='5'>
756
+ <td class="failCreateToken">
757
+ <label><input type="checkbox" name="failCreateToken" value="failCreateToken" />Fail create_token only (renew_loan still succeeds)</label>
758
+ <span style="color: #666; font-size: 11px; display: block; padding: 0 10px 10px;">
759
+ WEBDEV-8322 repro: unlike the checkbox above, this leaves renew_loan
760
+ succeeding and fails only the create_token call right after it — turn
761
+ this on, then run scenario 2 → 3 (or 2 → 4) below. Expect the error
762
+ modal, and the bar/timer resetting to Borrow/0 (no retry).
763
+ </span>
764
+ </td>
765
+ </tr>
729
766
  <tr>
730
767
  <td class="userState">
731
768
  <label><input type="checkbox" name="user" value="isAdmin" />isAdmin</label>
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.4-alpha9",
10
+ "version": "1.4.6-alpha1",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -0,0 +1,17 @@
1
+ import { html } from 'lit';
2
+
3
+ export const infoIcon = html`<svg
4
+ height="20"
5
+ width="20"
6
+ viewBox="0 0 100 100"
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ >
9
+ <g fill="none" fill-rule="evenodd">
10
+ <path d="m0 0h100v100h-100z" fill="#fff" />
11
+ <path
12
+ d="m49.8315487 0h.1702245c6.7356878 0 13.1853038 1.31117332 19.3488483 3.93351997 6.1635444 2.62234664 11.4854233 6.15778963 15.9656369 10.60632903 4.4802135 4.4485394 8.0478347 9.7522946 10.7028636 15.9112655 2.655029 6.1589709 3.980878 12.6038012 3.9789971 19.3344909.0567419 6.6716279-1.1702933 13.0585776-3.6811042 19.1608491-2.510811 6.1022715-6.106803 11.5206067-10.7879759 16.2550055-9.7027949 9.7522946-21.4884754 14.6851412-35.3570414 14.79854h-.1702244c-6.7333236 0-13.1829397-1.3111733-19.3488483-3.93352-6.1659087-2.6223466-11.4877876-6.1577896-15.9656369-10.606329s-8.04547055-9.7522946-10.7028637-15.9112655c-2.65739314-6.1589709-3.9844243-12.6038012-3.98254337-19.3344909-.05674149-6.6716279 1.17029325-13.0585776 3.68110421-19.1608491 2.51081095-6.1022715 6.10680292-11.5206067 10.78797586-16.2550055 9.7027949-9.75229456 21.4884754-14.68514123 35.3570414-14.79854zm12.6566146 26.4757998c1.6745578-1.6828001 2.5118367-3.6747334 2.5118367-5.9757998 0-2.4126333-.8095238-4.4324583-2.4285714-6.059475s-3.6289796-2.440525-6.0297959-2.440525c-2.4008164 0-4.4107483.8135083-6.029796 2.440525-1.6745578 1.6270167-2.5118367 3.6468417-2.5118367 6.059475 0 2.1871753.8372789 4.1791086 2.5118367 5.9757998 1.6745579 1.6828001 3.6844898 2.5242002 6.029796 2.5242002 2.3453061 0 4.3274829-.8414001 5.9465306-2.5242002zm-12.1370589 52.7776981-1.2815282-.9486968c0-.4588935.398855-1.8938272 1.196565-4.3048011l12.7338588-39-23.0745876 3.6164609.2548896 3.873251c0-.1141289.4554971-.1997256 1.3664914-.2567901.9109942 0 1.623741.1723823 2.1382404.5171468.5121392.2306356.7965299.6039323.8531721 1.1198902 0 .8607225-.6549247 3.2134431-1.9647739 7.0581619l-8.1175252 24.1061729c-1.0242785 3.2716963-1.5080967 5.5388203-1.4514546 6.8013717.0566421 1.6643804.8826732 2.9839963 2.4780932 3.9588477 1.2532071.803658 2.8769482 1.205487 4.8712231 1.205487h.5982825c1.7653464-.0570645 3.8445846-.3875629 6.2377146-.9914952 2.3931299-.6039323 4.3590839-1.3933242 5.8978617-2.3681756 1.3098493-.803658 2.3919499-1.5359853 3.2463021-2.1969821.8543521-.6609968 1.3959925-1.1341564 1.6249211-1.4194788l.3433929-.3459533-2.8214861-3.3596708-2.9914125 2.0650205c-.79771.4588935-1.5104568.7454047-2.1382404.8595337z"
13
+ fill="#000"
14
+ fill-rule="nonzero"
15
+ />
16
+ </g>
17
+ </svg> `;
@@ -1,7 +1,22 @@
1
1
  /* eslint-disable */
2
2
  import { sentryLogs } from '../../config/sentry-events.js';
3
+ import {
4
+ sentryCaptureMessage,
5
+ sentryCaptureException,
6
+ } from '../sentry-report.js';
3
7
  import log from '../log.js';
4
8
 
9
+ /**
10
+ * Patron-facing messages for failures that never reached the lending
11
+ * service's own JSON error handling.
12
+ */
13
+ export const unexpectedResponseError = status =>
14
+ `Unexpected response from the lending service (HTTP ${status}). Please try again.`;
15
+ export const networkError =
16
+ 'Could not reach the lending service. Please check your connection and try again.';
17
+ export const unexpectedError =
18
+ 'Something went wrong handling the lending service response. Please try again.';
19
+
5
20
  /**
6
21
  * Helper to call loan service
7
22
  * @param {Object} options
@@ -18,7 +33,6 @@ export default async function ActionsHandlerService(options) {
18
33
  let baseHost = '/services/loans/loan';
19
34
  const location = window?.location;
20
35
 
21
- // return error reponse when not production and has ?error=true param...
22
36
  const tokenError = 'loan token not found. please try again later.';
23
37
  const borrowError =
24
38
  'This book is not available to borrow at this time. Please try again later.';
@@ -29,9 +43,18 @@ export default async function ActionsHandlerService(options) {
29
43
  'renew_loan',
30
44
  'return_loan',
31
45
  ];
46
+ const searchParams = new URLSearchParams(location?.search);
47
+ // ?error=true fails every erroneous action (e.g. simulates the book
48
+ // already being borrowed by someone else). ?failAction=create_token (or
49
+ // any action name) fails ONLY that action, so a renew_loan can succeed
50
+ // and the immediately-following create_token can be made to fail —
51
+ // reproducing WEBDEV-8322's "renewed, but create_token failed" bug
52
+ // without needing a real book.
32
53
  const shouldReturnError =
33
- new URLSearchParams(location?.search).get('error') === 'true' &&
34
- location?.hostname !== 'archive.org';
54
+ location?.hostname !== 'archive.org' &&
55
+ erroneousActions.includes(option?.action) &&
56
+ (searchParams.get('error') === 'true' ||
57
+ searchParams.get('failAction') === option?.action);
35
58
 
36
59
  const testHostname = ['localhost', 'internetarchive.github.io'];
37
60
  let isTest = false;
@@ -44,14 +67,21 @@ export default async function ActionsHandlerService(options) {
44
67
  formData.append('action', option.action);
45
68
  formData.append('identifier', option.identifier);
46
69
 
70
+ // Set once a response arrives, so the catch below can tell a failed fetch
71
+ // (a real network problem) from anything thrown afterwards — including by
72
+ // the caller's own success/error callback, which run inside this try.
73
+ let reachedResponse = false;
74
+
47
75
  try {
48
76
  await fetch(baseHost, {
49
77
  method: 'POST',
50
78
  body: formData,
51
79
  })
52
80
  .then(async response => {
81
+ reachedResponse = true;
82
+
53
83
  // intentional error on localhost
54
- if (shouldReturnError && erroneousActions.includes(option?.action)) {
84
+ if (shouldReturnError) {
55
85
  return {
56
86
  success: false,
57
87
  error: option?.action === 'create_token' ? tokenError : borrowError,
@@ -77,9 +107,7 @@ export default async function ActionsHandlerService(options) {
77
107
  };
78
108
  }
79
109
 
80
- // The response is a Response instance.
81
- // You parse the data into a useable format using `.json()`
82
- return response.json();
110
+ return parseServiceResponse(response, option);
83
111
  })
84
112
  .then(data => {
85
113
  // `data` is the parsed version of the JSON returned from the above endpoint.
@@ -92,8 +120,100 @@ export default async function ActionsHandlerService(options) {
92
120
  }
93
121
  });
94
122
  } catch (error) {
95
- window?.Sentry?.captureException(
96
- `${sentryLogs.actionsHandlerService} - Error: ${error}`
123
+ sentryCaptureException(error, {
124
+ identifier: option.identifier,
125
+ action: option.action,
126
+ extra: {
127
+ message: `${sentryLogs.actionsHandlerService} - ${option.action} ${
128
+ reachedResponse ? 'threw' : 'fetch failed'
129
+ }`,
130
+ },
131
+ });
132
+
133
+ /**
134
+ * Report it, don't just swallow it. A rejected fetch (offline, dropped
135
+ * connection) lands here — a non-JSON body no longer does, see
136
+ * parseServiceResponse() — as does anything thrown after the response
137
+ * arrived; calling neither `success` nor `error` leaves every caller
138
+ * waiting on a callback that never comes. Only the former is a network
139
+ * problem, so only that one says so.
140
+ *
141
+ * For renew_loan that's not merely a missing modal: IABookActions
142
+ * clears its loanRenewInProgress guard from these callbacks, so a
143
+ * silent failure latched it on for good and every later renewal
144
+ * attempt became a no-op. Backgrounding a tab on a flaky mobile
145
+ * connection is exactly when this fires.
146
+ */
147
+ log(`[IABookActions] ✗ ${option.action} threw`, error);
148
+ option?.error?.({
149
+ error: reachedResponse ? unexpectedError : networkError,
150
+ detail: String(error),
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse a lending service response without ever surfacing a raw
157
+ * `SyntaxError` to the patron. Anything in front of PHP (the WAF, or nginx
158
+ * swapping in /about/400.html or a 5xx page) can answer with HTML instead
159
+ * of the service's JSON; that becomes a readable error carrying the HTTP
160
+ * status, and is reported to Sentry with the identifier/action.
161
+ *
162
+ * @param {Response} response
163
+ * @param {Object} option - the ActionsHandlerService options
164
+ * @returns {Promise<Object>}
165
+ */
166
+ export async function parseServiceResponse(response, option) {
167
+ const { status } = response;
168
+ const contentType = response.headers?.get('content-type') || '';
169
+
170
+ // The service always answers with JSON (application/json, or
171
+ // application/problem+json for OPDS) — anything else never came from it.
172
+ const body = await response.text();
173
+ let data;
174
+ if (contentType.includes('json')) {
175
+ try {
176
+ data = JSON.parse(body);
177
+ } catch (parseError) {
178
+ data = undefined;
179
+ }
180
+ }
181
+
182
+ if (data === undefined) {
183
+ sentryCaptureMessage(
184
+ `${sentryLogs.actionsHandlerService} - ${option.action}: non-JSON response (HTTP ${status})`,
185
+ {
186
+ identifier: option.identifier,
187
+ action: option.action,
188
+ extra: {
189
+ status,
190
+ contentType,
191
+ bodySnippet: body.slice(0, 300),
192
+ },
193
+ }
97
194
  );
195
+ // nonJsonResponse lets callers tell "something in front of PHP answered"
196
+ // apart from the service's own error — see
197
+ // IABookActions.handleLendingActionError.
198
+ return {
199
+ success: false,
200
+ error: unexpectedResponseError(status),
201
+ status,
202
+ nonJsonResponse: true,
203
+ };
204
+ }
205
+
206
+ // Carry the HTTP status on the service's own JSON errors too, so it
207
+ // reaches Sentry. A JSON error status without the service's usual `error`
208
+ // field would otherwise be treated as a success.
209
+ if (!response.ok) {
210
+ return {
211
+ ...(data && typeof data === 'object' ? data : {}),
212
+ success: false,
213
+ error: data?.error || unexpectedResponseError(status),
214
+ status,
215
+ };
98
216
  }
217
+
218
+ return data;
99
219
  }
@@ -2,11 +2,19 @@ import { LitElement } from 'lit';
2
2
 
3
3
  import { URLHelper } from '../../config/url-helper.js';
4
4
  import { sentryLogs } from '../../config/sentry-events.js';
5
+ import {
6
+ sentryCaptureMessage,
7
+ sentryCaptureException,
8
+ } from '../sentry-report.js';
5
9
  import log from '../log.js';
6
10
 
7
11
  import ActionsHandlerService from './actions-handler-service.js';
8
12
  import LoanAnanlytics from '../loan-analytics.js';
9
- import { analyticsCategories, analyticsActions, analyticsLabels } from '../../config/analytics-event-and-category.js';
13
+ import {
14
+ analyticsCategories,
15
+ analyticsActions,
16
+ analyticsLabels,
17
+ } from '../../config/analytics-event-and-category.js';
10
18
  import * as Cookies from '../doc-cookies.js';
11
19
 
12
20
  /**
@@ -172,32 +180,51 @@ export default class ActionsHandler extends LitElement {
172
180
  action,
173
181
  identifier: this.identifier,
174
182
  success: async data => {
175
- log('RENEW_LOAN --- ', data, this.identifier);
176
- const activeLoan = data.loan ? data.loan : undefined;
177
- const isRenewal = activeLoan.renewal;
178
-
179
- if (activeLoan && isRenewal) {
180
- // Await — loanAutoRenewed listeners read this same cache key
181
- // immediately; dispatching before the write lands was a race.
182
- await this.setBrowseTimeSession();
183
-
184
- // Only record the renew analytics event once the renewal has
185
- // actually succeeded — not merely attempted.
186
- await this.loanAnanlytics?.storeLoanStatsCount(
187
- this.identifier,
188
- 'autorenew'
189
- );
190
- const analyticsLabel =
191
- renewType === 'auto'
192
- ? analyticsLabels.browseAutoRenew
193
- : analyticsLabels.browseManualRenew;
194
- this.loanAnanlytics?.sendEvent(
195
- analyticsCategories.browse,
196
- analyticsActions.browseRenew,
197
- analyticsLabel,
198
- this.identifier
199
- );
200
- } else {
183
+ // Anything thrown in here is an unhandled rejection that dispatches
184
+ // NOTHING — and IABookActions only clears loanRenewInProgress when
185
+ // one of these two events arrives, so a throw here latches it on
186
+ // permanently (dead countdown, loan stuck "active"). Always exit
187
+ // through exactly one of the two dispatches below.
188
+ try {
189
+ log('RENEW_LOAN --- ', data, this.identifier);
190
+ const activeLoan = data.loan ? data.loan : undefined;
191
+ // Optional chaining: `data.loan` is genuinely absent on some
192
+ // failures, and `activeLoan` can be undefined here.
193
+ const isRenewal = activeLoan?.renewal;
194
+
195
+ if (activeLoan && isRenewal) {
196
+ // Await — loanAutoRenewed listeners read this same cache key
197
+ // immediately; dispatching before the write lands was a race.
198
+ await this.setBrowseTimeSession();
199
+
200
+ // Only record the renew analytics event once the renewal has
201
+ // actually succeeded — not merely attempted.
202
+ await this.loanAnanlytics?.storeLoanStatsCount(
203
+ this.identifier,
204
+ 'autorenew'
205
+ );
206
+ const analyticsLabel =
207
+ renewType === 'auto'
208
+ ? analyticsLabels.browseAutoRenew
209
+ : analyticsLabels.browseManualRenew;
210
+ this.loanAnanlytics?.sendEvent(
211
+ analyticsCategories.browse,
212
+ analyticsActions.browseRenew,
213
+ analyticsLabel,
214
+ this.identifier
215
+ );
216
+
217
+ // Dispatch the success outcome ONLY for a confirmed renewal —
218
+ // a `{loan: {renewal: false}}` response goes through
219
+ // dispatchActionError below instead, never both.
220
+ this.dispatchEvent(
221
+ new CustomEvent('loanAutoRenewed', {
222
+ detail: { action, data: { ...data, loan: activeLoan } },
223
+ })
224
+ );
225
+ return;
226
+ }
227
+
201
228
  log('RENEW_LOAN ERROR --- ', {
202
229
  action,
203
230
  isRenewal,
@@ -205,22 +232,32 @@ export default class ActionsHandler extends LitElement {
205
232
  data,
206
233
  id: this.identifier,
207
234
  });
208
- window?.Sentry?.captureMessage(
209
- `${sentryLogs.bookRenewFailed} - Error: ${JSON.stringify(data)}`
235
+ sentryCaptureMessage(
236
+ `${sentryLogs.bookRenewFailed} - no loan active`,
237
+ {
238
+ identifier: this.identifier,
239
+ action,
240
+ extra: { response: JSON.stringify(data) },
241
+ }
210
242
  );
211
243
  this.dispatchActionError(action, {
212
244
  data,
213
245
  error: true,
214
246
  message: 'Loan renewal failed: no loan active.',
215
247
  });
248
+ } catch (error) {
249
+ log('RENEW_LOAN THREW --- ', error);
250
+ sentryCaptureException(error, {
251
+ identifier: this.identifier,
252
+ action,
253
+ extra: { message: `${sentryLogs.bookRenewFailed} - threw` },
254
+ });
255
+ this.dispatchActionError(action, {
256
+ data,
257
+ error: true,
258
+ message: `Loan renewal failed: ${error}`,
259
+ });
216
260
  }
217
-
218
- // dispatch outcome of loan renewal
219
- this.dispatchEvent(
220
- new CustomEvent('loanAutoRenewed', {
221
- detail: { action, data: { ...data, loan: activeLoan } },
222
- })
223
- );
224
261
  },
225
262
  error: data => {
226
263
  this.dispatchActionError(action, data);
@@ -372,6 +409,12 @@ export default class ActionsHandler extends LitElement {
372
409
  new Date().getTime() + this.loanTotalTime * 1000
373
410
  );
374
411
 
412
+ log('[ActionsHandler] setBrowseTimeSession: resetting loanTime', {
413
+ identifier: this.identifier,
414
+ expireDate,
415
+ loanTotalTime: this.loanTotalTime,
416
+ });
417
+
375
418
  // set a value
376
419
  await this.localCache.set({
377
420
  key: `${this.identifier}-loanTime`,
@@ -381,12 +424,20 @@ export default class ActionsHandler extends LitElement {
381
424
 
382
425
  // delete pageChangedTime when book is auto renew at nth minute
383
426
  await this.localCache.delete(`${this.identifier}-pageChangedTime`);
427
+
428
+ log('[ActionsHandler] setBrowseTimeSession: loanTime reset complete', {
429
+ identifier: this.identifier,
430
+ });
384
431
  } catch (error) {
385
- log(error);
432
+ log('[ActionsHandler] setBrowseTimeSession failed', error);
386
433
  }
387
434
  }
388
435
 
389
436
  deleteLoanCookies() {
437
+ log('[ActionsHandler] deleteLoanCookies: expiring loan cookies', {
438
+ identifier: this.identifier,
439
+ });
440
+
390
441
  const date = new Date();
391
442
  date.setTime(date.getTime() - 24 * 60 * 60 * 1000); // one day ago
392
443
 
@@ -414,7 +465,8 @@ export default class ActionsHandler extends LitElement {
414
465
  * @returns {void}
415
466
  */
416
467
  setStickyAdminAccess(value) {
417
- const domain = window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
468
+ const domain =
469
+ window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
418
470
  const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days from now
419
471
  Cookies.setItem('sticky-admin-access', value, expires, '/', domain);
420
472
  }
@@ -159,17 +159,6 @@ export default class LoanAnanlytics {
159
159
  * @memberof LoanAnanlytics
160
160
  */
161
161
  sendEvent(eventCategory, eventAction, label, extraParams) {
162
- log(
163
- 'eventCategory:-',
164
- eventCategory,
165
- '||\teventAction:-',
166
- eventAction,
167
- '||\tlabel:-',
168
- label,
169
- '||\textraParams:-',
170
- extraParams,
171
- );
172
-
173
162
  window?.archive_analytics?.send_event_no_sampling(
174
163
  eventCategory,
175
164
  eventAction,
@@ -26,7 +26,7 @@ export class LoanRenewHelper {
26
26
  };
27
27
  }
28
28
 
29
- async handleLoanRenew() {
29
+ handleLoanRenew() {
30
30
  try {
31
31
  if (this.hasPageChanged) {
32
32
  return this.pageChanged(); // user clicked on page
@@ -99,7 +99,7 @@ export class LoanRenewHelper {
99
99
  } else if (pageChangedTime >= pageChangeTimeFrame) {
100
100
  this.result = {
101
101
  texts: '',
102
- renewNow: true, // viewed in last time frame
102
+ renewNow: true, // viewed in last time frame
103
103
  renewType: 'auto',
104
104
  };
105
105
  }
@@ -2,6 +2,8 @@
2
2
  import ActionsHandlerService from './actions-handler/actions-handler-service.js';
3
3
  import LoanAnanlytics from './loan-analytics.js';
4
4
  import { sentryLogs } from '../config/sentry-events.js';
5
+ import { sentryCaptureMessage } from './sentry-report.js';
6
+ import log from './log.js';
5
7
 
6
8
  /**
7
9
  * This class is used to create loan token for borrowed books
@@ -9,12 +11,39 @@ import { sentryLogs } from '../config/sentry-events.js';
9
11
  * ActionsHandlerService is a function being used to execute
10
12
  */
11
13
  export class LoanTokenPoller {
12
- constructor(id, borrowType, successCallback, errorCallback, pollerDelay) {
13
- this.identifier = id;
14
+ /**
15
+ * @param {Object} options
16
+ * @param {string} options.identifier
17
+ * @param {string} options.borrowType
18
+ * @param {Function} options.successCallback - called after the initial
19
+ * loan token is created.
20
+ * @param {Function} options.errorCallback - called on any create_token
21
+ * failure (initial or a routine interval tick).
22
+ * @param {number} options.pollerDelay - interval between routine
23
+ * create_token checks, in seconds.
24
+ * @param {boolean} [options.skipInitialCall] - skip the immediate
25
+ * create_token call, only start the recurring interval. Used after a
26
+ * loan-expiry recovery renewal, where BookLoanService::
27
+ * attempt_to_renew_loan() already minted a valid access token as part
28
+ * of the renew_loan response itself — an immediate confirming
29
+ * create_token call is redundant.
30
+ */
31
+ constructor(options = {}) {
32
+ const {
33
+ identifier,
34
+ borrowType,
35
+ successCallback,
36
+ errorCallback,
37
+ pollerDelay,
38
+ skipInitialCall = false,
39
+ } = options;
40
+
41
+ this.identifier = identifier;
14
42
  this.borrowType = borrowType;
15
43
  this.successCallback = successCallback; // callback function to be called after loan token is created
16
44
  this.errorCallback = errorCallback; // callback function to be called after loan token is created
17
45
  this.pollerDelay = pollerDelay; // value in seconds
46
+ this.skipInitialCall = skipInitialCall === true;
18
47
 
19
48
  this.loanTokenInterval = undefined;
20
49
 
@@ -33,8 +62,17 @@ export class LoanTokenPoller {
33
62
 
34
63
  async bookAccessed() {
35
64
  if (this.borrowType) {
36
- // Do an initial token, then set an interval
37
- this.handleLoanTokenPoller(true);
65
+ if (this.skipInitialCall) {
66
+ log(
67
+ '[LoanTokenPoller] skipping initial create_token — already minted by the renewal response',
68
+ {
69
+ identifier: this.identifier,
70
+ }
71
+ );
72
+ } else {
73
+ // Do an initial token, then set an interval
74
+ this.handleLoanTokenPoller(true);
75
+ }
38
76
 
39
77
  // if this.borrowType = adminBorrowed,
40
78
  // - we don't want to fetch token on interval
@@ -49,38 +87,86 @@ export class LoanTokenPoller {
49
87
  }, this.pollerDelay * 1000);
50
88
  }
51
89
  } else {
52
- window?.Sentry?.captureMessage(
53
- `${sentryLogs.bookAccessed} - not borrowed`
54
- );
90
+ sentryCaptureMessage(`${sentryLogs.bookAccessed} - not borrowed`, {
91
+ identifier: this.identifier,
92
+ action: 'create_token',
93
+ });
55
94
 
56
95
  // if book is not browsed, just clear token polling interval
57
96
  this.disconnectedCallback();
58
97
  }
59
98
  }
60
99
 
100
+ /**
101
+ * @param {boolean} isInitial - the first create_token call right after
102
+ * bookAccessed()/a renewal, as opposed to a routine interval tick.
103
+ */
61
104
  async handleLoanTokenPoller(isInitial = false) {
62
105
  const action = 'create_token';
106
+ log('[LoanTokenPoller] create_token requested', {
107
+ identifier: this.identifier,
108
+ isInitial,
109
+ });
63
110
  ActionsHandlerService({
64
111
  identifier: this.identifier,
65
112
  action,
66
- error: data => {
67
- this.errorCallback({ detail: { action, data } });
68
-
69
- // send error to Sentry
70
- window?.Sentry?.captureMessage(
71
- `${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
72
- );
73
-
74
- // send LendingServiceError to GA
75
- this.loanAnalytics?.sendEvent(
76
- 'LendingServiceLoanError',
77
- action,
78
- this.identifier
79
- );
80
- },
113
+ error: data => this.handleTokenError(data, isInitial),
81
114
  success: () => {
115
+ log('[LoanTokenPoller] create_token succeeded', {
116
+ identifier: this.identifier,
117
+ isInitial,
118
+ });
82
119
  if (isInitial) this.successCallback();
83
120
  },
84
121
  });
85
122
  }
123
+
124
+ /**
125
+ * Reports every create_token failure via errorCallback — the poller never
126
+ * retries, and the consumer decides whether a failure is terminal (see
127
+ * IABookActions.handleLendingActionError). Split out from
128
+ * handleLoanTokenPoller so it's directly testable without needing to
129
+ * mock the network call.
130
+ *
131
+ * @param {Object} data - the error payload from ActionsHandlerService.
132
+ * @param {boolean} isInitial
133
+ */
134
+ handleTokenError(data, isInitial) {
135
+ const action = 'create_token';
136
+
137
+ log('[LoanTokenPoller] create_token failed', {
138
+ identifier: this.identifier,
139
+ isInitial,
140
+ error: data?.error,
141
+ });
142
+
143
+ // isInitial rides along so the consumer can distinguish "the book won't
144
+ // open at all" from "a mid-read refresh blipped" — see
145
+ // IABookActions.handleLendingActionError.
146
+ this.errorCallback({ detail: { action, data, isInitial } });
147
+
148
+ // send error to Sentry
149
+ sentryCaptureMessage(
150
+ `${sentryLogs.handleLoanTokenPoller} - create_token failed`,
151
+ {
152
+ identifier: this.identifier,
153
+ action,
154
+ extra: {
155
+ borrowType: this.borrowType,
156
+ isInitial,
157
+ error: data?.error,
158
+ status: data?.status,
159
+ nonJsonResponse: data?.nonJsonResponse === true,
160
+ response: JSON.stringify(data),
161
+ },
162
+ }
163
+ );
164
+
165
+ // send LendingServiceError to GA
166
+ this.loanAnalytics?.sendEvent(
167
+ 'LendingServiceLoanError',
168
+ action,
169
+ this.identifier
170
+ );
171
+ }
86
172
  }