1import * as reviews from "@distilled.cloud/gcp/merchantapi_reviews_v1beta";
2import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13/** Optional deploy-time setting from the ConfigProvider (env by default). */
14const optionalSetting = (key: string) =>
15 Effect.gen(function* () {
16 const value = yield* Config.option(Config.String(key));
17 return Option.getOrUndefined(value)?.trim() || undefined;
18 });
19
20export const MAX_ID_LENGTH = 64;
21export const MAX_CONTENT_LENGTH = 16_000;
22export const DEFAULT_REVIEW_TIME = "2020-01-01T00:00:00Z";
23export const DEFAULT_LANGUAGE = "en";
24export const DEFAULT_COUNTRY = "US";
25
26export type ReviewCustomAttribute = {
27 name?: string;
28 value?: string;
29 groupValues?: ReviewCustomAttribute[];
30};
31
32export type ReviewLinkProps = {
33 link?: string;
34 type?: reviews.ReviewLinkTypeEnum | (string & {});
35};
36
37const markerOf = (stack: string, stage: string, id: string) =>
38 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
39
40const fitMarker = (labels: Record<string, string>, maxLength: number) => {
41 let stack = labels[alchemyLabelKeys.stack] ?? "x";
42 let stage = labels[alchemyLabelKeys.stage] ?? "x";
43 let id = labels[alchemyLabelKeys.id] ?? "x";
44 let marker = markerOf(stack, stage, id);
45 while (
46 marker.length > maxLength &&
47 (stack.length > 1 || stage.length > 1 || id.length > 1)
48 ) {
49 if (stack.length >= stage.length && stack.length >= id.length) {
50 stack = stack.slice(0, -1);
51 } else if (stage.length >= id.length) {
52 stage = stage.slice(0, -1);
53 } else {
54 id = id.slice(0, -1);
55 }
56 marker = markerOf(stack, stage, id);
57 }
58 return marker.slice(0, maxLength);
59};
60
61export const encodeOwnership = (
62 labels: Record<string, string>,
63 text: string | undefined,
64 maxLength = MAX_CONTENT_LENGTH,
65): string => {
66 const marker = fitMarker(labels, Math.min(800, maxLength));
67 const trimmed = text?.trim();
68 const combined =
69 trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
70 return combined.slice(0, maxLength);
71};
72
73export const parseOwnership = (
74 text: string | undefined,
75): {
76 labels: Record<string, string>;
77 text: string | undefined;
78} => {
79 if (!text?.startsWith("[alchemy ")) {
80 return { labels: {}, text };
81 }
82 const end = text.indexOf("]");
83 if (end < 0) return { labels: {}, text };
84 const labels: Record<string, string> = {};
85 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
86 const eq = part.indexOf("=");
87 if (eq > 0) {
88 labels[part.slice(0, eq)] = part.slice(eq + 1);
89 }
90 }
91 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
92 return { labels, text: rest.length > 0 ? rest : undefined };
93};
94
95export const hasOwnershipMarker = (text: string | undefined) =>
96 Object.keys(parseOwnership(text).labels).some((key) =>
97 key.startsWith("alchemy-"),
98 );
99
100const prefixMatch = (expected: string, observed: string) =>
101 expected === observed ||
102 expected.startsWith(observed) ||
103 observed.startsWith(expected);
104
105const labelsMatch = (
106 expected: Record<string, string>,
107 labels: Record<string, string>,
108) =>
109 prefixMatch(
110 expected[alchemyLabelKeys.stack] ?? "",
111 labels[alchemyLabelKeys.stack] ?? "",
112 ) &&
113 prefixMatch(
114 expected[alchemyLabelKeys.stage] ?? "",
115 labels[alchemyLabelKeys.stage] ?? "",
116 ) &&
117 prefixMatch(
118 expected[alchemyLabelKeys.id] ?? "",
119 labels[alchemyLabelKeys.id] ?? "",
120 );
121
122export const stampCustomAttributes = (
123 labels: Record<string, string>,
124 custom: readonly ReviewCustomAttribute[] | undefined,
125): reviews.CustomAttribute[] => {
126 const user = (custom ?? []).filter(
127 (attr) => attr.name === undefined || !attr.name.startsWith("alchemy-"),
128 );
129 return [
130 ...user,
131 {
132 name: alchemyLabelKeys.stack,
133 value: labels[alchemyLabelKeys.stack],
134 },
135 {
136 name: alchemyLabelKeys.stage,
137 value: labels[alchemyLabelKeys.stage],
138 },
139 {
140 name: alchemyLabelKeys.id,
141 value: labels[alchemyLabelKeys.id],
142 },
143 ];
144};
145
146export const stripCustomAttributes = (
147 custom: readonly ReviewCustomAttribute[] | undefined,
148): ReviewCustomAttribute[] =>
149 (custom ?? []).filter(
150 (attr) => attr.name === undefined || !attr.name.startsWith("alchemy-"),
151 );
152
153export const ownershipFromCustomAttributes = (
154 custom: readonly ReviewCustomAttribute[] | undefined,
155): Record<string, string> => {
156 const labels: Record<string, string> = {};
157 for (const attr of custom ?? []) {
158 if (attr.name?.startsWith("alchemy-") && attr.value) {
159 labels[attr.name] = attr.value;
160 }
161 }
162 return labels;
163};
164
165export const hasCustomOwnership = (
166 custom: readonly ReviewCustomAttribute[] | undefined,
167) => Object.keys(ownershipFromCustomAttributes(custom)).length > 0;
168
169export const ownedByAlchemy = (
170 id: string,
171 input: {
172 customAttributes?: readonly ReviewCustomAttribute[];
173 content?: string;
174 },
175) =>
176 Effect.gen(function* () {
177 const expected = yield* createInternalLabels(id);
178 const fromCustom = ownershipFromCustomAttributes(input.customAttributes);
179 if (Object.keys(fromCustom).length > 0) {
180 if (yield* hasAlchemyLabels(id, fromCustom)) return true;
181 if (labelsMatch(expected, fromCustom)) return true;
182 }
183 const parsed = parseOwnership(input.content);
184 if (!hasOwnershipMarker(input.content)) return false;
185 if (yield* hasAlchemyLabels(id, parsed.labels)) return true;
186 return labelsMatch(expected, parsed.labels);
187 });
188
189export const hasAlchemyOwnership = (input: {
190 customAttributes?: readonly ReviewCustomAttribute[];
191 content?: string;
192}) =>
193 hasCustomOwnership(input.customAttributes) ||
194 hasOwnershipMarker(input.content);
195
196export const accountIdOf = (account: string) =>
197 account.replace(/^accounts\//, "").split("/")[0] ?? account;
198
199export const parentOf = (account: string) => `accounts/${accountIdOf(account)}`;
200
201export const dataSourceNameOf = (account: string, dataSource: string) => {
202 if (dataSource.includes("/dataSources/")) return dataSource;
203 const id = dataSource.replace(/^dataSources\//, "");
204 return `${parentOf(account)}/dataSources/${id}`;
205};
206
207export const merchantReviewNameOf = (account: string, reviewId: string) =>
208 `${parentOf(account)}/merchantReviews/${reviewId}`;
209
210export const productReviewNameOf = (account: string, reviewId: string) =>
211 `${parentOf(account)}/productReviews/${reviewId}`;
212
213export const lastSegment = (name: string) => {
214 const trimmed = name.replace(/\/+$/, "");
215 const parts = trimmed.split("/");
216 return parts[parts.length - 1] || trimmed;
217};
218
219export const sameText = (left: string | undefined, right: string | undefined) =>
220 (left ?? "") === (right ?? "");
221
222export const jsonEqual = (left: unknown, right: unknown) =>
223 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
224
225export const sameStringList = (
226 left: readonly string[] | undefined,
227 right: readonly string[] | undefined,
228) =>
229 JSON.stringify([...(left ?? [])].slice().sort()) ===
230 JSON.stringify([...(right ?? [])].slice().sort());
231
232export const normalizeCustomAttributes = (
233 custom: readonly ReviewCustomAttribute[] | undefined,
234) =>
235 [...(custom ?? [])]
236 .map((attr) => ({
237 name: attr.name,
238 value: attr.value,
239 groupValues: attr.groupValues,
240 }))
241 .sort((left, right) => (left.name ?? "").localeCompare(right.name ?? ""));
242
243export const toResourceId = (
244 id: string,
245 requested: string | undefined,
246 existing: string | undefined,
247 maxLength = MAX_ID_LENGTH,
248) =>
249 Effect.gen(function* () {
250 if (requested !== undefined) return requested;
251 if (existing !== undefined) return existing;
252 const generated = yield* createPhysicalName({
253 id,
254 maxLength,
255 lowercase: true,
256 });
257 return /^[a-z]/.test(generated)
258 ? generated
259 : `r${generated}`.slice(0, maxLength);
260 });
261
262/** Merchant Center accounts named in the Merchant API settings (for `list`). */
263export const configuredAccountIds = () =>
264 Effect.gen(function* () {
265 const ids = new Set<string>();
266 for (const key of [
267 "GCP_MERCHANTAPI_ACCOUNT_ID",
268 "GCP_CONTENT_MERCHANT_ID",
269 ]) {
270 const value = yield* optionalSetting(key);
271 if (value) ids.add(accountIdOf(value));
272 }
273 return [...ids];
274 });
275
276const collectPages = <Page, A, E, R>(
277 pages: Stream.Stream<Page, E, R>,
278 items: (page: Page) => readonly A[] | undefined,
279) =>
280 pages.pipe(
281 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
282 Stream.runCollect,
283 Effect.map((chunk) => Array.from(chunk)),
284 );
285
286export const getMerchantReview = (name: string) =>
287 name.length === 0
288 ? Effect.succeed(undefined)
289 : reviews
290 .getAccountsMerchantReviews({ name })
291 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
292
293export const getProductReview = (name: string) =>
294 name.length === 0
295 ? Effect.succeed(undefined)
296 : reviews
297 .getAccountsProductReviews({ name })
298 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
299
300export const listMerchantReviewsAt = (account: string) =>
301 account.length === 0
302 ? Effect.succeed([] as reviews.MerchantReview[])
303 : collectPages(
304 reviews.listAccountsMerchantReviews.pages({
305 parent: parentOf(account),
306 pageSize: 250,
307 }),
308 (page) => page.merchantReviews,
309 ).pipe(
310 Effect.catchTag("NotFound", () =>
311 Effect.succeed([] as reviews.MerchantReview[]),
312 ),
313 );
314
315export const listProductReviewsAt = (account: string) =>
316 account.length === 0
317 ? Effect.succeed([] as reviews.ProductReview[])
318 : collectPages(
319 reviews.listAccountsProductReviews.pages({
320 parent: parentOf(account),
321 pageSize: 250,
322 }),
323 (page) => page.productReviews,
324 ).pipe(
325 Effect.catchTag("NotFound", () =>
326 Effect.succeed([] as reviews.ProductReview[]),
327 ),
328 );
329
330export const findOwnedMerchantReview = (
331 id: string,
332 account: string,
333 merchantReviewId?: string,
334) =>
335 Effect.gen(function* () {
336 const listed = yield* listMerchantReviewsAt(account);
337 for (const review of listed) {
338 if (merchantReviewId && review.merchantReviewId !== merchantReviewId) {
339 continue;
340 }
341 if (
342 yield* ownedByAlchemy(id, {
343 customAttributes: review.customAttributes,
344 content: review.merchantReviewAttributes?.content,
345 })
346 ) {
347 return review;
348 }
349 }
350 return undefined;
351 });
352
353export const findOwnedProductReview = (
354 id: string,
355 account: string,
356 productReviewId?: string,
357) =>
358 Effect.gen(function* () {
359 const listed = yield* listProductReviewsAt(account);
360 for (const review of listed) {
361 if (productReviewId && review.productReviewId !== productReviewId) {
362 continue;
363 }
364 if (
365 yield* ownedByAlchemy(id, {
366 customAttributes: review.customAttributes,
367 content: review.productReviewAttributes?.content,
368 })
369 ) {
370 return review;
371 }
372 }
373 return undefined;
374 });