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 findOwnedPage,
12 getPage,
13 hasOwnershipMarker,
14 jsonEqual,
15 listOwnedPages,
16 ownedByAlchemy,
17 ownershipTextFromNames,
18 publicNames,
19 sameStringList,
20 defaultNames,
21 toDisplayName,
22} from "./internal.ts";
23
24export type StorelayoutpageProps = {
25 /**
26 * Play EMM enterprise id. Immutable — changing it replaces the page.
27 */
28 enterpriseId: string;
29 /**
30 * Server-assigned store page id. Immutable — changing it replaces the
31 * page.
32 */
33 pageId?: string;
34 /**
35 * Localized page names, shown to device users as given.
36 */
37 name?: androidenterprise.LocalizedText[];
38 /**
39 * Page ids a user can reach from this page. The list cannot include
40 * this page.
41 */
42 link?: string[];
43};
44
45export type Storelayoutpage = Resource<
46 "GCP.AndroidEnterprise.Storelayoutpage",
47 StorelayoutpageProps,
48 {
49 /** Server-assigned store page id. */
50 pageId: string;
51 /** Play EMM enterprise id. */
52 enterpriseId: string;
53 /** Project id used when the page was reconciled. */
54 project: string;
55 /** Localized names with the Alchemy ownership prefix stripped. */
56 name: androidenterprise.LocalizedText[] | undefined;
57 /** Linked page ids. */
58 link: string[] | undefined;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * A managed Google Play store page (`storelayoutpages`).
66 *
67 * Store pages have no labels field and their names are shown to device
68 * users, so Alchemy tracks a page by its recorded id only (`list` finds
69 * pages from older releases that carried a name marker). `enterpriseId` and `pageId`
70 * are identity — changing either replaces the page. Names and links
71 * update in place.
72 *
73 * ### Creating a Store Page
74 * **Example:** Generated name
75 * ```typescript
76 * const page = yield* GCP.AndroidEnterprise.Storelayoutpage("Home", {
77 * enterpriseId: "123456789",
78 * });
79 * ```
80 *
81 * **Example:** Explicit localized name
82 * ```typescript
83 * const page = yield* GCP.AndroidEnterprise.Storelayoutpage("Home", {
84 * enterpriseId: "123456789",
85 * name: [{ locale: "en-US", text: "Home" }],
86 * });
87 * ```
88 *
89 * ### Updating a Store Page
90 * Re-declare the same logical id with changed props; the engine keeps the
91 * physical resource and updates it in place.
92 *
93 * **Example:** Rename
94 * ```typescript
95 * const page = yield* GCP.AndroidEnterprise.Storelayoutpage("Home", {
96 * enterpriseId: "123456789",
97 * name: [{ locale: "en-US", text: "Featured" }],
98 * });
99 * ```
100 *
101 * @resource
102 * @category AndroidEnterprise
103 */
104export const Storelayoutpage = Resource<Storelayoutpage>(
105 "GCP.AndroidEnterprise.Storelayoutpage",
106);
107
108export class StorelayoutpageNotResolved extends Data.TaggedError(
109 "GCP.AndroidEnterprise.StorelayoutpageNotResolved",
110)<{
111 enterpriseId: string;
112 pageId: string;
113}> {}
114
115const toAttrs = (
116 page: androidenterprise.StorePage,
117 enterpriseId: string,
118 project: string,
119) => ({
120 pageId: page.id ?? "",
121 enterpriseId,
122 project,
123 name: publicNames(page.name),
124 link: page.link,
125});
126
127const desiredBody = (input: {
128 pageId?: string;
129 name: androidenterprise.LocalizedText[];
130 news: StorelayoutpageProps;
131 current?: androidenterprise.StorePage;
132}): androidenterprise.StorePage => ({
133 id: input.pageId,
134 name: input.name,
135 link: input.news.link ?? input.current?.link,
136});
137
138const needsSync = (
139 current: androidenterprise.StorePage,
140 desired: androidenterprise.StorePage,
141) =>
142 !jsonEqual(current.name, desired.name) ||
143 (desired.link !== undefined && !sameStringList(current.link, desired.link));
144
145export const StorelayoutpageProvider = () =>
146 Provider.succeed(Storelayoutpage, {
147 stables: ["pageId", "enterpriseId", "project"],
148
149 diff: Effect.fn(function* ({ news, olds, output }) {
150 if (!isResolved(news)) return undefined;
151 const previousEnterprise = olds?.enterpriseId ?? output?.enterpriseId;
152 if (
153 previousEnterprise !== undefined &&
154 news.enterpriseId !== previousEnterprise
155 ) {
156 return { action: "replace" as const, deleteFirst: false };
157 }
158 const previousId = olds?.pageId ?? output?.pageId;
159 if (
160 previousId !== undefined &&
161 news.pageId !== undefined &&
162 news.pageId !== previousId
163 ) {
164 return { action: "replace" as const, deleteFirst: false };
165 }
166 return undefined;
167 }),
168
169 read: Effect.fn(function* ({ id, olds, output }) {
170 const env = yield* GcpEnvironment.current;
171 const enterpriseId = olds?.enterpriseId ?? output?.enterpriseId ?? "";
172 const pageId = olds?.pageId ?? output?.pageId ?? "";
173 let existing = yield* getPage(enterpriseId, pageId);
174 if (existing === undefined && enterpriseId.length > 0) {
175 existing = yield* findOwnedPage(id, enterpriseId);
176 }
177 if (existing === undefined) return undefined;
178 const attrs = toAttrs(existing, enterpriseId, env.project);
179 // Only a recorded id (or a legacy ownership marker) proves ownership;
180 // names and titles are user-visible and carry no marker.
181 return output !== undefined ||
182 (yield* ownedByAlchemy(id, ownershipTextFromNames(existing.name)))
183 ? attrs
184 : Unowned(attrs);
185 }),
186
187 list: () =>
188 Effect.gen(function* () {
189 const env = yield* GcpEnvironment.current;
190 const pages = yield* listOwnedPages();
191 return pages
192 .filter(({ page }) =>
193 hasOwnershipMarker(ownershipTextFromNames(page.name)),
194 )
195 .map(({ page, enterpriseId }) =>
196 toAttrs(page, enterpriseId, env.project),
197 );
198 }),
199
200 reconcile: Effect.fn(function* ({ id, news, output }) {
201 const env = yield* GcpEnvironment.current;
202 const enterpriseId = news.enterpriseId;
203 const displayName = yield* toDisplayName(
204 id,
205 news.name?.[0]?.text,
206 output?.name?.[0]?.text,
207 );
208 const name = defaultNames(news.name, displayName);
209
210 let current = yield* getPage(
211 enterpriseId,
212 news.pageId ?? output?.pageId ?? "",
213 );
214 if (current === undefined) {
215 current = yield* findOwnedPage(id, enterpriseId);
216 }
217
218 if (current === undefined) {
219 const created = yield* androidenterprise
220 .insertStorelayoutpages({
221 enterpriseId,
222 body: desiredBody({ name, news }),
223 })
224 .pipe(
225 Effect.catchTag("Conflict", () => findOwnedPage(id, enterpriseId)),
226 );
227 current = created ?? undefined;
228 }
229
230 if (current === undefined) {
231 return yield* new StorelayoutpageNotResolved({
232 enterpriseId,
233 pageId: news.pageId ?? output?.pageId ?? displayName,
234 });
235 }
236
237 const pageId = current.id ?? news.pageId ?? output?.pageId ?? "";
238 const desired = desiredBody({
239 pageId,
240 name,
241 news,
242 current,
243 });
244 if (needsSync(current, desired)) {
245 current = yield* androidenterprise.updateStorelayoutpages({
246 enterpriseId,
247 pageId,
248 body: desired,
249 });
250 }
251
252 return toAttrs(current, enterpriseId, env.project);
253 }),
254
255 delete: Effect.fn(function* ({ output }) {
256 if (!output.enterpriseId || !output.pageId) return;
257 yield* androidenterprise
258 .deleteStorelayoutpages({
259 enterpriseId: output.enterpriseId,
260 pageId: output.pageId,
261 })
262 .pipe(
263 Effect.catchTag("NotFound", () => Effect.void),
264 Effect.catchTag("BadRequest", () => Effect.void),
265 Effect.catchTag("Conflict", () => Effect.void),
266 );
267 }),
268 });