@domandigital/gbp 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,128 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@domandigital/gbp`.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
+ this project uses [semantic versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Releases before 0.3.1 were consumed as git dependencies pinned to a tag. This
9
+ file was written retroactively from that tag history, so entries below 0.3.1
10
+ describe what the diffs actually changed rather than what was recorded at the
11
+ time.
12
+
13
+ ## [Unreleased]
14
+
15
+ ## [0.3.2] - 2026-08-16
16
+
17
+ ### Added
18
+
19
+ - `GetBusinessReviewsOptions.order`, `"shuffle" | "api"`. Default is
20
+ `"shuffle"`, unchanged from prior behaviour. `"api"` preserves the Business
21
+ Profile API's own `updateTime desc` ordering instead.
22
+
23
+ ### Fixed
24
+
25
+ - **`limit` could silently under-return.** Pagination stopped once `raw.length`
26
+ reached `limit`, but the comment/star-rating filter that determines which
27
+ reviews are actually usable ran after the loop. A page that was mostly
28
+ star-only ratings with no comment -- which the API allows -- could satisfy
29
+ the raw count while producing far fewer, or zero, usable reviews, with no
30
+ error and no further pages fetched. The stopping condition now counts
31
+ usable reviews, so `limit` means "give me N reviews you can show."
32
+
33
+ ### Changed
34
+
35
+ - `limit`'s doc comment now says what it actually counts (usable reviews, not
36
+ raw API results), matching the fix above.
37
+
38
+ ## [0.3.1] - 2026-08-16
39
+
40
+ ### Added
41
+
42
+ - `./package.json` is now exposed in the `exports` map. Tooling that reads a
43
+ dependency's manifest previously hit `ERR_PACKAGE_PATH_NOT_EXPORTED`.
44
+ - `@types/node` is declared as a devDependency, and `tsconfig.json` sets
45
+ `"types": ["node"]`. This package uses `process.env`, `fetch`,
46
+ `URLSearchParams`, `RequestInit` and `Response`, all of which need those
47
+ types, and none of which had a declared source. `pnpm typecheck` passed only
48
+ because TypeScript walks up past the repo root and can find an `@types/node`
49
+ installed elsewhere on the machine. No consumer was affected, since published
50
+ `.d.ts` files carry their own references, but the check was not real.
51
+ - CI on every push and pull request: typecheck, test and build across Node 20,
52
+ 22 and 24. Previously the only workflow was the tag-triggered release.
53
+ - README section covering `scripts/oauth-listener.mjs`, the one-time local flow
54
+ that mints a client's `GBP_REFRESH_TOKEN` and writes it straight to Doppler.
55
+ It shipped in 0.3.0 with no documentation at all.
56
+
57
+ ### Changed
58
+
59
+ - `engines.node` raised from `>=18` to `>=20`. Node 18 reached end of life in
60
+ April 2025, and the previous range was never exercised by anything.
61
+ - README rewritten for npm distribution. The install section still described the
62
+ package as unpublished and pointed at the `#v0.1.0` git tag.
63
+
64
+ ## [0.3.0] - 2026-08-16
65
+
66
+ ### Added
67
+
68
+ - `scripts/oauth-listener.mjs`: a one-time local OAuth flow that prints a
69
+ consent URL, catches Google's redirect on a local listener, exchanges the
70
+ code, looks up the account and location IDs, and writes all three values to
71
+ Doppler. The token never passes through stdout, so it can't land in a
72
+ captured log.
73
+
74
+ This script is not part of the published package. It isn't in `files`, so it's
75
+ absent from the npm tarball: it shells out to the `doppler` binary, which has
76
+ no place in a public library's runtime contract. Clone the repo to use it.
77
+
78
+ ### Fixed
79
+
80
+ - The pagination test asserted a fixed order against a result the library
81
+ deliberately shuffles. With two reviews a shuffle returns the input order
82
+ about half the time, so the test failed roughly 50% of runs and survived two
83
+ releases because nothing ran the suite automatically. It now compares the set
84
+ of ids.
85
+
86
+ ### Changed
87
+
88
+ - Licensed Apache-2.0, with publish metadata and `publishConfig` for public
89
+ access and provenance. First version published to the npm registry.
90
+
91
+ ## [0.2.0] - 2026-07-31
92
+
93
+ ### Changed
94
+
95
+ - **`getBusinessReviews` now returns reviews in random order.** Results are
96
+ shuffled before being returned, replacing the API's own
97
+ `orderBy=updateTime desc` ordering. There is no way to opt out.
98
+
99
+ Two consequences worth knowing before you upgrade from 0.1.0: output is not
100
+ stable across builds, so a statically generated testimonial section reorders
101
+ on every rebuild; and because the shuffle runs after `limit` is applied, you
102
+ get the most recent N reviews in random order rather than a random N.
103
+
104
+ (The 0.2.0 tag message also credits min-star filtering. That's inaccurate:
105
+ `filterMinStars` shipped in 0.1.0.)
106
+
107
+ ## [0.1.0] - 2026-07-30
108
+
109
+ ### Added
110
+
111
+ - Initial release, extracted from MMM Beauty's `packages/google-apis`. Zero
112
+ runtime dependencies.
113
+ - `getGoogleOAuthAccessToken` and `hasGoogleOAuthCredentials`: user
114
+ refresh-token OAuth, with the access token cached in process and refreshed a
115
+ minute before expiry. Service accounts are rejected by the Business Profile
116
+ API entirely, so this is the only auth route.
117
+ - `getBusinessReviews` and `isBusinessProfileConfigured`: a paginated reviews
118
+ fetch (the API caps each page at 50) that decodes Google's star-rating enums,
119
+ includes the business's reply to each review, and supports `limit`,
120
+ `filterMinStars` and a Next.js `next` cache hint. Returns empty results rather
121
+ than throwing when unconfigured, so UI can render unconditionally.
122
+
123
+ [Unreleased]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.2...HEAD
124
+ [0.3.2]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.1...v0.3.2
125
+ [0.3.1]: https://github.com/Doman-Digital/dd-gbp/compare/v0.3.0...v0.3.1
126
+ [0.3.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.2.0...v0.3.0
127
+ [0.2.0]: https://github.com/Doman-Digital/dd-gbp/compare/v0.1.0...v0.2.0
128
+ [0.1.0]: https://github.com/Doman-Digital/dd-gbp/releases/tag/v0.1.0
package/README.md CHANGED
@@ -53,19 +53,16 @@ with the reply attached, for free.
53
53
 
54
54
  ## Install
55
55
 
56
- Not published to npm. Consumed as a git dependency pinned to a tag:
57
-
58
- ```json
59
- {
60
- "dependencies": {
61
- "@domandigital/gbp": "github:Doman-Digital/dd-gbp#v0.1.0"
62
- }
63
- }
56
+ ```bash
57
+ pnpm add @domandigital/gbp
64
58
  ```
65
59
 
66
- `dist/` is committed to this repo (no CI build step runs on a git-dependency
67
- install), so no build step is required in the consuming project beyond a
68
- normal `pnpm install`.
60
+ Public on npm, Apache-2.0, published with provenance from a tagged release.
61
+ Zero runtime dependencies. ESM and CJS builds ship together, each with its own
62
+ types. Requires Node 20 or newer.
63
+
64
+ Upgrading a repo that still pins `github:Doman-Digital/dd-gbp#vX.Y.Z`? Swap it
65
+ for a semver range.
69
66
 
70
67
  ## Env
71
68
 
@@ -81,6 +78,42 @@ Which actual Google Cloud OAuth client + refresh token you point at (the
81
78
  agency's shared credential, or a business's own dedicated one) is entirely a
82
79
  deployment-env decision, not a code-level one -- each app configures its own.
83
80
 
81
+ ## Minting a client's refresh token
82
+
83
+ `GBP_REFRESH_TOKEN` can only be obtained by a human completing Google's consent
84
+ flow as the business owner. `scripts/oauth-listener.mjs` in this repo automates
85
+ everything around that: it prints a consent URL, catches Google's redirect on a
86
+ local listener, exchanges the code, looks up the account and location IDs, and
87
+ writes all three values straight to Doppler.
88
+
89
+ The token and both IDs travel via argv into `doppler secrets set` and are never
90
+ printed to stdout or returned from the process, so a captured terminal log can't
91
+ leak them.
92
+
93
+ This script is repo-only. It isn't in the package's `files`, so it's absent from
94
+ the npm tarball, deliberately: it shells out to the `doppler` binary, which has
95
+ no business being a runtime expectation of a public library. Clone the repo to
96
+ use it.
97
+
98
+ Run it from inside the **client's** repo, so `doppler secrets set` targets that
99
+ client's own project and config via their local `.doppler.yaml` scoping:
100
+
101
+ ```bash
102
+ GBP_CLIENT_ID=$(doppler secrets get GBP_CLIENT_ID --plain) GBP_CLIENT_SECRET=$(doppler secrets get GBP_CLIENT_SECRET --plain) node ../dd-gbp/scripts/oauth-listener.mjs
103
+ ```
104
+
105
+ Two things that will bite you once each:
106
+
107
+ - `http://127.0.0.1:3333/oauth2callback` has to be registered as an authorised
108
+ redirect URI on the OAuth client in the GCP console. If it isn't, Google
109
+ returns `redirect_uri_mismatch`.
110
+ - If the account has already granted this app access, Google can decline to
111
+ issue a new refresh token even with `prompt=consent`. Revoke it at
112
+ https://myaccount.google.com/permissions and run the script again.
113
+
114
+ If the account has more than one location, the script uses the first and prints
115
+ every location it saw, so you can check it picked the right one.
116
+
84
117
  ## API
85
118
 
86
119
  ### `getBusinessReviews(options?): Promise<BusinessReviewsResult>`
@@ -107,8 +140,9 @@ interface BusinessReview {
107
140
 
108
141
  Options:
109
142
 
110
- - `limit?: number` -- stop paginating once this many reviews are collected.
111
- Omit to fetch all of them.
143
+ - `limit?: number` -- stop paginating once this many *usable* reviews are
144
+ collected (a raw result needs both a comment and a star rating to count, and
145
+ `filterMinStars` narrows it further). Omit to fetch all of them.
112
146
  - `filterMinStars?: number` -- drop reviews below this rating. Use for a
113
147
  public testimonial feed; omit for an owner-facing surface where the point
114
148
  is to see everything. `totalReviewCount` always reflects the location's
@@ -116,6 +150,11 @@ Options:
116
150
  - `next?: { revalidate?: number; tags?: string[] }` -- passed straight through
117
151
  to `fetch`'s Next.js cache-hint augmentation. A no-op outside Next.js.
118
152
  Default: revalidate hourly.
153
+ - `order?: "shuffle" | "api"` -- `"shuffle"` (default) randomizes the returned
154
+ order, applied after `limit`. `"api"` preserves the Business Profile API's
155
+ own ordering (`updateTime desc`, most recent first). Pick `"api"` for
156
+ anything that should read as chronological, e.g. an activity feed; the
157
+ default suits a testimonial grid where a fixed order would look stale.
119
158
 
120
159
  Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
121
160
  internally and returns an empty result, so UI can render unconditionally.
package/dist/index.cjs CHANGED
@@ -79,6 +79,11 @@ function shuffleArray(arr) {
79
79
  }
80
80
  return copy;
81
81
  }
82
+ function isUsableReview(r, filterMinStars) {
83
+ if (!r.comment || !r.starRating) return false;
84
+ if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
85
+ return true;
86
+ }
82
87
  var EMPTY = {
83
88
  averageRating: null,
84
89
  totalReviewCount: 0,
@@ -102,7 +107,7 @@ function toBusinessReview(r) {
102
107
  return review;
103
108
  }
104
109
  async function getBusinessReviews(options = {}) {
105
- const { limit, filterMinStars, next } = options;
110
+ const { limit, filterMinStars, next, order = "shuffle" } = options;
106
111
  if (!isBusinessProfileConfigured()) return EMPTY;
107
112
  const token = await getGoogleOAuthAccessToken();
108
113
  if (!token) return EMPTY;
@@ -127,12 +132,12 @@ async function getBusinessReviews(options = {}) {
127
132
  averageRating = data.averageRating ?? averageRating;
128
133
  totalReviewCount = data.totalReviewCount ?? totalReviewCount;
129
134
  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);
135
+ } while (pageToken && (limit === void 0 || raw.filter((r) => isUsableReview(r, filterMinStars)).length < limit));
136
+ const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map(toBusinessReview).slice(0, limit);
132
137
  return {
133
138
  averageRating: averageRating ?? null,
134
139
  totalReviewCount: totalReviewCount ?? reviews.length,
135
- reviews: shuffleArray(reviews)
140
+ reviews: order === "api" ? reviews : shuffleArray(reviews)
136
141
  };
137
142
  }
138
143
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.d.cts CHANGED
@@ -64,8 +64,11 @@ interface BusinessReviewsResult {
64
64
  }
65
65
  /** True when account + location + OAuth credentials are all configured. */
66
66
  declare function isBusinessProfileConfigured(): boolean;
67
+ type ReviewOrder = "shuffle" | "api";
67
68
  interface GetBusinessReviewsOptions {
68
- /** Max reviews to return (default: all of them, paginated). */
69
+ /** Max reviews to return (default: all of them, paginated). Counts usable
70
+ * reviews only -- see `filterMinStars` -- so this is "give me N reviews you
71
+ * can show", not "give me N raw API results". */
69
72
  limit?: number;
70
73
  /**
71
74
  * When set, only reviews at or above this star rating are returned -- for
@@ -80,6 +83,12 @@ interface GetBusinessReviewsOptions {
80
83
  revalidate?: number;
81
84
  tags?: string[];
82
85
  };
86
+ /**
87
+ * `"shuffle"` (default): randomizes the returned order, applied after
88
+ * `limit`. `"api"`: preserves the Business Profile API's own ordering
89
+ * (`updateTime desc`, most recent first).
90
+ */
91
+ order?: ReviewOrder;
83
92
  }
84
93
  /**
85
94
  * Fetch reviews for the configured location, paginating through all pages
package/dist/index.d.ts CHANGED
@@ -64,8 +64,11 @@ interface BusinessReviewsResult {
64
64
  }
65
65
  /** True when account + location + OAuth credentials are all configured. */
66
66
  declare function isBusinessProfileConfigured(): boolean;
67
+ type ReviewOrder = "shuffle" | "api";
67
68
  interface GetBusinessReviewsOptions {
68
- /** Max reviews to return (default: all of them, paginated). */
69
+ /** Max reviews to return (default: all of them, paginated). Counts usable
70
+ * reviews only -- see `filterMinStars` -- so this is "give me N reviews you
71
+ * can show", not "give me N raw API results". */
69
72
  limit?: number;
70
73
  /**
71
74
  * When set, only reviews at or above this star rating are returned -- for
@@ -80,6 +83,12 @@ interface GetBusinessReviewsOptions {
80
83
  revalidate?: number;
81
84
  tags?: string[];
82
85
  };
86
+ /**
87
+ * `"shuffle"` (default): randomizes the returned order, applied after
88
+ * `limit`. `"api"`: preserves the Business Profile API's own ordering
89
+ * (`updateTime desc`, most recent first).
90
+ */
91
+ order?: ReviewOrder;
83
92
  }
84
93
  /**
85
94
  * Fetch reviews for the configured location, paginating through all pages
package/dist/index.js CHANGED
@@ -50,6 +50,11 @@ function shuffleArray(arr) {
50
50
  }
51
51
  return copy;
52
52
  }
53
+ function isUsableReview(r, filterMinStars) {
54
+ if (!r.comment || !r.starRating) return false;
55
+ if (filterMinStars !== void 0 && STAR_VALUES[r.starRating] < filterMinStars) return false;
56
+ return true;
57
+ }
53
58
  var EMPTY = {
54
59
  averageRating: null,
55
60
  totalReviewCount: 0,
@@ -73,7 +78,7 @@ function toBusinessReview(r) {
73
78
  return review;
74
79
  }
75
80
  async function getBusinessReviews(options = {}) {
76
- const { limit, filterMinStars, next } = options;
81
+ const { limit, filterMinStars, next, order = "shuffle" } = options;
77
82
  if (!isBusinessProfileConfigured()) return EMPTY;
78
83
  const token = await getGoogleOAuthAccessToken();
79
84
  if (!token) return EMPTY;
@@ -98,12 +103,12 @@ async function getBusinessReviews(options = {}) {
98
103
  averageRating = data.averageRating ?? averageRating;
99
104
  totalReviewCount = data.totalReviewCount ?? totalReviewCount;
100
105
  pageToken = data.nextPageToken;
101
- } while (pageToken && (limit === void 0 || raw.length < limit));
102
- const reviews = raw.filter((r) => r.comment && r.starRating).filter((r) => filterMinStars === void 0 || STAR_VALUES[r.starRating] >= filterMinStars).map(toBusinessReview).slice(0, limit);
106
+ } while (pageToken && (limit === void 0 || raw.filter((r) => isUsableReview(r, filterMinStars)).length < limit));
107
+ const reviews = raw.filter((r) => isUsableReview(r, filterMinStars)).map(toBusinessReview).slice(0, limit);
103
108
  return {
104
109
  averageRating: averageRating ?? null,
105
110
  totalReviewCount: totalReviewCount ?? reviews.length,
106
- reviews: shuffleArray(reviews)
111
+ reviews: order === "api" ? reviews : shuffleArray(reviews)
107
112
  };
108
113
  }
109
114
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/gbp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -16,9 +16,11 @@
16
16
  "types": "./dist/index.d.cts",
17
17
  "default": "./dist/index.cjs"
18
18
  }
19
- }
19
+ },
20
+ "./package.json": "./package.json"
20
21
  },
21
22
  "files": [
23
+ "CHANGELOG.md",
22
24
  "LICENSE",
23
25
  "README.md",
24
26
  "dist"
@@ -30,12 +32,13 @@
30
32
  "prepublishOnly": "pnpm run build"
31
33
  },
32
34
  "devDependencies": {
35
+ "@types/node": "^20",
33
36
  "tsup": "^8.3.5",
34
37
  "typescript": "^5.6.3",
35
38
  "vitest": "^2.1.9"
36
39
  },
37
40
  "engines": {
38
- "node": ">=18"
41
+ "node": ">=20"
39
42
  },
40
43
  "packageManager": "pnpm@9.15.9",
41
44
  "repository": {