@domandigital/gbp 0.4.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7437827: A dead Google token or a Google 503 no longer reaches the page, and no longer files a new error-tracker issue every time.
8
+
9
+ - **`getBusinessReviewsSafe(options)` and `getPublishedReviewsSafe(options)`** never throw. On any failure they serve the last good result this process fetched (up to `maxStaleMs`, default 30 days), then your `fallback` (a snapshot, or a loader such as a KV read), then an empty result. `source` says which (`live`, `cache`, `fallback`, `empty`) and `error` holds the failure.
10
+ - They report each kind of failure at most once per `reportIntervalMs` (default one hour) per process, through `report(error, { fingerprint, context, transient, served, suppressed })`. Pass `fingerprint` and `context` straight to `Sentry.captureException`.
11
+ - After `invalid_grant` or `invalid_client` they skip the token request for `authRetryMs` (default five minutes) and serve the cache or fallback straight away.
12
+ - **Error messages are now short and stable**, with what varies moved to `error.context`: `GBP token refresh failed: invalid_grant` (was `Google OAuth token refresh failed: 400 invalid_grant (...)`), `GBP reviews.list failed: 5xx` (was the status plus Google's body), `GBP reviews.list timed out`. A published-file HTTP failure no longer puts the body in its message. Anything matching on the old message text needs updating; match on `error.code` instead.
13
+ - Every `GbpError` has `code`, `transient`, `context` and `fingerprint` (`["gbp", code]`). Every 5xx shares the code `api_5xx`, so a Google outage is one issue rather than one per status. `GbpApiError` also has `body`.
14
+
15
+ ## 0.5.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 4588886: Read reviews from the file Doman Digital publishes, so a site needs no Google token.
20
+
21
+ - **`getPublishedReviews({ client })`** fetches `https://files.domandigital.co.uk/reviews/<client>.json` (override with `baseUrl` or `GBP_REVIEWS_BASE_URL`). One collector reads every listing with the only Google credential and the portal publishes the file each night, so a dead token makes reviews stale, not missing.
22
+ - Same shape as `getBusinessReviews`: Google's own `averageRating` and `totalReviewCount` for the whole listing, plus `reviews` (four and five stars with words, replies Google shows). Adds `syncedAt`, when Google was last read.
23
+ - Throws `GbpPublishedError` (`http`, `invalid`, `unknown_schema`) instead of returning an empty result, so the caller keeps the copy it already has. Retries 429 and transient 5xx within the usual deadline.
24
+ - Defaults to newest first (`order: "api"`) and a daily Next.js revalidate tagged `google-reviews`.
25
+
3
26
  ## 0.4.0
4
27
 
5
28
  ### Minor Changes
package/README.md CHANGED
@@ -61,6 +61,35 @@ remove such markup and "You won't get a manual action just for this"
61
61
  Showing the reviews on the page is unaffected. Just do not sell or expect the
62
62
  stars. `@domandigital/graph`'s `findGraphIssues` can flag the pattern.
63
63
 
64
+ ## Reading the published file (no Google token on the site)
65
+
66
+ Client sites should read reviews from the file Doman Digital publishes rather
67
+ than from Google. One collector holds the only Google credential, the portal
68
+ stores what it reads, and each night it writes
69
+ `https://files.domandigital.co.uk/reviews/<client-slug>.json`:
70
+
71
+ ```ts
72
+ import { getPublishedReviews } from "@domandigital/gbp";
73
+
74
+ // Throws when the file is missing or malformed: keep the copy you already have.
75
+ const { averageRating, totalReviewCount, reviews, syncedAt } = await getPublishedReviews({
76
+ client: "chair-and-blade",
77
+ });
78
+ ```
79
+
80
+ The result has the same shape as `getBusinessReviews`, plus `syncedAt`. The
81
+ file already holds only what a site shows (four and five stars, with words,
82
+ and owner replies Google shows); `filterMinStars` can only raise that floor.
83
+
84
+ It throws `GbpPublishedError` instead of returning an empty result. Catch it
85
+ where the site has an older copy to fall back to (a Next.js fetch keeps its
86
+ last good response; a static build should refuse to publish and leave the
87
+ last deploy live), and render no rating, count or review section when there
88
+ is no copy at all. Never fall back to a hardcoded number.
89
+
90
+ A site that reads this file needs none of the `GBP_*` or `GOOGLE_BUSINESS_*`
91
+ variables below.
92
+
64
93
  ## Why Business Profile API, not Places API
65
94
 
66
95
  Places API's `Review` object has no field for the business's reply, full
@@ -198,7 +227,45 @@ Options:
198
227
  Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
199
228
  internally and returns an empty result, so UI can render unconditionally.
200
229
  Does throw on a real failure, so a calling route should catch and degrade
201
- explicitly if it wants zero-downtime behaviour on a Google outage.
230
+ explicitly if it wants zero-downtime behaviour on a Google outage, or use
231
+ `getBusinessReviewsSafe`, which does that for it.
232
+
233
+ ### `getBusinessReviewsSafe(options?)` and `getPublishedReviewsSafe(options)`: reviews that never throw
234
+
235
+ Use these in a page render. They take the same options as `getBusinessReviews`
236
+ and `getPublishedReviews`, catch every failure, and serve in order: the last
237
+ good result this process fetched for the same options (up to `maxStaleMs`,
238
+ default 30 days), then your `fallback`, then an empty result, which has a
239
+ `null` rating and count and an empty `reviews` list. `source` says which:
240
+ `"live"`, `"cache"`, `"fallback"` or `"empty"`, and `error` holds the failure
241
+ when there was one.
242
+
243
+ ```ts
244
+ import * as Sentry from "@sentry/nextjs";
245
+ import { getBusinessReviewsSafe } from "@domandigital/gbp";
246
+
247
+ const { averageRating, totalReviewCount, reviews, source } = await getBusinessReviewsSafe({
248
+ filterMinStars: 4,
249
+ next: { revalidate: 86400, tags: ["google-reviews"] },
250
+ // A snapshot, or a loader for one (a KV read). Served as given.
251
+ fallback: () => kv.get(LAST_KNOWN_GOOD_KEY),
252
+ report: (error, { fingerprint, context, transient }) =>
253
+ Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
254
+ });
255
+ if (source === "live") await kv.set(LAST_KNOWN_GOOD_KEY, { averageRating, totalReviewCount, reviews });
256
+ ```
257
+
258
+ - `report` runs at most once per kind of failure per `reportIntervalMs`
259
+ (default one hour) in each process, not on every request. It is told how
260
+ many it held back since the last report (`suppressed`). Without `report`, one
261
+ `console.warn` per window.
262
+ - After `invalid_grant` or `invalid_client` the live call is skipped for
263
+ `authRetryMs` (default five minutes), because retrying cannot fix it.
264
+ - A `fallback` is served as given: `limit` and `filterMinStars` are not
265
+ applied to it. A loader that throws counts as no fallback.
266
+
267
+ A static build that should refuse to publish rather than ship stale or empty
268
+ reviews should keep using the throwing functions.
202
269
 
203
270
  ### Failures, and what each one means
204
271
 
@@ -208,7 +275,13 @@ a few times with backoff; Google documents the 429 for quota
208
275
  A 401 means the cached access token is no longer good: it is dropped,
209
276
  refreshed once, and the page is asked for again; a second 401 throws.
210
277
 
211
- All errors extend `GbpError`, and none carries a credential:
278
+ All errors extend `GbpError`, and none carries a credential. Each message is
279
+ short and stable (`GBP token refresh failed: invalid_grant`, `GBP reviews.list
280
+ failed: 5xx`), so an error tracker files every occurrence under one issue.
281
+ What varies, such as the status, Google's description or a body excerpt, is in
282
+ `error.context`. `error.code` names the failure, `error.fingerprint` is
283
+ `["gbp", code]` to pass to Sentry, and `error.transient` says whether it may
284
+ clear without anyone acting. Every 5xx shares the code `api_5xx`.
212
285
 
213
286
  | Error | When | What to do |
214
287
  |---|---|---|
package/dist/index.cjs CHANGED
@@ -20,36 +20,55 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_AUTH_RETRY_MS: () => DEFAULT_AUTH_RETRY_MS,
23
24
  DEFAULT_MAX_PAGES: () => DEFAULT_MAX_PAGES,
25
+ DEFAULT_MAX_STALE_MS: () => DEFAULT_MAX_STALE_MS,
26
+ DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
27
+ DEFAULT_REPORT_INTERVAL_MS: () => DEFAULT_REPORT_INTERVAL_MS,
24
28
  DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
25
29
  GbpApiError: () => GbpApiError,
26
30
  GbpAuthError: () => GbpAuthError,
27
31
  GbpError: () => GbpError,
28
32
  GbpPaginationError: () => GbpPaginationError,
33
+ GbpPublishedError: () => GbpPublishedError,
29
34
  GbpTimeoutError: () => GbpTimeoutError,
35
+ PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
30
36
  getBusinessReviews: () => getBusinessReviews,
37
+ getBusinessReviewsSafe: () => getBusinessReviewsSafe,
31
38
  getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
39
+ getPublishedReviews: () => getPublishedReviews,
40
+ getPublishedReviewsSafe: () => getPublishedReviewsSafe,
32
41
  hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
33
42
  invalidateAccessToken: () => invalidateAccessToken,
34
43
  isBusinessProfileConfigured: () => isBusinessProfileConfigured,
35
44
  parseListReviewsResponse: () => parseListReviewsResponse,
36
- parseRetryAfter: () => parseRetryAfter
45
+ parsePublishedReviews: () => parsePublishedReviews,
46
+ parseRetryAfter: () => parseRetryAfter,
47
+ publishedReviewsUrl: () => publishedReviewsUrl
37
48
  });
38
49
  module.exports = __toCommonJS(index_exports);
39
50
 
40
51
  // src/errors.ts
41
52
  var GbpError = class extends Error {
42
- constructor(message) {
53
+ constructor(message, code = "unknown", transient = false, context = {}) {
43
54
  super(message);
44
55
  this.name = new.target.name;
56
+ this.code = code;
57
+ this.transient = transient;
58
+ this.context = context;
59
+ }
60
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
61
+ get fingerprint() {
62
+ return ["gbp", this.code];
45
63
  }
46
64
  };
47
65
  var GbpAuthError = class extends GbpError {
48
66
  constructor(code, status, description) {
49
- super(
50
- `Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
51
- );
52
- this.code = code;
67
+ super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
68
+ status,
69
+ ...description ? { description } : {},
70
+ ...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
71
+ });
53
72
  this.reauthorizationRequired = code === "invalid_grant";
54
73
  this.status = status;
55
74
  this.description = description;
@@ -57,7 +76,15 @@ var GbpAuthError = class extends GbpError {
57
76
  };
58
77
  var GbpApiError = class extends GbpError {
59
78
  constructor(options) {
60
- super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
79
+ const server = options.status >= 500;
80
+ super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
81
+ operation: options.operation,
82
+ status: options.status,
83
+ attempts: options.attempts,
84
+ ...options.body ? { body: options.body } : {},
85
+ ...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
86
+ });
87
+ this.body = options.body;
61
88
  this.operation = options.operation;
62
89
  this.status = options.status;
63
90
  this.retryable = options.retryable;
@@ -67,7 +94,7 @@ var GbpApiError = class extends GbpError {
67
94
  };
68
95
  var GbpTimeoutError = class extends GbpError {
69
96
  constructor(operation, timeoutMs) {
70
- super(`${operation} timed out after ${timeoutMs}ms`);
97
+ super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
71
98
  this.operation = operation;
72
99
  this.timeoutMs = timeoutMs;
73
100
  }
@@ -75,7 +102,10 @@ var GbpTimeoutError = class extends GbpError {
75
102
  var GbpPaginationError = class extends GbpError {
76
103
  constructor(reason, pagesFetched) {
77
104
  super(
78
- reason === "repeated_token" ? `Business Profile returned a page token it had already returned, after ${pagesFetched} page(s)` : `Business Profile reviews stopped at the page limit (${pagesFetched} pages)`
105
+ reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
106
+ `pagination_${reason}`,
107
+ false,
108
+ { pagesFetched }
79
109
  );
80
110
  this.reason = reason;
81
111
  this.pagesFetched = pagesFetched;
@@ -85,6 +115,19 @@ function excerpt(text, max = 300) {
85
115
  const flat = text.replace(/\s+/g, " ").trim();
86
116
  return flat.length > max ? `${flat.slice(0, max)}...` : flat;
87
117
  }
118
+ var GbpPublishedError = class extends GbpError {
119
+ constructor(reason, client, detail, status, body) {
120
+ const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
121
+ super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
122
+ client,
123
+ ...status !== void 0 ? { status } : {},
124
+ ...body ? { body } : {}
125
+ });
126
+ this.reason = reason;
127
+ this.client = client;
128
+ this.status = status;
129
+ }
130
+ };
88
131
 
89
132
  // src/http.ts
90
133
  var DEFAULT_REQUEST_POLICY = {
@@ -395,20 +438,224 @@ async function getBusinessReviews(options = {}) {
395
438
  reviews: order === "api" ? reviews : shuffleArray(reviews)
396
439
  };
397
440
  }
441
+
442
+ // src/published.ts
443
+ var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
444
+ var PUBLISHED_REVIEWS_SCHEMA = 1;
445
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
446
+ var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
447
+ function parseReview2(v) {
448
+ if (!isRecord2(v)) return null;
449
+ const { id, author, rating, comment, createdAt, reply } = v;
450
+ if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
451
+ if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
452
+ const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
453
+ if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
454
+ review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
455
+ }
456
+ return review;
457
+ }
458
+ function parsePublishedReviews(body, client) {
459
+ if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
460
+ if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
461
+ throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
462
+ }
463
+ if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
464
+ const { rating, count } = body;
465
+ if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
466
+ throw new GbpPublishedError("invalid", client, "rating out of range");
467
+ }
468
+ if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
469
+ throw new GbpPublishedError("invalid", client, "count is not a whole number");
470
+ }
471
+ if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
472
+ throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
473
+ }
474
+ return {
475
+ averageRating: rating,
476
+ totalReviewCount: count,
477
+ reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
478
+ syncedAt: body.synced_at,
479
+ publishedAt: body.published_at
480
+ };
481
+ }
482
+ function shuffle(arr) {
483
+ const copy = [...arr];
484
+ for (let i = copy.length - 1; i > 0; i--) {
485
+ const j = Math.floor(Math.random() * (i + 1));
486
+ [copy[i], copy[j]] = [copy[j], copy[i]];
487
+ }
488
+ return copy;
489
+ }
490
+ function publishedReviewsUrl(client, baseUrl) {
491
+ if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
492
+ const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
493
+ return `${base}/${client}.json`;
494
+ }
495
+ async function getPublishedReviews(options) {
496
+ const { client, filterMinStars, limit, order = "api", next, request } = options;
497
+ const url = publishedReviewsUrl(client, options.baseUrl);
498
+ const init = {
499
+ headers: { Accept: "application/json" },
500
+ next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
501
+ };
502
+ const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
503
+ if (!response.ok) {
504
+ const body2 = excerpt(await readErrorBody(response), 120);
505
+ throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
506
+ }
507
+ let body;
508
+ try {
509
+ body = await response.json();
510
+ } catch {
511
+ throw new GbpPublishedError("invalid", client, "the file is not JSON");
512
+ }
513
+ const result = parsePublishedReviews(body, client);
514
+ let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
515
+ if (limit !== void 0) reviews = reviews.slice(0, limit);
516
+ return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
517
+ }
518
+
519
+ // src/safe.ts
520
+ var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
521
+ var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
522
+ var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
523
+ var lastGood = /* @__PURE__ */ new Map();
524
+ var reported = /* @__PURE__ */ new Map();
525
+ var authBlockedUntil = /* @__PURE__ */ new Map();
526
+ function toGbpError(error) {
527
+ if (error instanceof GbpError) return error;
528
+ const name = error instanceof Error ? error.name : typeof error;
529
+ const message = error instanceof Error ? error.message : String(error);
530
+ const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
531
+ wrapped.cause = error;
532
+ return wrapped;
533
+ }
534
+ function isAuthFailure(error) {
535
+ return error.code === "invalid_grant" || error.code === "invalid_client";
536
+ }
537
+ function defaultReport(error, report) {
538
+ console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
539
+ }
540
+ async function loadFallback(fallback) {
541
+ if (fallback === void 0) return null;
542
+ try {
543
+ return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+ function reportOnce(error, served, options) {
549
+ const now = Date.now();
550
+ const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
551
+ const last = reported.get(error.code);
552
+ if (last && now - last.at < window) {
553
+ last.suppressed++;
554
+ return;
555
+ }
556
+ reported.set(error.code, { at: now, suppressed: 0 });
557
+ try {
558
+ (options.report ?? defaultReport)(error, {
559
+ fingerprint: error.fingerprint,
560
+ code: error.code,
561
+ transient: error.transient,
562
+ context: error.context,
563
+ served,
564
+ suppressed: last?.suppressed ?? 0
565
+ });
566
+ } catch {
567
+ }
568
+ }
569
+ async function safely(key, load, empty, options) {
570
+ const now = Date.now();
571
+ let error;
572
+ const blocked = authBlockedUntil.get(key);
573
+ if (blocked && blocked.until > now) {
574
+ error = blocked.error;
575
+ } else {
576
+ try {
577
+ const result = await load();
578
+ lastGood.set(key, { result, at: Date.now() });
579
+ authBlockedUntil.delete(key);
580
+ return { ...result, source: "live" };
581
+ } catch (caught) {
582
+ error = toGbpError(caught);
583
+ const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
584
+ if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
585
+ }
586
+ }
587
+ let cached2 = lastGood.get(key);
588
+ if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
589
+ lastGood.delete(key);
590
+ cached2 = void 0;
591
+ }
592
+ let served;
593
+ if (cached2) {
594
+ served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
595
+ } else {
596
+ const fallback = await loadFallback(options.fallback);
597
+ served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
598
+ }
599
+ reportOnce(error, served.source, options);
600
+ return served;
601
+ }
602
+ function getBusinessReviewsSafe(options = {}) {
603
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
604
+ const key = JSON.stringify([
605
+ "business",
606
+ process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
607
+ fetchOptions.limit ?? null,
608
+ fetchOptions.filterMinStars ?? null,
609
+ fetchOptions.includeUnapprovedReplies ?? false
610
+ ]);
611
+ return safely(
612
+ key,
613
+ () => getBusinessReviews(fetchOptions),
614
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
615
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
616
+ );
617
+ }
618
+ function getPublishedReviewsSafe(options) {
619
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
620
+ const key = JSON.stringify([
621
+ "published",
622
+ fetchOptions.client,
623
+ fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
624
+ fetchOptions.limit ?? null,
625
+ fetchOptions.filterMinStars ?? null
626
+ ]);
627
+ return safely(
628
+ key,
629
+ () => getPublishedReviews(fetchOptions),
630
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
631
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
632
+ );
633
+ }
398
634
  // Annotate the CommonJS export names for ESM import in node:
399
635
  0 && (module.exports = {
636
+ DEFAULT_AUTH_RETRY_MS,
400
637
  DEFAULT_MAX_PAGES,
638
+ DEFAULT_MAX_STALE_MS,
639
+ DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
640
+ DEFAULT_REPORT_INTERVAL_MS,
401
641
  DEFAULT_REQUEST_POLICY,
402
642
  GbpApiError,
403
643
  GbpAuthError,
404
644
  GbpError,
405
645
  GbpPaginationError,
646
+ GbpPublishedError,
406
647
  GbpTimeoutError,
648
+ PUBLISHED_REVIEWS_SCHEMA,
407
649
  getBusinessReviews,
650
+ getBusinessReviewsSafe,
408
651
  getGoogleOAuthAccessToken,
652
+ getPublishedReviews,
653
+ getPublishedReviewsSafe,
409
654
  hasGoogleOAuthCredentials,
410
655
  invalidateAccessToken,
411
656
  isBusinessProfileConfigured,
412
657
  parseListReviewsResponse,
413
- parseRetryAfter
658
+ parsePublishedReviews,
659
+ parseRetryAfter,
660
+ publishedReviewsUrl
414
661
  });
package/dist/index.d.cts CHANGED
@@ -240,18 +240,74 @@ declare function parseListReviewsResponse(body: unknown): ReviewsPage;
240
240
  */
241
241
  declare function getBusinessReviews(options?: GetBusinessReviewsOptions): Promise<BusinessReviewsResult>;
242
242
 
243
+ /** Where Doman Digital's portal publishes review files. */
244
+ declare const DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
245
+ /** The file schema this reader understands. A newer file is refused rather than misread. */
246
+ declare const PUBLISHED_REVIEWS_SCHEMA = 1;
247
+ interface GetPublishedReviewsOptions {
248
+ /** The client's slug, e.g. `"chair-and-blade"`. */
249
+ client: string;
250
+ /** Default: `GBP_REVIEWS_BASE_URL` from the environment, else {@link DEFAULT_PUBLISHED_REVIEWS_BASE_URL}. */
251
+ baseUrl?: string;
252
+ /** Raise the published floor (four stars). Values at or below four change nothing. */
253
+ filterMinStars?: number;
254
+ /** Max reviews to return. Default: all of them. */
255
+ limit?: number;
256
+ /** `"api"` (default): newest first, as published. `"shuffle"`: random order, applied after `limit`. */
257
+ order?: ReviewOrder;
258
+ /** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `google-reviews`. */
259
+ next?: {
260
+ revalidate?: number;
261
+ tags?: string[];
262
+ };
263
+ /** Deadline and retry policy. */
264
+ request?: GbpRequestOptions;
265
+ }
266
+ interface PublishedReviewsResult extends BusinessReviewsResult {
267
+ /** When the collector last read Google in full. */
268
+ syncedAt: string;
269
+ /** When the file was written. */
270
+ publishedAt: string;
271
+ }
272
+ /**
273
+ * Validate a published file. A malformed review is skipped; a malformed file, a file for another client or a schema
274
+ * this reader does not know throws {@link GbpPublishedError}.
275
+ */
276
+ declare function parsePublishedReviews(body: unknown, client: string): PublishedReviewsResult;
277
+ /** The URL a client's file is read from. */
278
+ declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
279
+ /**
280
+ * Fetch a client's published reviews. Retries 429 and transient 5xx within a deadline (see `request`), then throws:
281
+ * {@link GbpPublishedError} for a missing or malformed file, `GbpTimeoutError` for a deadline.
282
+ */
283
+ declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
284
+
243
285
  /**
244
286
  * Typed failures, so a site can tell "reauthorise the Google account" from
245
287
  * "Google is having a bad minute" from "our code looped", and log the
246
288
  * difference without ever logging a credential.
247
289
  *
248
- * No message here carries a client id, client secret, refresh token, access
249
- * token or review text. Bodies from Google are cut to a short, bounded
250
- * excerpt.
290
+ * Every message is short and stable: the same failure always reads the same,
291
+ * so an error tracker groups it as one issue rather than one per response
292
+ * body. What varies (status, Google's description, a body excerpt) sits in
293
+ * `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
294
+ * one (Sentry's `captureException(error, { fingerprint, extra: context })`).
295
+ *
296
+ * No message or context here carries a client id, client secret, refresh
297
+ * token, access token or review text. Bodies from Google are cut to a short,
298
+ * bounded excerpt.
251
299
  */
252
300
  /** Base class: `instanceof GbpError` catches every failure this package throws. */
253
301
  declare class GbpError extends Error {
254
- constructor(message: string);
302
+ /** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
303
+ readonly code: string;
304
+ /** True when the same request may well work later without anyone changing anything. */
305
+ readonly transient: boolean;
306
+ /** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
307
+ readonly context: Record<string, unknown>;
308
+ constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
309
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
310
+ get fingerprint(): string[];
255
311
  }
256
312
  type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
257
313
  /**
@@ -277,6 +333,8 @@ declare class GbpApiError extends GbpError {
277
333
  readonly attempts: number;
278
334
  /** Set when Google asked for a longer wait than this package will sleep through. */
279
335
  readonly retryAfterMs?: number;
336
+ /** A short excerpt of Google's error body. In `context` too, never in the message. */
337
+ readonly body?: string;
280
338
  constructor(options: {
281
339
  operation: string;
282
340
  status: number;
@@ -303,5 +361,104 @@ declare class GbpPaginationError extends GbpError {
303
361
  readonly pagesFetched: number;
304
362
  constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
305
363
  }
364
+ /**
365
+ * A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
366
+ * malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
367
+ * upgrade the package). The caller keeps the copy it already has.
368
+ */
369
+ declare class GbpPublishedError extends GbpError {
370
+ readonly reason: "http" | "invalid" | "unknown_schema";
371
+ readonly client: string;
372
+ readonly status?: number;
373
+ constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
374
+ }
375
+
376
+ /**
377
+ * Reviews that never throw into a page render.
378
+ *
379
+ * `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
380
+ * caller can choose what to do. Most callers want the same thing: keep showing
381
+ * what they showed last time, tell someone once, and carry on. A dead refresh
382
+ * token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
383
+ * Google 503 otherwise reaches every server render, and a site that reports
384
+ * each one sends thousands of events about one fault.
385
+ *
386
+ * The `...Safe` functions here catch every failure and serve, in order:
387
+ *
388
+ * 1. the last good result this process fetched for the same options
389
+ * (`source: "cache"`), up to `maxStaleMs` old;
390
+ * 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
391
+ * read (`source: "fallback"`);
392
+ * 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
393
+ *
394
+ * Each kind of failure is reported at most once per `reportIntervalMs` per
395
+ * process, through `report`. After an auth failure no amount of retrying
396
+ * helps, so the live call is skipped for `authRetryMs` and the cache or
397
+ * fallback is served straight away.
398
+ */
399
+
400
+ /** Where a safe result came from. */
401
+ type ReviewsSource = "live" | "cache" | "fallback" | "empty";
402
+ /** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
403
+ type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
404
+ source: ReviewsSource;
405
+ /** The failure behind a `cache`, `fallback` or `empty` result. */
406
+ error?: GbpError;
407
+ };
408
+ /** What `report` is told alongside the error. */
409
+ interface GbpFailureReport {
410
+ /** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
411
+ fingerprint: string[];
412
+ code: string;
413
+ /** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
414
+ transient: boolean;
415
+ /** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
416
+ context: Record<string, unknown>;
417
+ /** What the page was given instead. */
418
+ served: Exclude<ReviewsSource, "live">;
419
+ /** Failures of this kind left unreported since the last report, because they fell inside the window. */
420
+ suppressed: number;
421
+ }
422
+ interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
423
+ /**
424
+ * Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
425
+ * throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
426
+ * not applied to it.
427
+ */
428
+ fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
429
+ /**
430
+ * Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
431
+ *
432
+ * ```ts
433
+ * report: (error, { fingerprint, context, transient }) =>
434
+ * Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
435
+ * ```
436
+ */
437
+ report?: (error: GbpError, report: GbpFailureReport) => void;
438
+ /** Default one hour. */
439
+ reportIntervalMs?: number;
440
+ /** Longest a cached result is served after the last good fetch. Default 30 days. */
441
+ maxStaleMs?: number;
442
+ /** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
443
+ authRetryMs?: number;
444
+ }
445
+ declare const DEFAULT_REPORT_INTERVAL_MS: number;
446
+ declare const DEFAULT_MAX_STALE_MS: number;
447
+ declare const DEFAULT_AUTH_RETRY_MS: number;
448
+ /**
449
+ * `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
450
+ * empty result, and reports the failure at most once per window. `source` says which.
451
+ */
452
+ declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
453
+ /** A published result, or a fallback or empty one, which carries no sync times. */
454
+ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
455
+ /**
456
+ * `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
457
+ * empty result, and reports the failure at most once per window. `source` says which.
458
+ *
459
+ * Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
460
+ * throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
461
+ */
462
+ declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
306
463
 
307
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parseRetryAfter };
464
+ export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
package/dist/index.d.ts CHANGED
@@ -240,18 +240,74 @@ declare function parseListReviewsResponse(body: unknown): ReviewsPage;
240
240
  */
241
241
  declare function getBusinessReviews(options?: GetBusinessReviewsOptions): Promise<BusinessReviewsResult>;
242
242
 
243
+ /** Where Doman Digital's portal publishes review files. */
244
+ declare const DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
245
+ /** The file schema this reader understands. A newer file is refused rather than misread. */
246
+ declare const PUBLISHED_REVIEWS_SCHEMA = 1;
247
+ interface GetPublishedReviewsOptions {
248
+ /** The client's slug, e.g. `"chair-and-blade"`. */
249
+ client: string;
250
+ /** Default: `GBP_REVIEWS_BASE_URL` from the environment, else {@link DEFAULT_PUBLISHED_REVIEWS_BASE_URL}. */
251
+ baseUrl?: string;
252
+ /** Raise the published floor (four stars). Values at or below four change nothing. */
253
+ filterMinStars?: number;
254
+ /** Max reviews to return. Default: all of them. */
255
+ limit?: number;
256
+ /** `"api"` (default): newest first, as published. `"shuffle"`: random order, applied after `limit`. */
257
+ order?: ReviewOrder;
258
+ /** Cache hint passed to `fetch` for Next.js. Default: revalidate daily, tag `google-reviews`. */
259
+ next?: {
260
+ revalidate?: number;
261
+ tags?: string[];
262
+ };
263
+ /** Deadline and retry policy. */
264
+ request?: GbpRequestOptions;
265
+ }
266
+ interface PublishedReviewsResult extends BusinessReviewsResult {
267
+ /** When the collector last read Google in full. */
268
+ syncedAt: string;
269
+ /** When the file was written. */
270
+ publishedAt: string;
271
+ }
272
+ /**
273
+ * Validate a published file. A malformed review is skipped; a malformed file, a file for another client or a schema
274
+ * this reader does not know throws {@link GbpPublishedError}.
275
+ */
276
+ declare function parsePublishedReviews(body: unknown, client: string): PublishedReviewsResult;
277
+ /** The URL a client's file is read from. */
278
+ declare function publishedReviewsUrl(client: string, baseUrl?: string): string;
279
+ /**
280
+ * Fetch a client's published reviews. Retries 429 and transient 5xx within a deadline (see `request`), then throws:
281
+ * {@link GbpPublishedError} for a missing or malformed file, `GbpTimeoutError` for a deadline.
282
+ */
283
+ declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promise<PublishedReviewsResult>;
284
+
243
285
  /**
244
286
  * Typed failures, so a site can tell "reauthorise the Google account" from
245
287
  * "Google is having a bad minute" from "our code looped", and log the
246
288
  * difference without ever logging a credential.
247
289
  *
248
- * No message here carries a client id, client secret, refresh token, access
249
- * token or review text. Bodies from Google are cut to a short, bounded
250
- * excerpt.
290
+ * Every message is short and stable: the same failure always reads the same,
291
+ * so an error tracker groups it as one issue rather than one per response
292
+ * body. What varies (status, Google's description, a body excerpt) sits in
293
+ * `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
294
+ * one (Sentry's `captureException(error, { fingerprint, extra: context })`).
295
+ *
296
+ * No message or context here carries a client id, client secret, refresh
297
+ * token, access token or review text. Bodies from Google are cut to a short,
298
+ * bounded excerpt.
251
299
  */
252
300
  /** Base class: `instanceof GbpError` catches every failure this package throws. */
253
301
  declare class GbpError extends Error {
254
- constructor(message: string);
302
+ /** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
303
+ readonly code: string;
304
+ /** True when the same request may well work later without anyone changing anything. */
305
+ readonly transient: boolean;
306
+ /** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
307
+ readonly context: Record<string, unknown>;
308
+ constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
309
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
310
+ get fingerprint(): string[];
255
311
  }
256
312
  type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
257
313
  /**
@@ -277,6 +333,8 @@ declare class GbpApiError extends GbpError {
277
333
  readonly attempts: number;
278
334
  /** Set when Google asked for a longer wait than this package will sleep through. */
279
335
  readonly retryAfterMs?: number;
336
+ /** A short excerpt of Google's error body. In `context` too, never in the message. */
337
+ readonly body?: string;
280
338
  constructor(options: {
281
339
  operation: string;
282
340
  status: number;
@@ -303,5 +361,104 @@ declare class GbpPaginationError extends GbpError {
303
361
  readonly pagesFetched: number;
304
362
  constructor(reason: "repeated_token" | "page_limit", pagesFetched: number);
305
363
  }
364
+ /**
365
+ * A published reviews file could not be used: the address answered an error (`http`, with `status`), the file was
366
+ * malformed or for another client (`invalid`), or it uses a schema this version does not read (`unknown_schema`,
367
+ * upgrade the package). The caller keeps the copy it already has.
368
+ */
369
+ declare class GbpPublishedError extends GbpError {
370
+ readonly reason: "http" | "invalid" | "unknown_schema";
371
+ readonly client: string;
372
+ readonly status?: number;
373
+ constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
374
+ }
375
+
376
+ /**
377
+ * Reviews that never throw into a page render.
378
+ *
379
+ * `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
380
+ * caller can choose what to do. Most callers want the same thing: keep showing
381
+ * what they showed last time, tell someone once, and carry on. A dead refresh
382
+ * token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
383
+ * Google 503 otherwise reaches every server render, and a site that reports
384
+ * each one sends thousands of events about one fault.
385
+ *
386
+ * The `...Safe` functions here catch every failure and serve, in order:
387
+ *
388
+ * 1. the last good result this process fetched for the same options
389
+ * (`source: "cache"`), up to `maxStaleMs` old;
390
+ * 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
391
+ * read (`source: "fallback"`);
392
+ * 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
393
+ *
394
+ * Each kind of failure is reported at most once per `reportIntervalMs` per
395
+ * process, through `report`. After an auth failure no amount of retrying
396
+ * helps, so the live call is skipped for `authRetryMs` and the cache or
397
+ * fallback is served straight away.
398
+ */
399
+
400
+ /** Where a safe result came from. */
401
+ type ReviewsSource = "live" | "cache" | "fallback" | "empty";
402
+ /** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
403
+ type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
404
+ source: ReviewsSource;
405
+ /** The failure behind a `cache`, `fallback` or `empty` result. */
406
+ error?: GbpError;
407
+ };
408
+ /** What `report` is told alongside the error. */
409
+ interface GbpFailureReport {
410
+ /** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
411
+ fingerprint: string[];
412
+ code: string;
413
+ /** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
414
+ transient: boolean;
415
+ /** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
416
+ context: Record<string, unknown>;
417
+ /** What the page was given instead. */
418
+ served: Exclude<ReviewsSource, "live">;
419
+ /** Failures of this kind left unreported since the last report, because they fell inside the window. */
420
+ suppressed: number;
421
+ }
422
+ interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
423
+ /**
424
+ * Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
425
+ * throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
426
+ * not applied to it.
427
+ */
428
+ fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
429
+ /**
430
+ * Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
431
+ *
432
+ * ```ts
433
+ * report: (error, { fingerprint, context, transient }) =>
434
+ * Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
435
+ * ```
436
+ */
437
+ report?: (error: GbpError, report: GbpFailureReport) => void;
438
+ /** Default one hour. */
439
+ reportIntervalMs?: number;
440
+ /** Longest a cached result is served after the last good fetch. Default 30 days. */
441
+ maxStaleMs?: number;
442
+ /** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
443
+ authRetryMs?: number;
444
+ }
445
+ declare const DEFAULT_REPORT_INTERVAL_MS: number;
446
+ declare const DEFAULT_MAX_STALE_MS: number;
447
+ declare const DEFAULT_AUTH_RETRY_MS: number;
448
+ /**
449
+ * `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
450
+ * empty result, and reports the failure at most once per window. `source` says which.
451
+ */
452
+ declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
453
+ /** A published result, or a fallback or empty one, which carries no sync times. */
454
+ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
455
+ /**
456
+ * `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
457
+ * empty result, and reports the failure at most once per window. `source` says which.
458
+ *
459
+ * Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
460
+ * throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
461
+ */
462
+ declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
306
463
 
307
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parseRetryAfter };
464
+ export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
package/dist/index.js CHANGED
@@ -1,16 +1,24 @@
1
1
  // src/errors.ts
2
2
  var GbpError = class extends Error {
3
- constructor(message) {
3
+ constructor(message, code = "unknown", transient = false, context = {}) {
4
4
  super(message);
5
5
  this.name = new.target.name;
6
+ this.code = code;
7
+ this.transient = transient;
8
+ this.context = context;
9
+ }
10
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
11
+ get fingerprint() {
12
+ return ["gbp", this.code];
6
13
  }
7
14
  };
8
15
  var GbpAuthError = class extends GbpError {
9
16
  constructor(code, status, description) {
10
- super(
11
- `Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
12
- );
13
- this.code = code;
17
+ super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
18
+ status,
19
+ ...description ? { description } : {},
20
+ ...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
21
+ });
14
22
  this.reauthorizationRequired = code === "invalid_grant";
15
23
  this.status = status;
16
24
  this.description = description;
@@ -18,7 +26,15 @@ var GbpAuthError = class extends GbpError {
18
26
  };
19
27
  var GbpApiError = class extends GbpError {
20
28
  constructor(options) {
21
- super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
29
+ const server = options.status >= 500;
30
+ super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
31
+ operation: options.operation,
32
+ status: options.status,
33
+ attempts: options.attempts,
34
+ ...options.body ? { body: options.body } : {},
35
+ ...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
36
+ });
37
+ this.body = options.body;
22
38
  this.operation = options.operation;
23
39
  this.status = options.status;
24
40
  this.retryable = options.retryable;
@@ -28,7 +44,7 @@ var GbpApiError = class extends GbpError {
28
44
  };
29
45
  var GbpTimeoutError = class extends GbpError {
30
46
  constructor(operation, timeoutMs) {
31
- super(`${operation} timed out after ${timeoutMs}ms`);
47
+ super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
32
48
  this.operation = operation;
33
49
  this.timeoutMs = timeoutMs;
34
50
  }
@@ -36,7 +52,10 @@ var GbpTimeoutError = class extends GbpError {
36
52
  var GbpPaginationError = class extends GbpError {
37
53
  constructor(reason, pagesFetched) {
38
54
  super(
39
- reason === "repeated_token" ? `Business Profile returned a page token it had already returned, after ${pagesFetched} page(s)` : `Business Profile reviews stopped at the page limit (${pagesFetched} pages)`
55
+ reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
56
+ `pagination_${reason}`,
57
+ false,
58
+ { pagesFetched }
40
59
  );
41
60
  this.reason = reason;
42
61
  this.pagesFetched = pagesFetched;
@@ -46,6 +65,19 @@ function excerpt(text, max = 300) {
46
65
  const flat = text.replace(/\s+/g, " ").trim();
47
66
  return flat.length > max ? `${flat.slice(0, max)}...` : flat;
48
67
  }
68
+ var GbpPublishedError = class extends GbpError {
69
+ constructor(reason, client, detail, status, body) {
70
+ const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
71
+ super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
72
+ client,
73
+ ...status !== void 0 ? { status } : {},
74
+ ...body ? { body } : {}
75
+ });
76
+ this.reason = reason;
77
+ this.client = client;
78
+ this.status = status;
79
+ }
80
+ };
49
81
 
50
82
  // src/http.ts
51
83
  var DEFAULT_REQUEST_POLICY = {
@@ -356,19 +388,223 @@ async function getBusinessReviews(options = {}) {
356
388
  reviews: order === "api" ? reviews : shuffleArray(reviews)
357
389
  };
358
390
  }
391
+
392
+ // src/published.ts
393
+ var DEFAULT_PUBLISHED_REVIEWS_BASE_URL = "https://files.domandigital.co.uk/reviews";
394
+ var PUBLISHED_REVIEWS_SCHEMA = 1;
395
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
396
+ var isTime = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
397
+ function parseReview2(v) {
398
+ if (!isRecord2(v)) return null;
399
+ const { id, author, rating, comment, createdAt, reply } = v;
400
+ if (typeof id !== "string" || typeof author !== "string" || typeof comment !== "string" || !comment) return null;
401
+ if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) return null;
402
+ const review = { id, author, rating, comment, createdAt: isTime(createdAt) ? createdAt : "" };
403
+ if (isRecord2(reply) && typeof reply.text === "string" && reply.text) {
404
+ review.reply = { text: reply.text, updatedAt: isTime(reply.updatedAt) ? reply.updatedAt : "" };
405
+ }
406
+ return review;
407
+ }
408
+ function parsePublishedReviews(body, client) {
409
+ if (!isRecord2(body)) throw new GbpPublishedError("invalid", client, "the file is not an object");
410
+ if (body.schema !== PUBLISHED_REVIEWS_SCHEMA) {
411
+ throw new GbpPublishedError("unknown_schema", client, `schema ${String(body.schema)}`);
412
+ }
413
+ if (body.client !== client) throw new GbpPublishedError("invalid", client, `the file is for ${String(body.client)}`);
414
+ const { rating, count } = body;
415
+ if (rating !== null && (typeof rating !== "number" || !(rating >= 0 && rating <= 5))) {
416
+ throw new GbpPublishedError("invalid", client, "rating out of range");
417
+ }
418
+ if (count !== null && (typeof count !== "number" || !Number.isInteger(count) || count < 0)) {
419
+ throw new GbpPublishedError("invalid", client, "count is not a whole number");
420
+ }
421
+ if (!isTime(body.synced_at) || !isTime(body.published_at) || !Array.isArray(body.reviews)) {
422
+ throw new GbpPublishedError("invalid", client, "synced_at, published_at or reviews missing");
423
+ }
424
+ return {
425
+ averageRating: rating,
426
+ totalReviewCount: count,
427
+ reviews: body.reviews.map(parseReview2).filter((r) => r !== null),
428
+ syncedAt: body.synced_at,
429
+ publishedAt: body.published_at
430
+ };
431
+ }
432
+ function shuffle(arr) {
433
+ const copy = [...arr];
434
+ for (let i = copy.length - 1; i > 0; i--) {
435
+ const j = Math.floor(Math.random() * (i + 1));
436
+ [copy[i], copy[j]] = [copy[j], copy[i]];
437
+ }
438
+ return copy;
439
+ }
440
+ function publishedReviewsUrl(client, baseUrl) {
441
+ if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(client)) throw new RangeError(`not a client slug: ${client}`);
442
+ const base = (baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? DEFAULT_PUBLISHED_REVIEWS_BASE_URL).replace(/\/+$/, "");
443
+ return `${base}/${client}.json`;
444
+ }
445
+ async function getPublishedReviews(options) {
446
+ const { client, filterMinStars, limit, order = "api", next, request } = options;
447
+ const url = publishedReviewsUrl(client, options.baseUrl);
448
+ const init = {
449
+ headers: { Accept: "application/json" },
450
+ next: next ?? { revalidate: 86400, tags: ["google-reviews"] }
451
+ };
452
+ const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
453
+ if (!response.ok) {
454
+ const body2 = excerpt(await readErrorBody(response), 120);
455
+ throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
456
+ }
457
+ let body;
458
+ try {
459
+ body = await response.json();
460
+ } catch {
461
+ throw new GbpPublishedError("invalid", client, "the file is not JSON");
462
+ }
463
+ const result = parsePublishedReviews(body, client);
464
+ let reviews = filterMinStars === void 0 ? result.reviews : result.reviews.filter((r) => r.rating >= filterMinStars);
465
+ if (limit !== void 0) reviews = reviews.slice(0, limit);
466
+ return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
467
+ }
468
+
469
+ // src/safe.ts
470
+ var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
471
+ var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
472
+ var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
473
+ var lastGood = /* @__PURE__ */ new Map();
474
+ var reported = /* @__PURE__ */ new Map();
475
+ var authBlockedUntil = /* @__PURE__ */ new Map();
476
+ function toGbpError(error) {
477
+ if (error instanceof GbpError) return error;
478
+ const name = error instanceof Error ? error.name : typeof error;
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
481
+ wrapped.cause = error;
482
+ return wrapped;
483
+ }
484
+ function isAuthFailure(error) {
485
+ return error.code === "invalid_grant" || error.code === "invalid_client";
486
+ }
487
+ function defaultReport(error, report) {
488
+ console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
489
+ }
490
+ async function loadFallback(fallback) {
491
+ if (fallback === void 0) return null;
492
+ try {
493
+ return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ function reportOnce(error, served, options) {
499
+ const now = Date.now();
500
+ const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
501
+ const last = reported.get(error.code);
502
+ if (last && now - last.at < window) {
503
+ last.suppressed++;
504
+ return;
505
+ }
506
+ reported.set(error.code, { at: now, suppressed: 0 });
507
+ try {
508
+ (options.report ?? defaultReport)(error, {
509
+ fingerprint: error.fingerprint,
510
+ code: error.code,
511
+ transient: error.transient,
512
+ context: error.context,
513
+ served,
514
+ suppressed: last?.suppressed ?? 0
515
+ });
516
+ } catch {
517
+ }
518
+ }
519
+ async function safely(key, load, empty, options) {
520
+ const now = Date.now();
521
+ let error;
522
+ const blocked = authBlockedUntil.get(key);
523
+ if (blocked && blocked.until > now) {
524
+ error = blocked.error;
525
+ } else {
526
+ try {
527
+ const result = await load();
528
+ lastGood.set(key, { result, at: Date.now() });
529
+ authBlockedUntil.delete(key);
530
+ return { ...result, source: "live" };
531
+ } catch (caught) {
532
+ error = toGbpError(caught);
533
+ const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
534
+ if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
535
+ }
536
+ }
537
+ let cached2 = lastGood.get(key);
538
+ if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
539
+ lastGood.delete(key);
540
+ cached2 = void 0;
541
+ }
542
+ let served;
543
+ if (cached2) {
544
+ served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
545
+ } else {
546
+ const fallback = await loadFallback(options.fallback);
547
+ served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
548
+ }
549
+ reportOnce(error, served.source, options);
550
+ return served;
551
+ }
552
+ function getBusinessReviewsSafe(options = {}) {
553
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
554
+ const key = JSON.stringify([
555
+ "business",
556
+ process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
557
+ fetchOptions.limit ?? null,
558
+ fetchOptions.filterMinStars ?? null,
559
+ fetchOptions.includeUnapprovedReplies ?? false
560
+ ]);
561
+ return safely(
562
+ key,
563
+ () => getBusinessReviews(fetchOptions),
564
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
565
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
566
+ );
567
+ }
568
+ function getPublishedReviewsSafe(options) {
569
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
570
+ const key = JSON.stringify([
571
+ "published",
572
+ fetchOptions.client,
573
+ fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
574
+ fetchOptions.limit ?? null,
575
+ fetchOptions.filterMinStars ?? null
576
+ ]);
577
+ return safely(
578
+ key,
579
+ () => getPublishedReviews(fetchOptions),
580
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
581
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
582
+ );
583
+ }
359
584
  export {
585
+ DEFAULT_AUTH_RETRY_MS,
360
586
  DEFAULT_MAX_PAGES,
587
+ DEFAULT_MAX_STALE_MS,
588
+ DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
589
+ DEFAULT_REPORT_INTERVAL_MS,
361
590
  DEFAULT_REQUEST_POLICY,
362
591
  GbpApiError,
363
592
  GbpAuthError,
364
593
  GbpError,
365
594
  GbpPaginationError,
595
+ GbpPublishedError,
366
596
  GbpTimeoutError,
597
+ PUBLISHED_REVIEWS_SCHEMA,
367
598
  getBusinessReviews,
599
+ getBusinessReviewsSafe,
368
600
  getGoogleOAuthAccessToken,
601
+ getPublishedReviews,
602
+ getPublishedReviewsSafe,
369
603
  hasGoogleOAuthCredentials,
370
604
  invalidateAccessToken,
371
605
  isBusinessProfileConfigured,
372
606
  parseListReviewsResponse,
373
- parseRetryAfter
607
+ parsePublishedReviews,
608
+ parseRetryAfter,
609
+ publishedReviewsUrl
374
610
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/gbp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Zero-dependency Google Business Profile client: OAuth refresh-token auth and a reviews (with owner replies) fetch, shared across Doman Digital / IDS client sites.",
6
6
  "license": "Apache-2.0",
@@ -32,7 +32,7 @@
32
32
  "@types/node": "^20",
33
33
  "tsup": "^8.3.5",
34
34
  "typescript": "^5.6.3",
35
- "vitest": "^2.1.9"
35
+ "vitest": "^4.0.0"
36
36
  },
37
37
  "engines": {
38
38
  "node": ">=22.12"