@domandigital/gbp 0.3.1 → 0.4.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bcc2a83: Reviews survive a bad minute at Google, and stop publishing a count Google never gave.
8
+
9
+ - **Breaking: `totalReviewCount` is now `number | null`.** A missing count used to fall back to the length of the filtered, limited list, which would put a wrong number into anything built from it (an `aggregateRating`, a "120 reviews" badge). Now it is `null`, and so is an unconfigured result. Handle `null` by rendering no number.
10
+ - A 401 drops the cached access token, refreshes once and asks for the same page again; a second 401 throws. Concurrent callers share one token refresh.
11
+ - Every request has an 8 s deadline. 429 and 500/502/503/504 are retried up to three attempts with full-jitter backoff, honouring `Retry-After` up to 30 s. Tunable with `request`.
12
+ - Pagination stops on a repeated page token or after `maxPages` (default 50) with a `GbpPaginationError`, rather than looping.
13
+ - Typed errors: `GbpAuthError` (`reauthorizationRequired` on `invalid_grant`), `GbpApiError`, `GbpTimeoutError`, `GbpPaginationError`, all extending `GbpError`, none carrying a credential.
14
+ - New review fields from Google: attached photos and videos (`media`), the reply link (`replyUrl`), and the reply's moderation `state` with `policyViolation`. Replies Google has not approved are left off unless `includeUnapprovedReplies` is set, so a site never shows a rejected reply.
15
+ - The response is validated: a malformed review is skipped, a malformed page throws.
16
+
17
+ - 3eb1560: CommonJS consumers now get the CommonJS type declarations. The `exports` map put a top-level `types` (the ESM `.d.ts`) ahead of `require`, so TypeScript resolving a `require` under `node16` stopped at the ESM file ("Masquerading as ESM" in @arethetypeswrong/cli). `types` now sits inside `import` and `require` separately. Runtime behaviour is unchanged.
18
+
19
+ Requires Node 22.12 or later (`engines.node`). Node 20 reached end of life on 2026-04-30. Also declares `sideEffects` so bundlers can tree-shake (craft keeps its CSS).
20
+
3
21
  All notable changes to `@domandigital/gbp`.
4
22
 
5
23
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
@@ -12,6 +30,29 @@ time.
12
30
 
13
31
  ## [Unreleased]
14
32
 
33
+ ## [0.3.2] - 2026-08-16
34
+
35
+ ### Added
36
+
37
+ - `GetBusinessReviewsOptions.order`, `"shuffle" | "api"`. Default is
38
+ `"shuffle"`, unchanged from prior behaviour. `"api"` preserves the Business
39
+ Profile API's own `updateTime desc` ordering instead.
40
+
41
+ ### Fixed
42
+
43
+ - **`limit` could silently under-return.** Pagination stopped once `raw.length`
44
+ reached `limit`, but the comment/star-rating filter that determines which
45
+ reviews are actually usable ran after the loop. A page that was mostly
46
+ star-only ratings with no comment -- which the API allows -- could satisfy
47
+ the raw count while producing far fewer, or zero, usable reviews, with no
48
+ error and no further pages fetched. The stopping condition now counts
49
+ usable reviews, so `limit` means "give me N reviews you can show."
50
+
51
+ ### Changed
52
+
53
+ - `limit`'s doc comment now says what it actually counts (usable reviews, not
54
+ raw API results), matching the fix above.
55
+
15
56
  ## [0.3.1] - 2026-08-16
16
57
 
17
58
  ### Added
@@ -97,7 +138,8 @@ time.
97
138
  `filterMinStars` and a Next.js `next` cache hint. Returns empty results rather
98
139
  than throwing when unconfigured, so UI can render unconditionally.
99
140
 
100
- [Unreleased]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.1...HEAD
141
+ [Unreleased]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.2...HEAD
142
+ [0.3.2]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.1...v0.3.2
101
143
  [0.3.1]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.0...v0.3.1
102
144
  [0.3.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.2.0...v0.3.0
103
145
  [0.2.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -38,10 +38,28 @@ const reviewNodes = reviews.map((r) =>
38
38
  );
39
39
  ```
40
40
 
41
- `averageRating` / `totalReviewCount` feed `OrganizationInput.aggregateRating`
42
- directly -- don't hardcode a rating/count literal in a site's settings file
43
- once this is wired in. That literal drifting from the real listing (a site
44
- claiming 7 reviews when Google has 6) is the bug this package exists to kill.
41
+ `averageRating` / `totalReviewCount` are Google's own numbers for the whole
42
+ profile, rating-only reviews included. Show them instead of hardcoding a
43
+ rating/count literal in a site's settings file: that literal drifting from the
44
+ real listing (a site claiming 7 reviews when Google has 6) is the bug this
45
+ package exists to kill. Both are `null` when Google did not report them;
46
+ render nothing in that case rather than a number.
47
+
48
+ ### Structured data: these reviews earn no stars on the business's own site
49
+
50
+ Google calls a review "self-serving" when a review about a business sits on
51
+ that business's own website, whether written into the markup or through an
52
+ embedded widget. For `LocalBusiness` and `Organization`, Google shows review
53
+ stars only "for sites that capture reviews about other" businesses
54
+ (developers.google.com/search/docs/appearance/structured-data/review-snippet,
55
+ updated 2026-09-08). So `Review` or `AggregateRating` markup built from these
56
+ reviews, on the client's own site, is ineligible for stars.
57
+
58
+ It is not a penalty: Google's announcement of the rule says you do not need to
59
+ remove such markup and "You won't get a manual action just for this"
60
+ (developers.google.com/search/blog/2019/09/making-review-rich-results-more-helpful).
61
+ Showing the reviews on the page is unaffected. Just do not sell or expect the
62
+ stars. `@domandigital/graph`'s `findGraphIssues` can flag the pattern.
45
63
 
46
64
  ## Why Business Profile API, not Places API
47
65
 
@@ -59,7 +77,7 @@ pnpm add @domandigital/gbp
59
77
 
60
78
  Public on npm, Apache-2.0, published with provenance from a tagged release.
61
79
  Zero runtime dependencies. ESM and CJS builds ship together, each with its own
62
- types. Requires Node 20 or newer.
80
+ types. Requires Node 22.12 or newer.
63
81
 
64
82
  Upgrading a repo that still pins `github:Doman-Digital/dd-gbp#vX.Y.Z`? Swap it
65
83
  for a semver range.
@@ -122,8 +140,8 @@ Paginates through every review (GBP caps each page at 50) and returns:
122
140
 
123
141
  ```ts
124
142
  interface BusinessReviewsResult {
125
- averageRating: number | null;
126
- totalReviewCount: number;
143
+ averageRating: number | null; // Google's, for the whole profile; null if not reported
144
+ totalReviewCount: number | null; // Google's, for the whole profile; null if not reported
127
145
  reviews: BusinessReview[];
128
146
  }
129
147
 
@@ -134,14 +152,26 @@ interface BusinessReview {
134
152
  rating: number; // 1-5
135
153
  comment: string;
136
154
  createdAt: string; // ISO
137
- reply?: { text: string; updatedAt: string }; // no author field -- see below
155
+ reply?: { // no author field -- see below
156
+ text: string;
157
+ updatedAt: string;
158
+ state?: "PENDING" | "REJECTED" | "APPROVED"; // Google's moderation state
159
+ policyViolation?: string; // why, when REJECTED
160
+ };
161
+ media?: { thumbnailUrl: string; label?: string; videoUrl?: string }[]; // photos/videos the reviewer attached
162
+ replyUrl?: string; // Google's URL for replying, for an owner-facing surface
138
163
  }
139
164
  ```
140
165
 
166
+ `totalReviewCount` and `averageRating` are never computed from the returned
167
+ list. Before 0.4.0, a missing `totalReviewCount` fell back to the length of
168
+ the filtered, limited list, which would have published a wrong count.
169
+
141
170
  Options:
142
171
 
143
- - `limit?: number` -- stop paginating once this many reviews are collected.
144
- Omit to fetch all of them.
172
+ - `limit?: number` -- stop paginating once this many *usable* reviews are
173
+ collected (a raw result needs both a comment and a star rating to count, and
174
+ `filterMinStars` narrows it further). Omit to fetch all of them.
145
175
  - `filterMinStars?: number` -- drop reviews below this rating. Use for a
146
176
  public testimonial feed; omit for an owner-facing surface where the point
147
177
  is to see everything. `totalReviewCount` always reflects the location's
@@ -149,12 +179,52 @@ Options:
149
179
  - `next?: { revalidate?: number; tags?: string[] }` -- passed straight through
150
180
  to `fetch`'s Next.js cache-hint augmentation. A no-op outside Next.js.
151
181
  Default: revalidate hourly.
182
+ - `order?: "shuffle" | "api"` -- `"shuffle"` (default) randomizes the returned
183
+ order, applied after `limit`. `"api"` preserves the Business Profile API's
184
+ own ordering (`updateTime desc`, most recent first). Pick `"api"` for
185
+ anything that should read as chronological, e.g. an activity feed; the
186
+ default suits a testimonial grid where a fixed order would look stale.
187
+ - `includeUnapprovedReplies?: boolean` -- default `false`: an owner reply
188
+ Google has marked `PENDING` or `REJECTED` is left off, so a public page never
189
+ shows a reply Google does not. Set `true` for an owner-facing surface, where
190
+ `reply.state` and `reply.policyViolation` say what happened.
191
+ - `maxPages?: number` -- stop after this many pages (default 50, i.e. 2,500
192
+ reviews) rather than loop.
193
+ - `request?: { timeoutMs?, maxAttempts?, baseDelayMs?, maxDelayMs?, maxRetryAfterMs?, signal? }`
194
+ -- deadline and retry policy for every request the call makes. Defaults: 8 s
195
+ per attempt, 3 attempts, full-jitter backoff from 250 ms capped at 5 s, and a
196
+ `Retry-After` of up to 30 s honoured.
152
197
 
153
198
  Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
154
199
  internally and returns an empty result, so UI can render unconditionally.
155
- Does throw on a real API failure (non-OK response), so a calling route should
156
- catch and degrade explicitly if it wants zero-downtime behavior on a Google
157
- outage.
200
+ 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.
202
+
203
+ ### Failures, and what each one means
204
+
205
+ Every request has a deadline. A 429 or a transient 500/502/503/504 is retried
206
+ a few times with backoff; Google documents the 429 for quota
207
+ (developers.google.com/my-business/content/limits). Nothing else is retried.
208
+ A 401 means the cached access token is no longer good: it is dropped,
209
+ refreshed once, and the page is asked for again; a second 401 throws.
210
+
211
+ All errors extend `GbpError`, and none carries a credential:
212
+
213
+ | Error | When | What to do |
214
+ |---|---|---|
215
+ | `GbpAuthError` with `reauthorizationRequired: true` (`code: "invalid_grant"`) | Google refused the refresh token | Reauthorise the account and replace `GBP_REFRESH_TOKEN`. Retrying does nothing. |
216
+ | `GbpAuthError` (`code: "invalid_client"`) | The client id or secret is wrong | Fix the environment. |
217
+ | `GbpApiError` | The API returned an error after the retry budget. `status`, `retryable`, `attempts`, and `retryAfterMs` when Google asked for a long wait | Degrade, and try again on the next revalidation. |
218
+ | `GbpTimeoutError` | An attempt passed its deadline | As above. |
219
+ | `GbpPaginationError` | Google repeated a page token, or `maxPages` ran out | Report it: something upstream is wrong. |
220
+
221
+ Refresh tokens stop working when the user revokes access, when a token goes
222
+ unused for six months, when the account passes 100 live refresh tokens for
223
+ the client (the oldest is dropped without warning), and **after 7 days if the
224
+ OAuth app is still in "Testing" status**: publish the app, or expect weekly
225
+ reauthorisation (developers.google.com/identity/protocols/oauth2). Google can
226
+ also delete an OAuth client that goes unused, recoverable for 30 days
227
+ (developers.google.com/identity/protocols/oauth2/web-server).
158
228
 
159
229
  ### Why a reply has no author field
160
230
 
@@ -174,5 +244,8 @@ True once all five env vars above are set.
174
244
 
175
245
  Lower-level OAuth primitives, exported in case a consumer needs the raw
176
246
  access token for another Business Profile endpoint this package doesn't wrap
177
- (e.g. business hours, Q&A). The token is cached in-process and refreshed a
178
- minute before expiry.
247
+ (e.g. business hours). The token is cached in-process and refreshed a minute
248
+ before expiry; concurrent callers share one refresh.
249
+ `getGoogleOAuthAccessToken({ forceRefresh: true })` refreshes now, and
250
+ `invalidateAccessToken(token)` drops a token an API has rejected (only if it is
251
+ still the cached one).
package/dist/index.cjs CHANGED
@@ -20,28 +20,178 @@ 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_MAX_PAGES: () => DEFAULT_MAX_PAGES,
24
+ DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
25
+ GbpApiError: () => GbpApiError,
26
+ GbpAuthError: () => GbpAuthError,
27
+ GbpError: () => GbpError,
28
+ GbpPaginationError: () => GbpPaginationError,
29
+ GbpTimeoutError: () => GbpTimeoutError,
23
30
  getBusinessReviews: () => getBusinessReviews,
24
31
  getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
25
32
  hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
26
- isBusinessProfileConfigured: () => isBusinessProfileConfigured
33
+ invalidateAccessToken: () => invalidateAccessToken,
34
+ isBusinessProfileConfigured: () => isBusinessProfileConfigured,
35
+ parseListReviewsResponse: () => parseListReviewsResponse,
36
+ parseRetryAfter: () => parseRetryAfter
27
37
  });
28
38
  module.exports = __toCommonJS(index_exports);
29
39
 
40
+ // src/errors.ts
41
+ var GbpError = class extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = new.target.name;
45
+ }
46
+ };
47
+ var GbpAuthError = class extends GbpError {
48
+ 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;
53
+ this.reauthorizationRequired = code === "invalid_grant";
54
+ this.status = status;
55
+ this.description = description;
56
+ }
57
+ };
58
+ var GbpApiError = class extends GbpError {
59
+ constructor(options) {
60
+ super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
61
+ this.operation = options.operation;
62
+ this.status = options.status;
63
+ this.retryable = options.retryable;
64
+ this.attempts = options.attempts;
65
+ this.retryAfterMs = options.retryAfterMs;
66
+ }
67
+ };
68
+ var GbpTimeoutError = class extends GbpError {
69
+ constructor(operation, timeoutMs) {
70
+ super(`${operation} timed out after ${timeoutMs}ms`);
71
+ this.operation = operation;
72
+ this.timeoutMs = timeoutMs;
73
+ }
74
+ };
75
+ var GbpPaginationError = class extends GbpError {
76
+ constructor(reason, pagesFetched) {
77
+ 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)`
79
+ );
80
+ this.reason = reason;
81
+ this.pagesFetched = pagesFetched;
82
+ }
83
+ };
84
+ function excerpt(text, max = 300) {
85
+ const flat = text.replace(/\s+/g, " ").trim();
86
+ return flat.length > max ? `${flat.slice(0, max)}...` : flat;
87
+ }
88
+
89
+ // src/http.ts
90
+ var DEFAULT_REQUEST_POLICY = {
91
+ timeoutMs: 8e3,
92
+ maxAttempts: 3,
93
+ baseDelayMs: 250,
94
+ maxDelayMs: 5e3,
95
+ maxRetryAfterMs: 3e4
96
+ };
97
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
98
+ function resolvePolicy(options = {}) {
99
+ const policy = { ...DEFAULT_REQUEST_POLICY, ...options };
100
+ const check = (name, min, integer = false) => {
101
+ const value = policy[name];
102
+ if (!Number.isFinite(value) || value < min || integer && !Number.isInteger(value)) {
103
+ throw new RangeError(`request.${name} must be ${integer ? "an integer" : "a number"} >= ${min}, got ${value}`);
104
+ }
105
+ };
106
+ check("timeoutMs", 1);
107
+ check("maxAttempts", 1, true);
108
+ check("baseDelayMs", 0);
109
+ check("maxDelayMs", 0);
110
+ check("maxRetryAfterMs", 0);
111
+ return policy;
112
+ }
113
+ function parseRetryAfter(value, nowMs = Date.now()) {
114
+ if (!value) return null;
115
+ const v = value.trim();
116
+ if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number.parseFloat(v) * 1e3);
117
+ const at = Date.parse(v);
118
+ return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
119
+ }
120
+ function backoffMs(attempt, policy, random = Math.random) {
121
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
122
+ return Math.floor(random() * cap);
123
+ }
124
+ function sleep(ms, signal) {
125
+ if (ms <= 0) return Promise.resolve();
126
+ return new Promise((resolve, reject) => {
127
+ if (signal?.aborted) return reject(signal.reason);
128
+ const timer = setTimeout(resolve, ms);
129
+ signal?.addEventListener(
130
+ "abort",
131
+ () => {
132
+ clearTimeout(timer);
133
+ reject(signal.reason);
134
+ },
135
+ { once: true }
136
+ );
137
+ });
138
+ }
139
+ async function attemptOnce(url, init, operation, policy) {
140
+ const deadline = new AbortController();
141
+ const timer = setTimeout(() => deadline.abort(new GbpTimeoutError(operation, policy.timeoutMs)), policy.timeoutMs);
142
+ const signal = policy.signal ? AbortSignal.any([policy.signal, deadline.signal]) : deadline.signal;
143
+ try {
144
+ return await fetch(url, { ...init, signal });
145
+ } catch (error) {
146
+ if (deadline.signal.aborted && !policy.signal?.aborted) throw new GbpTimeoutError(operation, policy.timeoutMs);
147
+ throw error;
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ }
152
+ async function fetchWithRetry(url, init, operation, policy) {
153
+ for (let attempt = 1; ; attempt++) {
154
+ if (policy.signal?.aborted) throw policy.signal.reason;
155
+ let response;
156
+ try {
157
+ response = await attemptOnce(url, init, operation, policy);
158
+ } catch (error) {
159
+ if (policy.signal?.aborted || attempt >= policy.maxAttempts) throw error;
160
+ await sleep(backoffMs(attempt, policy), policy.signal);
161
+ continue;
162
+ }
163
+ if (response.ok || !RETRYABLE_STATUSES.has(response.status)) return { response, attempts: attempt };
164
+ const retryAfter = parseRetryAfter(response.headers?.get?.("retry-after"));
165
+ if (retryAfter !== null && retryAfter > policy.maxRetryAfterMs) return { response, attempts: attempt, retryAfterMs: retryAfter };
166
+ if (attempt >= policy.maxAttempts) return { response, attempts: attempt };
167
+ await sleep(retryAfter ?? backoffMs(attempt, policy), policy.signal);
168
+ }
169
+ }
170
+ async function readErrorBody(response) {
171
+ try {
172
+ const text = typeof response.text === "function" ? await response.text() : "";
173
+ return text.slice(0, 2048);
174
+ } catch {
175
+ return "";
176
+ }
177
+ }
178
+
30
179
  // src/oauth-client.ts
31
180
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
32
181
  var EXPIRY_SKEW_MS = 60 * 1e3;
182
+ var DEFAULT_LIFETIME_S = 3600;
33
183
  var cached = null;
184
+ var refreshing = null;
34
185
  function hasGoogleOAuthCredentials() {
35
186
  return Boolean(
36
187
  process.env.GBP_CLIENT_ID && process.env.GBP_CLIENT_SECRET && process.env.GBP_REFRESH_TOKEN
37
188
  );
38
189
  }
39
- async function getGoogleOAuthAccessToken() {
40
- if (cached && cached.expiresAt > Date.now()) {
41
- return cached.accessToken;
42
- }
43
- if (!hasGoogleOAuthCredentials()) return null;
44
- const res = await fetch(TOKEN_URI, {
190
+ function invalidateAccessToken(accessToken) {
191
+ if (cached?.accessToken === accessToken) cached = null;
192
+ }
193
+ async function refresh(request) {
194
+ const init = {
45
195
  method: "POST",
46
196
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
197
  body: new URLSearchParams({
@@ -49,17 +199,47 @@ async function getGoogleOAuthAccessToken() {
49
199
  client_secret: process.env.GBP_CLIENT_SECRET,
50
200
  refresh_token: process.env.GBP_REFRESH_TOKEN,
51
201
  grant_type: "refresh_token"
52
- })
53
- });
54
- if (!res.ok) {
55
- throw new Error(`Google OAuth token refresh failed: ${res.status} ${await res.text()}`);
56
- }
57
- const data = await res.json();
58
- cached = {
59
- accessToken: data.access_token,
60
- expiresAt: Date.now() + (data.expires_in ?? 3600) * 1e3 - EXPIRY_SKEW_MS
202
+ }),
203
+ cache: "no-store"
61
204
  };
62
- return cached.accessToken;
205
+ const { response } = await fetchWithRetry(TOKEN_URI, init, "oauth.refresh", resolvePolicy(request));
206
+ if (!response.ok) {
207
+ const body = await readErrorBody(response);
208
+ let error;
209
+ let description;
210
+ try {
211
+ const parsed = JSON.parse(body);
212
+ if (typeof parsed.error === "string") error = parsed.error;
213
+ if (typeof parsed.error_description === "string") description = excerpt(parsed.error_description, 200);
214
+ } catch {
215
+ if (/\binvalid_grant\b/.test(body)) error = "invalid_grant";
216
+ else if (/\binvalid_client\b/.test(body)) error = "invalid_client";
217
+ }
218
+ if (error === "invalid_grant") cached = null;
219
+ const code = error === "invalid_grant" || error === "invalid_client" ? error : "token_request_failed";
220
+ throw new GbpAuthError(code, response.status, description ?? (code === "token_request_failed" && body ? excerpt(body, 200) : void 0));
221
+ }
222
+ const data = await response.json();
223
+ if (typeof data.access_token !== "string" || !data.access_token) {
224
+ throw new GbpAuthError("token_request_failed", response.status, "token response had no access_token");
225
+ }
226
+ const lifetime = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0 ? data.expires_in : DEFAULT_LIFETIME_S;
227
+ return { accessToken: data.access_token, expiresAt: Date.now() + lifetime * 1e3 - EXPIRY_SKEW_MS };
228
+ }
229
+ async function getGoogleOAuthAccessToken(options = {}) {
230
+ if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) {
231
+ return cached.accessToken;
232
+ }
233
+ if (!hasGoogleOAuthCredentials()) return null;
234
+ if (!refreshing) {
235
+ refreshing = refresh(options.request).then((token) => {
236
+ cached = token;
237
+ return token;
238
+ }).finally(() => {
239
+ refreshing = null;
240
+ });
241
+ }
242
+ return (await refreshing).accessToken;
63
243
  }
64
244
 
65
245
  // src/reviews.ts
@@ -71,6 +251,7 @@ var STAR_VALUES = {
71
251
  FIVE: 5
72
252
  };
73
253
  var MAX_PAGE_SIZE = 50;
254
+ var DEFAULT_MAX_PAGES = 50;
74
255
  function shuffleArray(arr) {
75
256
  const copy = [...arr];
76
257
  for (let i = copy.length - 1; i > 0; i--) {
@@ -79,15 +260,50 @@ function shuffleArray(arr) {
79
260
  }
80
261
  return copy;
81
262
  }
263
+ function isUsableReview(r, filterMinStars) {
264
+ if (!r.comment || !r.starRating || !(r.starRating in STAR_VALUES)) return false;
265
+ if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
266
+ return true;
267
+ }
82
268
  var EMPTY = {
83
269
  averageRating: null,
84
- totalReviewCount: 0,
270
+ totalReviewCount: null,
85
271
  reviews: []
86
272
  };
87
273
  function isBusinessProfileConfigured() {
88
274
  return hasGoogleOAuthCredentials() && Boolean(process.env.GOOGLE_BUSINESS_ACCOUNT_ID) && Boolean(process.env.GOOGLE_BUSINESS_LOCATION_ID);
89
275
  }
90
- function toBusinessReview(r) {
276
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
277
+ var str = (v) => typeof v === "string" ? v : void 0;
278
+ function parseReview(v) {
279
+ if (!isRecord(v) || typeof v.reviewId !== "string") return null;
280
+ const reviewer = isRecord(v.reviewer) ? { displayName: str(v.reviewer.displayName), profilePhotoUrl: str(v.reviewer.profilePhotoUrl) } : void 0;
281
+ const reply = isRecord(v.reviewReply) ? {
282
+ comment: str(v.reviewReply.comment),
283
+ updateTime: str(v.reviewReply.updateTime),
284
+ reviewReplyState: str(v.reviewReply.reviewReplyState),
285
+ policyViolation: str(v.reviewReply.policyViolation)
286
+ } : void 0;
287
+ const media = Array.isArray(v.reviewMediaItems) ? v.reviewMediaItems.filter(isRecord).map((m) => ({ thumbnailUrl: str(m.thumbnailUrl), thumbnailLabel: str(m.thumbnailLabel), videoUrl: str(m.videoUrl) })) : void 0;
288
+ return {
289
+ reviewId: v.reviewId,
290
+ reviewer,
291
+ starRating: str(v.starRating),
292
+ comment: str(v.comment),
293
+ createTime: str(v.createTime),
294
+ reviewReply: reply,
295
+ reviewMediaItems: media,
296
+ reviewReplyUrl: str(v.reviewReplyUrl)
297
+ };
298
+ }
299
+ function parseListReviewsResponse(body) {
300
+ if (!isRecord(body)) throw new TypeError("Business Profile reviews response was not an object");
301
+ const reviews = Array.isArray(body.reviews) ? body.reviews.map(parseReview).filter((r) => r !== null) : [];
302
+ const average = typeof body.averageRating === "number" && Number.isFinite(body.averageRating) && body.averageRating >= 0 && body.averageRating <= 5 ? body.averageRating : void 0;
303
+ const total = typeof body.totalReviewCount === "number" && Number.isInteger(body.totalReviewCount) && body.totalReviewCount >= 0 ? body.totalReviewCount : void 0;
304
+ return { reviews, averageRating: average, totalReviewCount: total, nextPageToken: str(body.nextPageToken) || void 0 };
305
+ }
306
+ function toBusinessReview(r, includeUnapprovedReplies) {
91
307
  const review = {
92
308
  id: r.reviewId,
93
309
  author: r.reviewer?.displayName ?? "Anonymous",
@@ -96,15 +312,33 @@ function toBusinessReview(r) {
96
312
  comment: r.comment ?? "",
97
313
  createdAt: r.createTime ?? ""
98
314
  };
99
- if (r.reviewReply?.comment) {
100
- review.reply = { text: r.reviewReply.comment, updatedAt: r.reviewReply.updateTime ?? "" };
315
+ const reply = r.reviewReply;
316
+ if (reply?.comment) {
317
+ const state = reply.reviewReplyState === "PENDING" || reply.reviewReplyState === "REJECTED" || reply.reviewReplyState === "APPROVED" ? reply.reviewReplyState : void 0;
318
+ if (includeUnapprovedReplies || state !== "PENDING" && state !== "REJECTED") {
319
+ review.reply = { text: reply.comment, updatedAt: reply.updateTime ?? "" };
320
+ if (state) review.reply.state = state;
321
+ if (state === "REJECTED" && reply.policyViolation) review.reply.policyViolation = reply.policyViolation;
322
+ }
101
323
  }
324
+ const media = (r.reviewMediaItems ?? []).filter((m) => Boolean(m.thumbnailUrl));
325
+ if (media.length > 0) {
326
+ review.media = media.map((m) => ({
327
+ thumbnailUrl: m.thumbnailUrl,
328
+ ...m.thumbnailLabel ? { label: m.thumbnailLabel } : {},
329
+ ...m.videoUrl ? { videoUrl: m.videoUrl } : {}
330
+ }));
331
+ }
332
+ if (r.reviewReplyUrl) review.replyUrl = r.reviewReplyUrl;
102
333
  return review;
103
334
  }
104
335
  async function getBusinessReviews(options = {}) {
105
- const { limit, filterMinStars, next } = options;
336
+ const { limit, filterMinStars, next, order = "shuffle", includeUnapprovedReplies = false, request } = options;
337
+ const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
338
+ if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer >= 1, got ${maxPages}`);
106
339
  if (!isBusinessProfileConfigured()) return EMPTY;
107
- const token = await getGoogleOAuthAccessToken();
340
+ const policy = resolvePolicy(request);
341
+ let token = await getGoogleOAuthAccessToken({ request });
108
342
  if (!token) return EMPTY;
109
343
  const account = process.env.GOOGLE_BUSINESS_ACCOUNT_ID;
110
344
  const location = process.env.GOOGLE_BUSINESS_LOCATION_ID;
@@ -112,33 +346,69 @@ async function getBusinessReviews(options = {}) {
112
346
  let averageRating;
113
347
  let totalReviewCount;
114
348
  let pageToken;
115
- do {
116
- const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
117
- const fetchOptions = {
118
- headers: { Authorization: `Bearer ${token}` },
349
+ const seenTokens = /* @__PURE__ */ new Set();
350
+ let pages = 0;
351
+ const fetchPage = (accessToken, endpoint) => {
352
+ const init = {
353
+ headers: { Authorization: `Bearer ${accessToken}` },
119
354
  next: next ?? { revalidate: 3600, tags: ["google-reviews"] }
120
355
  };
121
- const res = await fetch(endpoint, fetchOptions);
122
- if (!res.ok) {
123
- throw new Error(`Business Profile reviews failed: ${res.status} ${await res.text()}`);
356
+ return fetchWithRetry(endpoint, init, "reviews.list", policy);
357
+ };
358
+ for (; ; ) {
359
+ const endpoint = `https://mybusiness.googleapis.com/v4/accounts/${account}/locations/${location}/reviews?orderBy=updateTime%20desc&pageSize=${MAX_PAGE_SIZE}` + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "");
360
+ let attempt = await fetchPage(token, endpoint);
361
+ if (attempt.response.status === 401) {
362
+ invalidateAccessToken(token);
363
+ const fresh = await getGoogleOAuthAccessToken({ forceRefresh: true, request });
364
+ if (!fresh) return EMPTY;
365
+ token = fresh;
366
+ attempt = await fetchPage(token, endpoint);
367
+ }
368
+ const { response, attempts, retryAfterMs } = attempt;
369
+ if (!response.ok) {
370
+ throw new GbpApiError({
371
+ operation: "reviews.list",
372
+ status: response.status,
373
+ retryable: RETRYABLE_STATUSES.has(response.status),
374
+ attempts,
375
+ body: excerpt(await readErrorBody(response)),
376
+ retryAfterMs
377
+ });
124
378
  }
125
- const data = await res.json();
126
- raw.push(...data.reviews ?? []);
127
- averageRating = data.averageRating ?? averageRating;
128
- totalReviewCount = data.totalReviewCount ?? totalReviewCount;
379
+ const data = parseListReviewsResponse(await response.json());
380
+ pages++;
381
+ raw.push(...data.reviews);
382
+ averageRating ?? (averageRating = data.averageRating);
383
+ totalReviewCount ?? (totalReviewCount = data.totalReviewCount);
129
384
  pageToken = data.nextPageToken;
130
- } while (pageToken && (limit === void 0 || raw.length < limit));
131
- const reviews = raw.filter((r) => r.comment && r.starRating).filter((r) => filterMinStars === void 0 || STAR_VALUES[r.starRating] >= filterMinStars).map(toBusinessReview).slice(0, limit);
385
+ const enough = limit !== void 0 && raw.filter((r) => isUsableReview(r, filterMinStars)).length >= limit;
386
+ if (!pageToken || enough) break;
387
+ if (seenTokens.has(pageToken)) throw new GbpPaginationError("repeated_token", pages);
388
+ seenTokens.add(pageToken);
389
+ if (pages >= maxPages) throw new GbpPaginationError("page_limit", pages);
390
+ }
391
+ const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map((r) => toBusinessReview(r, includeUnapprovedReplies)).slice(0, limit);
132
392
  return {
133
393
  averageRating: averageRating ?? null,
134
- totalReviewCount: totalReviewCount ?? reviews.length,
135
- reviews: shuffleArray(reviews)
394
+ totalReviewCount: totalReviewCount ?? null,
395
+ reviews: order === "api" ? reviews : shuffleArray(reviews)
136
396
  };
137
397
  }
138
398
  // Annotate the CommonJS export names for ESM import in node:
139
399
  0 && (module.exports = {
400
+ DEFAULT_MAX_PAGES,
401
+ DEFAULT_REQUEST_POLICY,
402
+ GbpApiError,
403
+ GbpAuthError,
404
+ GbpError,
405
+ GbpPaginationError,
406
+ GbpTimeoutError,
140
407
  getBusinessReviews,
141
408
  getGoogleOAuthAccessToken,
142
409
  hasGoogleOAuthCredentials,
143
- isBusinessProfileConfigured
410
+ invalidateAccessToken,
411
+ isBusinessProfileConfigured,
412
+ parseListReviewsResponse,
413
+ parseRetryAfter
144
414
  });