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 findOwnedMerchantReview,
18 getMerchantReview,
19 hasAlchemyOwnership,
20 jsonEqual,
21 lastSegment,
22 listMerchantReviewsAt,
23 merchantReviewNameOf,
24 normalizeCustomAttributes,
25 ownedByAlchemy,
26 parseOwnership,
27 parentOf,
28 stampCustomAttributes,
29 stripCustomAttributes,
30 toResourceId,
31 type ReviewCustomAttribute,
32} from "./internal.ts";
33
34export type MerchantReviewAttributesProps = {
35 /** Reviewer's overall rating of the merchant. */
36 rating?: number;
37 /** Title of the review. */
38 title?: string;
39 /**
40 * Unique, stable identifier for the merchant being reviewed. Defaults
41 * to the Merchant Center account id.
42 */
43 merchantId?: string;
44 /** When true, the reviewer remains anonymous. */
45 isAnonymous?: boolean;
46 /**
47 * Timestamp when the review was written (RFC3339).
48 * @default "2020-01-01T00:00:00Z"
49 */
50 reviewTime?: string;
51 /** URL of the merchant's main website. */
52 merchantLink?: string;
53 /** How the review was collected. */
54 collectionMethod?:
55 | reviews.MerchantReviewAttributesCollectionMethodEnum
56 | (string & {});
57 /** Publisher-system identifier for the review author. */
58 reviewerId?: string;
59 /**
60 * BCP-47 language of the review.
61 * @default "en"
62 */
63 reviewLanguage?: string;
64 /**
65 * ISO 3166-1 alpha-2 country where the reviewer ordered.
66 * @default "US"
67 */
68 reviewCountry?: string;
69 /** Human-readable merchant display name. */
70 merchantDisplayName?: string;
71 /** Landing page that hosts reviews for this merchant. */
72 merchantRatingLink?: string;
73 /** Maximum possible rating (must be greater than `minRating`). */
74 maxRating?: string;
75 /** Minimum possible rating (worst value, not "no rating"). */
76 minRating?: string;
77 /** Display name of the review author. */
78 reviewerUsername?: string;
79 /**
80 * Freeform review text, sent verbatim.
81 */
82 content?: string;
83};
84
85export type MerchantReviewProps = {
86 /**
87 * Merchant Center account id (the `{account}` segment of
88 * `accounts/{account}`). Immutable — changing it replaces the review.
89 */
90 account: string;
91 /**
92 * Merchant-reviews data source. Full name
93 * `accounts/{account}/dataSources/{datasource}` or the data source id.
94 */
95 dataSource: string;
96 /**
97 * User-provided merchant review id. If omitted, a unique id is
98 * generated from the stack, stage, and logical id. Immutable —
99 * changing it replaces the review.
100 */
101 merchantReviewId?: string;
102 /**
103 * Merchant review attributes. `merchantId`, `reviewTime`, and
104 * `content` are required by the API; omitted values get defaults.
105 */
106 merchantReviewAttributes?: MerchantReviewAttributesProps;
107 /**
108 * Custom (merchant-provided) attributes. Alchemy ownership keys are
109 * merged in automatically.
110 */
111 customAttributes?: ReviewCustomAttribute[];
112};
113
114export type MerchantReview = Resource<
115 "GCP.MerchantApiReviews.MerchantReview",
116 MerchantReviewProps,
117 {
118 /** Full resource name `accounts/{account}/merchantReviews/{id}`. */
119 name: string;
120 /** Merchant Center account id. */
121 account: string;
122 /** User-provided merchant review id. */
123 merchantReviewId: string;
124 /** Primary data source of the review. */
125 dataSource: string | undefined;
126 /** Review attributes with the Alchemy ownership prefix stripped. */
127 merchantReviewAttributes: MerchantReviewAttributesProps | undefined;
128 /** User custom attributes (Alchemy ownership keys stripped). */
129 customAttributes: ReviewCustomAttribute[];
130 /** Server-computed create time. */
131 createTime: string | undefined;
132 /** Server-computed last update time. */
133 lastUpdateTime: string | undefined;
134 },
135 never,
136 Providers
137>;
138
139/**
140 * A Merchant Center merchant review (Merchant API Reviews).
141 *
142 * Reviews have no labels field — Alchemy stamps ownership into
143 * `customAttributes` (never the review text) so `list` / nuke can find
144 * them. `account` and `merchantReviewId` are identity.
145 * `insertAccountsMerchantReviews` is a full-document upsert, so updates
146 * replace the review in place. Creating a review requires a Merchant
147 * Center account enrolled in merchant reviews and a merchant-reviews
148 * data source.
149 *
150 * ### Creating a Merchant Review
151 * **Example:** Generated id
152 * ```typescript
153 * const review = yield* GCP.MerchantApiReviews.MerchantReview("Storefront", {
154 * account: "123456",
155 * dataSource: "accounts/123456/dataSources/789",
156 * merchantReviewAttributes: {
157 * title: "Great shop",
158 * content: "Fast shipping",
159 * rating: 5,
160 * },
161 * });
162 * ```
163 *
164 * **Example:** Explicit id
165 * ```typescript
166 * const review = yield* GCP.MerchantApiReviews.MerchantReview("Storefront", {
167 * account: "123456",
168 * dataSource: "accounts/123456/dataSources/789",
169 * merchantReviewId: "storefront-2020",
170 * merchantReviewAttributes: {
171 * title: "Great shop",
172 * content: "Fast shipping",
173 * rating: 5,
174 * reviewLanguage: "en",
175 * reviewCountry: "US",
176 * },
177 * });
178 * ```
179 *
180 * ### Updating a Merchant Review
181 * **Example:** Change the title and rating
182 * ```typescript
183 * const review = yield* GCP.MerchantApiReviews.MerchantReview("Storefront", {
184 * account: "123456",
185 * dataSource: "accounts/123456/dataSources/789",
186 * merchantReviewAttributes: {
187 * title: "Even better",
188 * content: "Fast shipping and packing",
189 * rating: 5,
190 * },
191 * });
192 * ```
193 *
194 * @resource
195 * @category MerchantApiReviews
196 */
197export const MerchantReview = Resource<MerchantReview>(
198 "GCP.MerchantApiReviews.MerchantReview",
199);
200
201export class MerchantReviewNotResolved extends Data.TaggedError(
202 "GCP.MerchantApiReviews.MerchantReviewNotResolved",
203)<{
204 name: string;
205}> {}
206
207const attributesOf = (
208 attrs: reviews.MerchantReviewAttributes | undefined,
209): MerchantReviewAttributesProps | undefined => {
210 if (attrs === undefined) return undefined;
211 return {
212 rating: attrs.rating,
213 title: attrs.title,
214 merchantId: attrs.merchantId,
215 isAnonymous: attrs.isAnonymous,
216 reviewTime: attrs.reviewTime,
217 merchantLink: attrs.merchantLink,
218 collectionMethod: attrs.collectionMethod,
219 reviewerId: attrs.reviewerId,
220 reviewLanguage: attrs.reviewLanguage,
221 reviewCountry: attrs.reviewCountry,
222 merchantDisplayName: attrs.merchantDisplayName,
223 merchantRatingLink: attrs.merchantRatingLink,
224 maxRating: attrs.maxRating,
225 minRating: attrs.minRating,
226 reviewerUsername: attrs.reviewerUsername,
227 content: parseOwnership(attrs.content).text,
228 };
229};
230
231const toAttrs = (
232 review: reviews.MerchantReview,
233 account: string,
234 dataSource?: string,
235) => {
236 const merchantReviewId =
237 review.merchantReviewId ?? (review.name ? lastSegment(review.name) : "");
238 return {
239 name: review.name ?? merchantReviewNameOf(account, merchantReviewId),
240 account: accountIdOf(account),
241 merchantReviewId,
242 dataSource: review.dataSource ?? dataSource,
243 merchantReviewAttributes: attributesOf(review.merchantReviewAttributes),
244 customAttributes: stripCustomAttributes(review.customAttributes),
245 createTime: review.merchantReviewStatus?.createTime,
246 lastUpdateTime: review.merchantReviewStatus?.lastUpdateTime,
247 };
248};
249
250const desiredAttributes = (input: {
251 account: string;
252 ownership: Record<string, string>;
253 attrs: MerchantReviewAttributesProps | undefined;
254}): reviews.MerchantReviewAttributes => ({
255 rating: input.attrs?.rating,
256 title: input.attrs?.title,
257 merchantId: input.attrs?.merchantId ?? input.account,
258 isAnonymous: input.attrs?.isAnonymous,
259 reviewTime: input.attrs?.reviewTime ?? DEFAULT_REVIEW_TIME,
260 merchantLink: input.attrs?.merchantLink,
261 collectionMethod: input.attrs?.collectionMethod,
262 reviewerId: input.attrs?.reviewerId,
263 reviewLanguage: input.attrs?.reviewLanguage ?? DEFAULT_LANGUAGE,
264 reviewCountry: input.attrs?.reviewCountry ?? DEFAULT_COUNTRY,
265 merchantDisplayName: input.attrs?.merchantDisplayName,
266 merchantRatingLink: input.attrs?.merchantRatingLink,
267 maxRating: input.attrs?.maxRating,
268 minRating: input.attrs?.minRating,
269 reviewerUsername: input.attrs?.reviewerUsername,
270 content: input.attrs?.content,
271});
272
273const desiredBody = (input: {
274 merchantReviewId: string;
275 attributes: reviews.MerchantReviewAttributes;
276 customAttributes: reviews.CustomAttribute[];
277}): reviews.MerchantReview => ({
278 merchantReviewId: input.merchantReviewId,
279 merchantReviewAttributes: input.attributes,
280 customAttributes: input.customAttributes,
281});
282
283const reviewNeedsSync = (
284 current: reviews.MerchantReview,
285 desired: reviews.MerchantReview,
286) =>
287 !jsonEqual(current.merchantReviewId, desired.merchantReviewId) ||
288 !jsonEqual(
289 current.merchantReviewAttributes,
290 desired.merchantReviewAttributes,
291 ) ||
292 !jsonEqual(
293 normalizeCustomAttributes(current.customAttributes),
294 normalizeCustomAttributes(desired.customAttributes),
295 );
296
297export const MerchantReviewProvider = () =>
298 Provider.succeed(MerchantReview, {
299 stables: ["name", "account", "merchantReviewId"],
300
301 diff: Effect.fn(function* ({ news, olds, output }) {
302 if (!isResolved(news)) return undefined;
303 const previousAccount = olds?.account ?? output?.account;
304 if (
305 previousAccount !== undefined &&
306 accountIdOf(news.account) !== accountIdOf(previousAccount)
307 ) {
308 return { action: "replace" as const, deleteFirst: false };
309 }
310 const previousId = olds?.merchantReviewId ?? output?.merchantReviewId;
311 if (
312 previousId !== undefined &&
313 news.merchantReviewId !== undefined &&
314 news.merchantReviewId !== previousId
315 ) {
316 return { action: "replace" as const, deleteFirst: true };
317 }
318 return undefined;
319 }),
320
321 read: Effect.fn(function* ({ id, olds, output }) {
322 const account = olds?.account ?? output?.account ?? "";
323 const merchantReviewId = yield* toResourceId(
324 id,
325 olds?.merchantReviewId,
326 output?.merchantReviewId,
327 );
328 const name =
329 output?.name ?? merchantReviewNameOf(account, merchantReviewId);
330 let existing = yield* getMerchantReview(name);
331 if (existing === undefined && account) {
332 existing = yield* findOwnedMerchantReview(
333 id,
334 account,
335 merchantReviewId,
336 );
337 }
338 if (existing === undefined) return undefined;
339 const attrs = toAttrs(
340 existing,
341 account,
342 olds?.dataSource ?? output?.dataSource,
343 );
344 return (yield* ownedByAlchemy(id, {
345 customAttributes: existing.customAttributes,
346 content: existing.merchantReviewAttributes?.content,
347 }))
348 ? attrs
349 : Unowned(attrs);
350 }),
351
352 list: () =>
353 Effect.gen(function* () {
354 const accounts = yield* configuredAccountIds();
355 const pages = yield* Effect.forEach(
356 accounts,
357 (account) => listMerchantReviewsAt(account),
358 { concurrency: 4 },
359 );
360 const attrs = [];
361 for (let i = 0; i < pages.length; i++) {
362 const account = accounts[i]!;
363 for (const review of pages[i] ?? []) {
364 if (
365 !hasAlchemyOwnership({
366 customAttributes: review.customAttributes,
367 content: review.merchantReviewAttributes?.content,
368 })
369 ) {
370 continue;
371 }
372 attrs.push(toAttrs(review, account, review.dataSource));
373 }
374 }
375 return attrs;
376 }),
377
378 reconcile: Effect.fn(function* ({ id, news, output }) {
379 const account = accountIdOf(news.account);
380 const merchantReviewId = yield* toResourceId(
381 id,
382 news.merchantReviewId,
383 output?.merchantReviewId,
384 );
385 const dataSource = dataSourceNameOf(account, news.dataSource);
386 const name =
387 output?.name ?? merchantReviewNameOf(account, merchantReviewId);
388 const ownership = yield* createInternalLabels(id);
389 const attributes = desiredAttributes({
390 account,
391 ownership,
392 attrs: news.merchantReviewAttributes,
393 });
394 const customAttributes = stampCustomAttributes(
395 ownership,
396 news.customAttributes,
397 );
398 const desired = desiredBody({
399 merchantReviewId,
400 attributes,
401 customAttributes,
402 });
403
404 let current = yield* getMerchantReview(name);
405 if (current === undefined) {
406 current = yield* findOwnedMerchantReview(id, account, merchantReviewId);
407 }
408
409 const upsert = () =>
410 reviews
411 .insertAccountsMerchantReviews({
412 parent: parentOf(account),
413 dataSource,
414 body: desired,
415 })
416 .pipe(Effect.catchTag("Conflict", () => getMerchantReview(name)));
417
418 if (current === undefined) {
419 current = (yield* upsert()) ?? undefined;
420 } else if (reviewNeedsSync(current, desired)) {
421 current = (yield* upsert()) ?? current;
422 }
423
424 if (current === undefined) {
425 return yield* new MerchantReviewNotResolved({ name });
426 }
427
428 return toAttrs(current, account, dataSource);
429 }),
430
431 delete: Effect.fn(function* ({ output }) {
432 if (!output.name) return;
433 yield* reviews
434 .deleteAccountsMerchantReviews({ name: output.name })
435 .pipe(Effect.catchTag("NotFound", () => Effect.void));
436 }),
437 });