1import * as reviews from "@distilled.cloud/gcp/merchantapi_reviews_v1beta";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 accountIdOf,
12 configuredAccountIds,
13 dataSourceNameOf,
14 DEFAULT_COUNTRY,
15 DEFAULT_LANGUAGE,
16 DEFAULT_REVIEW_TIME,
17 findOwnedProductReview,
18 getProductReview,
19 hasAlchemyOwnership,
20 jsonEqual,
21 lastSegment,
22 listProductReviewsAt,
23 normalizeCustomAttributes,
24 ownedByAlchemy,
25 parseOwnership,
26 parentOf,
27 productReviewNameOf,
28 stampCustomAttributes,
29 stripCustomAttributes,
30 toResourceId,
31 type ReviewCustomAttribute,
32 type ReviewLinkProps,
33} from "./internal.ts";
34
35export type ProductReviewAttributesProps = {
36 /** GTINs associated with the product. */
37 gtins?: string[];
38 /** Brand names associated with the product. */
39 brands?: string[];
40 /** Minimum possible rating (worst value, not "no rating"). */
41 minRating?: string;
42 /** Name of the reviews aggregator, if any. */
43 aggregatorName?: string;
44 /** Descriptive product names. */
45 productNames?: string[];
46 /** Maximum possible rating (must be greater than `minRating`). */
47 maxRating?: string;
48 /** Advantages from the reviewer. */
49 pros?: string[];
50 /** How the review was collected. */
51 collectionMethod?:
52 | reviews.ProductReviewAttributesCollectionMethodEnum
53 | (string & {});
54 /** Whether the review is marked as spam. */
55 isSpam?: boolean;
56 /** Reviewer's overall rating of the product. */
57 rating?: number;
58 /** SKUs associated with the product. */
59 skus?: string[];
60 /** URIs of reviewer-created product images. */
61 reviewerImageLinks?: string[];
62 /** Publisher favicon URI. */
63 publisherFavicon?: string;
64 /** Name of the publisher of the product reviews. */
65 publisherName?: string;
66 /** Name of the reviewer. */
67 reviewerUsername?: string;
68 /** Publisher-system transaction id. */
69 transactionId?: string;
70 /**
71 * Review body, sent verbatim.
72 */
73 content?: string;
74 /** Whether the reviewer's purchase is verified. */
75 isVerifiedPurchase?: boolean;
76 /**
77 * ISO 3166-1 alpha-2 country of the review.
78 * @default "US"
79 */
80 reviewCountry?: string;
81 /** Disadvantages from the reviewer. */
82 cons?: string[];
83 /** URI of the review landing page. */
84 reviewLink?: ReviewLinkProps;
85 /**
86 * BCP-47 language of the review.
87 * @default "en"
88 */
89 reviewLanguage?: string;
90 /** Subclient identifier of the review source. */
91 subclientName?: string;
92 /** Publisher-system identifier for the review author. */
93 reviewerId?: string;
94 /** Title of the review. */
95 title?: string;
96 /** Product landing-page URIs. */
97 productLinks?: string[];
98 /**
99 * Timestamp when the review was written (RFC3339).
100 * @default "2020-01-01T00:00:00Z"
101 */
102 reviewTime?: string;
103 /** ASINs associated with the product. */
104 asins?: string[];
105 /** When true, the reviewer remains anonymous. */
106 reviewerIsAnonymous?: boolean;
107 /** MPNs associated with the product. */
108 mpns?: string[];
109 /** Whether the review is incentivized. */
110 isIncentivizedReview?: boolean;
111};
112
113export type ProductReviewProps = {
114 /**
115 * Merchant Center account id (the `{account}` segment of
116 * `accounts/{account}`). Immutable — changing it replaces the review.
117 */
118 account: string;
119 /**
120 * Product-reviews data source. Full name
121 * `accounts/{account}/dataSources/{datasource}` or the data source id.
122 */
123 dataSource: string;
124 /**
125 * Permanent publisher-system product review id. If omitted, a unique
126 * id is generated from the stack, stage, and logical id. Immutable —
127 * changing it replaces the review.
128 */
129 productReviewId?: string;
130 /**
131 * Product review attributes. `reviewTime` is required by the API;
132 * omitted values get defaults. When no product identifier is set,
133 * `skus` defaults to the review id.
134 */
135 productReviewAttributes?: ProductReviewAttributesProps;
136 /**
137 * Custom (merchant-provided) attributes. Alchemy ownership keys are
138 * merged in automatically.
139 */
140 customAttributes?: ReviewCustomAttribute[];
141};
142
143export type ProductReview = Resource<
144 "GCP.MerchantApiReviews.ProductReview",
145 ProductReviewProps,
146 {
147 /** Full resource name `accounts/{account}/productReviews/{id}`. */
148 name: string;
149 /** Merchant Center account id. */
150 account: string;
151 /** Publisher-system product review id. */
152 productReviewId: string;
153 /** Primary data source of the review. */
154 dataSource: string | undefined;
155 /** Review attributes with the Alchemy ownership prefix stripped. */
156 productReviewAttributes: ProductReviewAttributesProps | undefined;
157 /** User custom attributes (Alchemy ownership keys stripped). */
158 customAttributes: ReviewCustomAttribute[];
159 /** Server-computed create time. */
160 createTime: string | undefined;
161 /** Server-computed last update time. */
162 lastUpdateTime: string | undefined;
163 },
164 never,
165 Providers
166>;
167
168/**
169 * A Merchant Center product review (Merchant API Reviews).
170 *
171 * Reviews have no labels field — Alchemy stamps ownership into
172 * `customAttributes` (never the review text) so `list` / nuke can find
173 * them. `account` and `productReviewId` are identity.
174 * `insertAccountsProductReviews` is a full-document upsert, so updates
175 * replace the review in place. Creating a review requires a Merchant
176 * Center account enrolled in product ratings and a product-reviews
177 * data source.
178 *
179 * ### Creating a Product Review
180 * **Example:** Generated id
181 * ```typescript
182 * const review = yield* GCP.MerchantApiReviews.ProductReview("Tote", {
183 * account: "123456",
184 * dataSource: "accounts/123456/dataSources/789",
185 * productReviewAttributes: {
186 * title: "Sturdy tote",
187 * content: "Holds a laptop and lunch",
188 * rating: 5,
189 * gtins: ["9780007350896"],
190 * },
191 * });
192 * ```
193 *
194 * **Example:** Explicit id
195 * ```typescript
196 * const review = yield* GCP.MerchantApiReviews.ProductReview("Tote", {
197 * account: "123456",
198 * dataSource: "accounts/123456/dataSources/789",
199 * productReviewId: "tote-2020",
200 * productReviewAttributes: {
201 * title: "Sturdy tote",
202 * content: "Holds a laptop and lunch",
203 * rating: 5,
204 * skus: ["tote-navy"],
205 * reviewLanguage: "en",
206 * reviewCountry: "US",
207 * },
208 * });
209 * ```
210 *
211 * ### Updating a Product Review
212 * **Example:** Change the title and rating
213 * ```typescript
214 * const review = yield* GCP.MerchantApiReviews.ProductReview("Tote", {
215 * account: "123456",
216 * dataSource: "accounts/123456/dataSources/789",
217 * productReviewAttributes: {
218 * title: "Roomy tote",
219 * content: "Holds a laptop, lunch, and jacket",
220 * rating: 4,
221 * skus: ["tote-navy"],
222 * },
223 * });
224 * ```
225 *
226 * @resource
227 * @category MerchantApiReviews
228 */
229export const ProductReview = Resource<ProductReview>(
230 "GCP.MerchantApiReviews.ProductReview",
231);
232
233export class ProductReviewNotResolved extends Data.TaggedError(
234 "GCP.MerchantApiReviews.ProductReviewNotResolved",
235)<{
236 name: string;
237}> {}
238
239const hasProductIdentifier = (
240 attrs: ProductReviewAttributesProps | undefined,
241) =>
242 (attrs?.gtins?.length ?? 0) > 0 ||
243 (attrs?.skus?.length ?? 0) > 0 ||
244 (attrs?.mpns?.length ?? 0) > 0 ||
245 (attrs?.asins?.length ?? 0) > 0;
246
247const attributesOf = (
248 attrs: reviews.ProductReviewAttributes | undefined,
249): ProductReviewAttributesProps | undefined => {
250 if (attrs === undefined) return undefined;
251 return {
252 gtins: attrs.gtins,
253 brands: attrs.brands,
254 minRating: attrs.minRating,
255 aggregatorName: attrs.aggregatorName,
256 productNames: attrs.productNames,
257 maxRating: attrs.maxRating,
258 pros: attrs.pros,
259 collectionMethod: attrs.collectionMethod,
260 isSpam: attrs.isSpam,
261 rating: attrs.rating,
262 skus: attrs.skus,
263 reviewerImageLinks: attrs.reviewerImageLinks,
264 publisherFavicon: attrs.publisherFavicon,
265 publisherName: attrs.publisherName,
266 reviewerUsername: attrs.reviewerUsername,
267 transactionId: attrs.transactionId,
268 content: parseOwnership(attrs.content).text,
269 isVerifiedPurchase: attrs.isVerifiedPurchase,
270 reviewCountry: attrs.reviewCountry,
271 cons: attrs.cons,
272 reviewLink: attrs.reviewLink,
273 reviewLanguage: attrs.reviewLanguage,
274 subclientName: attrs.subclientName,
275 reviewerId: attrs.reviewerId,
276 title: attrs.title,
277 productLinks: attrs.productLinks,
278 reviewTime: attrs.reviewTime,
279 asins: attrs.asins,
280 reviewerIsAnonymous: attrs.reviewerIsAnonymous,
281 mpns: attrs.mpns,
282 isIncentivizedReview: attrs.isIncentivizedReview,
283 };
284};
285
286const toAttrs = (
287 review: reviews.ProductReview,
288 account: string,
289 dataSource?: string,
290) => {
291 const productReviewId =
292 review.productReviewId ?? (review.name ? lastSegment(review.name) : "");
293 return {
294 name: review.name ?? productReviewNameOf(account, productReviewId),
295 account: accountIdOf(account),
296 productReviewId,
297 dataSource: review.dataSource ?? dataSource,
298 productReviewAttributes: attributesOf(review.productReviewAttributes),
299 customAttributes: stripCustomAttributes(review.customAttributes),
300 createTime: review.productReviewStatus?.createTime,
301 lastUpdateTime: review.productReviewStatus?.lastUpdateTime,
302 };
303};
304
305const desiredAttributes = (input: {
306 productReviewId: string;
307 ownership: Record<string, string>;
308 attrs: ProductReviewAttributesProps | undefined;
309}): reviews.ProductReviewAttributes => ({
310 gtins: input.attrs?.gtins,
311 brands: input.attrs?.brands,
312 minRating: input.attrs?.minRating,
313 aggregatorName: input.attrs?.aggregatorName,
314 productNames: input.attrs?.productNames,
315 maxRating: input.attrs?.maxRating,
316 pros: input.attrs?.pros,
317 collectionMethod: input.attrs?.collectionMethod,
318 isSpam: input.attrs?.isSpam,
319 rating: input.attrs?.rating,
320 skus: hasProductIdentifier(input.attrs)
321 ? input.attrs?.skus
322 : [input.productReviewId],
323 reviewerImageLinks: input.attrs?.reviewerImageLinks,
324 publisherFavicon: input.attrs?.publisherFavicon,
325 publisherName: input.attrs?.publisherName,
326 reviewerUsername: input.attrs?.reviewerUsername,
327 transactionId: input.attrs?.transactionId,
328 content: input.attrs?.content,
329 isVerifiedPurchase: input.attrs?.isVerifiedPurchase,
330 reviewCountry: input.attrs?.reviewCountry ?? DEFAULT_COUNTRY,
331 cons: input.attrs?.cons,
332 reviewLink: input.attrs?.reviewLink,
333 reviewLanguage: input.attrs?.reviewLanguage ?? DEFAULT_LANGUAGE,
334 subclientName: input.attrs?.subclientName,
335 reviewerId: input.attrs?.reviewerId,
336 title: input.attrs?.title,
337 productLinks: input.attrs?.productLinks,
338 reviewTime: input.attrs?.reviewTime ?? DEFAULT_REVIEW_TIME,
339 asins: input.attrs?.asins,
340 reviewerIsAnonymous: input.attrs?.reviewerIsAnonymous,
341 mpns: input.attrs?.mpns,
342 isIncentivizedReview: input.attrs?.isIncentivizedReview,
343});
344
345const desiredBody = (input: {
346 productReviewId: string;
347 attributes: reviews.ProductReviewAttributes;
348 customAttributes: reviews.CustomAttribute[];
349}): reviews.ProductReview => ({
350 productReviewId: input.productReviewId,
351 productReviewAttributes: input.attributes,
352 customAttributes: input.customAttributes,
353});
354
355const reviewNeedsSync = (
356 current: reviews.ProductReview,
357 desired: reviews.ProductReview,
358) =>
359 !jsonEqual(current.productReviewId, desired.productReviewId) ||
360 !jsonEqual(
361 current.productReviewAttributes,
362 desired.productReviewAttributes,
363 ) ||
364 !jsonEqual(
365 normalizeCustomAttributes(current.customAttributes),
366 normalizeCustomAttributes(desired.customAttributes),
367 );
368
369export const ProductReviewProvider = () =>
370 Provider.succeed(ProductReview, {
371 stables: ["name", "account", "productReviewId"],
372
373 diff: Effect.fn(function* ({ news, olds, output }) {
374 if (!isResolved(news)) return undefined;
375 const previousAccount = olds?.account ?? output?.account;
376 if (
377 previousAccount !== undefined &&
378 accountIdOf(news.account) !== accountIdOf(previousAccount)
379 ) {
380 return { action: "replace" as const, deleteFirst: false };
381 }
382 const previousId = olds?.productReviewId ?? output?.productReviewId;
383 if (
384 previousId !== undefined &&
385 news.productReviewId !== undefined &&
386 news.productReviewId !== previousId
387 ) {
388 return { action: "replace" as const, deleteFirst: true };
389 }
390 return undefined;
391 }),
392
393 read: Effect.fn(function* ({ id, olds, output }) {
394 const account = olds?.account ?? output?.account ?? "";
395 const productReviewId = yield* toResourceId(
396 id,
397 olds?.productReviewId,
398 output?.productReviewId,
399 );
400 const name =
401 output?.name ?? productReviewNameOf(account, productReviewId);
402 let existing = yield* getProductReview(name);
403 if (existing === undefined && account) {
404 existing = yield* findOwnedProductReview(id, account, productReviewId);
405 }
406 if (existing === undefined) return undefined;
407 const attrs = toAttrs(
408 existing,
409 account,
410 olds?.dataSource ?? output?.dataSource,
411 );
412 return (yield* ownedByAlchemy(id, {
413 customAttributes: existing.customAttributes,
414 content: existing.productReviewAttributes?.content,
415 }))
416 ? attrs
417 : Unowned(attrs);
418 }),
419
420 list: () =>
421 Effect.gen(function* () {
422 const accounts = yield* configuredAccountIds();
423 const pages = yield* Effect.forEach(
424 accounts,
425 (account) => listProductReviewsAt(account),
426 { concurrency: 4 },
427 );
428 const attrs = [];
429 for (let i = 0; i < pages.length; i++) {
430 const account = accounts[i]!;
431 for (const review of pages[i] ?? []) {
432 if (
433 !hasAlchemyOwnership({
434 customAttributes: review.customAttributes,
435 content: review.productReviewAttributes?.content,
436 })
437 ) {
438 continue;
439 }
440 attrs.push(toAttrs(review, account, review.dataSource));
441 }
442 }
443 return attrs;
444 }),
445
446 reconcile: Effect.fn(function* ({ id, news, output }) {
447 const account = accountIdOf(news.account);
448 const productReviewId = yield* toResourceId(
449 id,
450 news.productReviewId,
451 output?.productReviewId,
452 );
453 const dataSource = dataSourceNameOf(account, news.dataSource);
454 const name =
455 output?.name ?? productReviewNameOf(account, productReviewId);
456 const ownership = yield* createInternalLabels(id);
457 const attributes = desiredAttributes({
458 productReviewId,
459 ownership,
460 attrs: news.productReviewAttributes,
461 });
462 const customAttributes = stampCustomAttributes(
463 ownership,
464 news.customAttributes,
465 );
466 const desired = desiredBody({
467 productReviewId,
468 attributes,
469 customAttributes,
470 });
471
472 let current = yield* getProductReview(name);
473 if (current === undefined) {
474 current = yield* findOwnedProductReview(id, account, productReviewId);
475 }
476
477 const upsert = () =>
478 reviews
479 .insertAccountsProductReviews({
480 parent: parentOf(account),
481 dataSource,
482 body: desired,
483 })
484 .pipe(Effect.catchTag("Conflict", () => getProductReview(name)));
485
486 if (current === undefined) {
487 current = (yield* upsert()) ?? undefined;
488 } else if (reviewNeedsSync(current, desired)) {
489 current = (yield* upsert()) ?? current;
490 }
491
492 if (current === undefined) {
493 return yield* new ProductReviewNotResolved({ name });
494 }
495
496 return toAttrs(current, account, dataSource);
497 }),
498
499 delete: Effect.fn(function* ({ output }) {
500 if (!output.name) return;
501 yield* reviews
502 .deleteAccountsProductReviews({ name: output.name })
503 .pipe(Effect.catchTag("NotFound", () => Effect.void));
504 }),
505 });