1import * as androidenterprise from "@distilled.cloud/gcp/androidenterprise_v1";
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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 findOwnedCluster,
12 getCluster,
13 hasOwnershipMarker,
14 jsonEqual,
15 listOwnedClusters,
16 ownedByAlchemy,
17 ownershipTextFromNames,
18 publicNames,
19 sameStringList,
20 sameText,
21 defaultNames,
22 toDisplayName,
23} from "./internal.ts";
24
25export type StorelayoutclusterProps = {
26 /**
27 * Play EMM enterprise id. Immutable — changing it replaces the cluster.
28 */
29 enterpriseId: string;
30 /**
31 * Parent store page id. Immutable — changing it replaces the cluster.
32 */
33 pageId: string;
34 /**
35 * Server-assigned cluster id. Immutable — changing it replaces the
36 * cluster.
37 */
38 clusterId?: string;
39 /**
40 * Localized cluster names, shown to device users as given.
41 */
42 name?: androidenterprise.LocalizedText[];
43 /**
44 * Product ids displayed in the cluster, in order. Duplicates are not
45 * allowed.
46 */
47 productId?: string[];
48 /**
49 * US-ASCII ordering key among elements on the parent page. Max 256
50 * characters.
51 */
52 orderInPage?: string;
53};
54
55export type Storelayoutcluster = Resource<
56 "GCP.AndroidEnterprise.Storelayoutcluster",
57 StorelayoutclusterProps,
58 {
59 /** Server-assigned cluster id. */
60 clusterId: string;
61 /** Parent store page id. */
62 pageId: string;
63 /** Play EMM enterprise id. */
64 enterpriseId: string;
65 /** Project id used when the cluster was reconciled. */
66 project: string;
67 /** Localized names with the Alchemy ownership prefix stripped. */
68 name: androidenterprise.LocalizedText[] | undefined;
69 /** Product ids in display order. */
70 productId: string[] | undefined;
71 /** Ordering key on the parent page. */
72 orderInPage: string | undefined;
73 },
74 never,
75 Providers
76>;
77
78/**
79 * A managed Google Play store cluster (`storelayoutclusters`).
80 *
81 * Store clusters have no labels field and their names are shown to device
82 * users, so Alchemy tracks a cluster by its recorded id only (`list` finds
83 * clusters from older releases that carried a name marker). `enterpriseId`, `pageId`,
84 * and `clusterId` are identity — changing any replaces the cluster.
85 * Names, products, and order update in place.
86 *
87 * ### Creating a Store Cluster
88 * **Example:** Generated name
89 * ```typescript
90 * const cluster = yield* GCP.AndroidEnterprise.Storelayoutcluster("Apps", {
91 * enterpriseId: page.enterpriseId,
92 * pageId: page.pageId,
93 * });
94 * ```
95 *
96 * **Example:** Explicit name and products
97 * ```typescript
98 * const cluster = yield* GCP.AndroidEnterprise.Storelayoutcluster("Apps", {
99 * enterpriseId: page.enterpriseId,
100 * pageId: page.pageId,
101 * name: [{ locale: "en-US", text: "Work apps" }],
102 * productId: ["app:com.google.android.gm"],
103 * });
104 * ```
105 *
106 * ### Updating a Store Cluster
107 * Re-declare the same logical id with changed props; the engine keeps the
108 * physical resource and updates it in place.
109 *
110 * **Example:** Rename
111 * ```typescript
112 * const cluster = yield* GCP.AndroidEnterprise.Storelayoutcluster("Apps", {
113 * enterpriseId: page.enterpriseId,
114 * pageId: page.pageId,
115 * name: [{ locale: "en-US", text: "Featured apps" }],
116 * });
117 * ```
118 *
119 * @resource
120 * @category AndroidEnterprise
121 */
122export const Storelayoutcluster = Resource<Storelayoutcluster>(
123 "GCP.AndroidEnterprise.Storelayoutcluster",
124);
125
126export class StorelayoutclusterNotResolved extends Data.TaggedError(
127 "GCP.AndroidEnterprise.StorelayoutclusterNotResolved",
128)<{
129 enterpriseId: string;
130 pageId: string;
131 clusterId: string;
132}> {}
133
134const toAttrs = (
135 cluster: androidenterprise.StoreCluster,
136 enterpriseId: string,
137 pageId: string,
138 project: string,
139) => ({
140 clusterId: cluster.id ?? "",
141 pageId,
142 enterpriseId,
143 project,
144 name: publicNames(cluster.name),
145 productId: cluster.productId,
146 orderInPage: cluster.orderInPage,
147});
148
149const desiredBody = (input: {
150 clusterId?: string;
151 name: androidenterprise.LocalizedText[];
152 news: StorelayoutclusterProps;
153 current?: androidenterprise.StoreCluster;
154}): androidenterprise.StoreCluster => ({
155 id: input.clusterId,
156 name: input.name,
157 productId: input.news.productId ?? input.current?.productId,
158 orderInPage: input.news.orderInPage ?? input.current?.orderInPage,
159});
160
161const needsSync = (
162 current: androidenterprise.StoreCluster,
163 desired: androidenterprise.StoreCluster,
164) =>
165 !jsonEqual(current.name, desired.name) ||
166 (desired.productId !== undefined &&
167 !sameStringList(current.productId, desired.productId)) ||
168 (desired.orderInPage !== undefined &&
169 !sameText(current.orderInPage, desired.orderInPage));
170
171export const StorelayoutclusterProvider = () =>
172 Provider.succeed(Storelayoutcluster, {
173 stables: ["clusterId", "pageId", "enterpriseId", "project"],
174
175 diff: Effect.fn(function* ({ news, olds, output }) {
176 if (!isResolved(news)) return undefined;
177 const previousEnterprise = olds?.enterpriseId ?? output?.enterpriseId;
178 if (
179 previousEnterprise !== undefined &&
180 news.enterpriseId !== previousEnterprise
181 ) {
182 return { action: "replace" as const, deleteFirst: false };
183 }
184 const previousPage = olds?.pageId ?? output?.pageId;
185 if (previousPage !== undefined && news.pageId !== previousPage) {
186 return { action: "replace" as const, deleteFirst: false };
187 }
188 const previousId = olds?.clusterId ?? output?.clusterId;
189 if (
190 previousId !== undefined &&
191 news.clusterId !== undefined &&
192 news.clusterId !== previousId
193 ) {
194 return { action: "replace" as const, deleteFirst: false };
195 }
196 return undefined;
197 }),
198
199 read: Effect.fn(function* ({ id, olds, output }) {
200 const env = yield* GcpEnvironment.current;
201 const enterpriseId = olds?.enterpriseId ?? output?.enterpriseId ?? "";
202 const pageId = olds?.pageId ?? output?.pageId ?? "";
203 const clusterId = olds?.clusterId ?? output?.clusterId ?? "";
204 let existing = yield* getCluster(enterpriseId, pageId, clusterId);
205 let observedPageId = pageId;
206 if (existing === undefined && enterpriseId.length > 0) {
207 const found = yield* findOwnedCluster(
208 id,
209 enterpriseId,
210 pageId || undefined,
211 );
212 existing = found?.cluster;
213 observedPageId = found?.pageId ?? pageId;
214 }
215 if (existing === undefined) return undefined;
216 const attrs = toAttrs(
217 existing,
218 enterpriseId,
219 observedPageId,
220 env.project,
221 );
222 // Only a recorded id (or a legacy ownership marker) proves ownership;
223 // names and titles are user-visible and carry no marker.
224 return output !== undefined ||
225 (yield* ownedByAlchemy(id, ownershipTextFromNames(existing.name)))
226 ? attrs
227 : Unowned(attrs);
228 }),
229
230 list: () =>
231 Effect.gen(function* () {
232 const env = yield* GcpEnvironment.current;
233 const clusters = yield* listOwnedClusters();
234 return clusters
235 .filter(({ cluster }) =>
236 hasOwnershipMarker(ownershipTextFromNames(cluster.name)),
237 )
238 .map(({ cluster, enterpriseId, pageId }) =>
239 toAttrs(cluster, enterpriseId, pageId, env.project),
240 );
241 }),
242
243 reconcile: Effect.fn(function* ({ id, news, output }) {
244 const env = yield* GcpEnvironment.current;
245 const enterpriseId = news.enterpriseId;
246 const pageId = news.pageId;
247 const displayName = yield* toDisplayName(
248 id,
249 news.name?.[0]?.text,
250 output?.name?.[0]?.text,
251 );
252 const name = defaultNames(news.name, displayName);
253
254 let current = yield* getCluster(
255 enterpriseId,
256 pageId,
257 news.clusterId ?? output?.clusterId ?? "",
258 );
259 if (current === undefined) {
260 const found = yield* findOwnedCluster(id, enterpriseId, pageId);
261 current = found?.cluster;
262 }
263
264 if (current === undefined) {
265 const created = yield* androidenterprise
266 .insertStorelayoutclusters({
267 enterpriseId,
268 pageId,
269 body: desiredBody({ name, news }),
270 })
271 .pipe(
272 Effect.catchTag("Conflict", () =>
273 findOwnedCluster(id, enterpriseId, pageId).pipe(
274 Effect.map((found) => found?.cluster),
275 ),
276 ),
277 );
278 current = created ?? undefined;
279 }
280
281 if (current === undefined) {
282 return yield* new StorelayoutclusterNotResolved({
283 enterpriseId,
284 pageId,
285 clusterId: news.clusterId ?? output?.clusterId ?? displayName,
286 });
287 }
288
289 const clusterId = current.id ?? news.clusterId ?? output?.clusterId ?? "";
290 const desired = desiredBody({
291 clusterId,
292 name,
293 news,
294 current,
295 });
296 if (needsSync(current, desired)) {
297 current = yield* androidenterprise.updateStorelayoutclusters({
298 enterpriseId,
299 pageId,
300 clusterId,
301 body: desired,
302 });
303 }
304
305 return toAttrs(current, enterpriseId, pageId, env.project);
306 }),
307
308 delete: Effect.fn(function* ({ output }) {
309 if (!output.enterpriseId || !output.pageId || !output.clusterId) return;
310 yield* androidenterprise
311 .deleteStorelayoutclusters({
312 enterpriseId: output.enterpriseId,
313 pageId: output.pageId,
314 clusterId: output.clusterId,
315 })
316 .pipe(
317 Effect.catchTag("NotFound", () => Effect.void),
318 Effect.catchTag("BadRequest", () => Effect.void),
319 Effect.catchTag("Conflict", () => Effect.void),
320 );
321 }),
322 });