@domandigital/gbp 0.3.2 → 0.5.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4588886: Read reviews from the file Doman Digital publishes, so a site needs no Google token.
8
+
9
+ - **`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.
10
+ - 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.
11
+ - 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.
12
+ - Defaults to newest first (`order: "api"`) and a daily Next.js revalidate tagged `google-reviews`.
13
+
14
+ ## 0.4.0
15
+
16
+ ### Minor Changes
17
+
18
+ - bcc2a83: Reviews survive a bad minute at Google, and stop publishing a count Google never gave.
19
+
20
+ - **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.
21
+ - 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.
22
+ - 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`.
23
+ - Pagination stops on a repeated page token or after `maxPages` (default 50) with a `GbpPaginationError`, rather than looping.
24
+ - Typed errors: `GbpAuthError` (`reauthorizationRequired` on `invalid_grant`), `GbpApiError`, `GbpTimeoutError`, `GbpPaginationError`, all extending `GbpError`, none carrying a credential.
25
+ - 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.
26
+ - The response is validated: a malformed review is skipped, a malformed page throws.
27
+
28
+ - 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.
29
+
30
+ 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).
31
+
3
32
  All notable changes to `@domandigital/gbp`.
4
33
 
5
34
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
package/README.md CHANGED
@@ -38,10 +38,57 @@ 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.
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.
45
92
 
46
93
  ## Why Business Profile API, not Places API
47
94
 
@@ -59,7 +106,7 @@ pnpm add @domandigital/gbp
59
106
 
60
107
  Public on npm, Apache-2.0, published with provenance from a tagged release.
61
108
  Zero runtime dependencies. ESM and CJS builds ship together, each with its own
62
- types. Requires Node 20 or newer.
109
+ types. Requires Node 22.12 or newer.
63
110
 
64
111
  Upgrading a repo that still pins `github:Doman-Digital/dd-gbp#vX.Y.Z`? Swap it
65
112
  for a semver range.
@@ -122,8 +169,8 @@ Paginates through every review (GBP caps each page at 50) and returns:
122
169
 
123
170
  ```ts
124
171
  interface BusinessReviewsResult {
125
- averageRating: number | null;
126
- totalReviewCount: number;
172
+ averageRating: number | null; // Google's, for the whole profile; null if not reported
173
+ totalReviewCount: number | null; // Google's, for the whole profile; null if not reported
127
174
  reviews: BusinessReview[];
128
175
  }
129
176
 
@@ -134,10 +181,21 @@ interface BusinessReview {
134
181
  rating: number; // 1-5
135
182
  comment: string;
136
183
  createdAt: string; // ISO
137
- reply?: { text: string; updatedAt: string }; // no author field -- see below
184
+ reply?: { // no author field -- see below
185
+ text: string;
186
+ updatedAt: string;
187
+ state?: "PENDING" | "REJECTED" | "APPROVED"; // Google's moderation state
188
+ policyViolation?: string; // why, when REJECTED
189
+ };
190
+ media?: { thumbnailUrl: string; label?: string; videoUrl?: string }[]; // photos/videos the reviewer attached
191
+ replyUrl?: string; // Google's URL for replying, for an owner-facing surface
138
192
  }
139
193
  ```
140
194
 
195
+ `totalReviewCount` and `averageRating` are never computed from the returned
196
+ list. Before 0.4.0, a missing `totalReviewCount` fell back to the length of
197
+ the filtered, limited list, which would have published a wrong count.
198
+
141
199
  Options:
142
200
 
143
201
  - `limit?: number` -- stop paginating once this many *usable* reviews are
@@ -155,12 +213,47 @@ Options:
155
213
  own ordering (`updateTime desc`, most recent first). Pick `"api"` for
156
214
  anything that should read as chronological, e.g. an activity feed; the
157
215
  default suits a testimonial grid where a fixed order would look stale.
216
+ - `includeUnapprovedReplies?: boolean` -- default `false`: an owner reply
217
+ Google has marked `PENDING` or `REJECTED` is left off, so a public page never
218
+ shows a reply Google does not. Set `true` for an owner-facing surface, where
219
+ `reply.state` and `reply.policyViolation` say what happened.
220
+ - `maxPages?: number` -- stop after this many pages (default 50, i.e. 2,500
221
+ reviews) rather than loop.
222
+ - `request?: { timeoutMs?, maxAttempts?, baseDelayMs?, maxDelayMs?, maxRetryAfterMs?, signal? }`
223
+ -- deadline and retry policy for every request the call makes. Defaults: 8 s
224
+ per attempt, 3 attempts, full-jitter backoff from 250 ms capped at 5 s, and a
225
+ `Retry-After` of up to 30 s honoured.
158
226
 
159
227
  Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
160
228
  internally and returns an empty result, so UI can render unconditionally.
161
- Does throw on a real API failure (non-OK response), so a calling route should
162
- catch and degrade explicitly if it wants zero-downtime behavior on a Google
163
- outage.
229
+ Does throw on a real failure, so a calling route should catch and degrade
230
+ explicitly if it wants zero-downtime behaviour on a Google outage.
231
+
232
+ ### Failures, and what each one means
233
+
234
+ Every request has a deadline. A 429 or a transient 500/502/503/504 is retried
235
+ a few times with backoff; Google documents the 429 for quota
236
+ (developers.google.com/my-business/content/limits). Nothing else is retried.
237
+ A 401 means the cached access token is no longer good: it is dropped,
238
+ refreshed once, and the page is asked for again; a second 401 throws.
239
+
240
+ All errors extend `GbpError`, and none carries a credential:
241
+
242
+ | Error | When | What to do |
243
+ |---|---|---|
244
+ | `GbpAuthError` with `reauthorizationRequired: true` (`code: "invalid_grant"`) | Google refused the refresh token | Reauthorise the account and replace `GBP_REFRESH_TOKEN`. Retrying does nothing. |
245
+ | `GbpAuthError` (`code: "invalid_client"`) | The client id or secret is wrong | Fix the environment. |
246
+ | `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. |
247
+ | `GbpTimeoutError` | An attempt passed its deadline | As above. |
248
+ | `GbpPaginationError` | Google repeated a page token, or `maxPages` ran out | Report it: something upstream is wrong. |
249
+
250
+ Refresh tokens stop working when the user revokes access, when a token goes
251
+ unused for six months, when the account passes 100 live refresh tokens for
252
+ the client (the oldest is dropped without warning), and **after 7 days if the
253
+ OAuth app is still in "Testing" status**: publish the app, or expect weekly
254
+ reauthorisation (developers.google.com/identity/protocols/oauth2). Google can
255
+ also delete an OAuth client that goes unused, recoverable for 30 days
256
+ (developers.google.com/identity/protocols/oauth2/web-server).
164
257
 
165
258
  ### Why a reply has no author field
166
259
 
@@ -180,5 +273,8 @@ True once all five env vars above are set.
180
273
 
181
274
  Lower-level OAuth primitives, exported in case a consumer needs the raw
182
275
  access token for another Business Profile endpoint this package doesn't wrap
183
- (e.g. business hours, Q&A). The token is cached in-process and refreshed a
184
- minute before expiry.
276
+ (e.g. business hours). The token is cached in-process and refreshed a minute
277
+ before expiry; concurrent callers share one refresh.
278
+ `getGoogleOAuthAccessToken({ forceRefresh: true })` refreshes now, and
279
+ `invalidateAccessToken(token)` drops a token an API has rejected (only if it is
280
+ still the cached one).