| 1 | import * as androidenterprise from "@distilled.cloud/gcp/androidenterprise_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { GcpEnvironment } from "../Environment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { |
| 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 | |
| 25 | export 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 | |
| 55 | export 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 | */ |
| 122 | export const Storelayoutcluster = Resource<Storelayoutcluster>( |
| 123 | "GCP.AndroidEnterprise.Storelayoutcluster", |
| 124 | ); |
| 125 | |
| 126 | export class StorelayoutclusterNotResolved extends Data.TaggedError( |
| 127 | "GCP.AndroidEnterprise.StorelayoutclusterNotResolved", |
| 128 | )<{ |
| 129 | enterpriseId: string; |
| 130 | pageId: string; |
| 131 | clusterId: string; |
| 132 | }> {} |
| 133 | |
| 134 | const 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 | |
| 149 | const 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 | |
| 161 | const 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 | |
| 171 | export 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 | }); |