1import * as androiddeviceprovisioning from "@distilled.cloud/gcp/androiddeviceprovisioning_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 DEFAULT_IS_DEFAULT,
12 encodeOwnershipLine,
13 findConfigurationByName,
14 findOwnedConfiguration,
15 getConfiguration,
16 hasOwnershipMarker,
17 lastSegment,
18 listOwnedConfigurations,
19 MAX_CONFIGURATION_NAME_LENGTH,
20 ownedByAlchemy,
21 ownershipLabels,
22 parentOf,
23 parseOwnership,
24 pickDpcName,
25 replaceOnIdentity,
26 sameBoolean,
27 sameText,
28 toConfigurationName,
29 toCustomerName,
30 toDisplayName,
31 toDpcName,
32 updateMaskOf,
33} from "./internal.ts";
34
35export type CustomersConfigurationProps = {
36 /**
37 * Parent customer (`customers/{customer}` or `{customer}`). Immutable
38 * — changing it replaces the configuration.
39 */
40 parent: string;
41 /**
42 * Server-assigned configuration id (last path segment). Immutable —
43 * changing it replaces the configuration.
44 */
45 configurationId?: string;
46 /**
47 * DPC resource (`customers/{customer}/dpcs/{dpc}` or `{dpc}`). When
48 * omitted, Alchemy lists the customer's DPCs and prefers Android
49 * Device Policy.
50 */
51 dpcResourcePath?: string;
52 /**
53 * Email shown to device users for help. Validated by the API.
54 */
55 contactEmail: string;
56 /**
57 * Phone number shown to device users for help. Accepts numerals,
58 * spaces, `+`, hyphens, and parentheses.
59 */
60 contactPhone: string;
61 /**
62 * Organization name shown during zero-touch setup.
63 */
64 companyName: string;
65 /**
66 * Short admin-facing name. Configurations have no labels field, so
67 * Alchemy stores ownership in a `[alchemy …]` prefix and strips it
68 * from attributes.
69 */
70 configurationName?: string;
71 /**
72 * JSON extras passed to the DPC at provisioning time.
73 */
74 dpcExtras?: string;
75 /**
76 * Whether newly purchased devices receive this configuration.
77 * @default false
78 */
79 isDefault?: boolean;
80 /**
81 * Help text shown to the device user before provisioning.
82 */
83 customMessage?: string;
84 /**
85 * Factory-reset timeout when setup does not complete (for example
86 * `"7200s"`). The API default is two hours when unset.
87 */
88 forcedResetTime?: string;
89};
90
91export type CustomersConfiguration = Resource<
92 "GCP.AndroidDeviceProvisioning.CustomersConfiguration",
93 CustomersConfigurationProps,
94 {
95 /** Resource name `customers/{customer}/configurations/{configuration}`. */
96 name: string;
97 /** Configuration id (last path segment). */
98 configurationId: string;
99 /** Parent customer name `customers/{customer}`. */
100 parent: string;
101 /** Project id used when the configuration was reconciled. */
102 project: string;
103 /** Admin-facing name with the Alchemy ownership prefix stripped. */
104 configurationName: string | undefined;
105 /** DPC resource path applied to devices. */
106 dpcResourcePath: string | undefined;
107 /** JSON extras passed to the DPC. */
108 dpcExtras: string | undefined;
109 /** Organization name shown during setup. */
110 companyName: string | undefined;
111 /** Help email shown to device users. */
112 contactEmail: string | undefined;
113 /** Help phone shown to device users. */
114 contactPhone: string | undefined;
115 /** Help message shown before provisioning. */
116 customMessage: string | undefined;
117 /** Whether this is the customer's default configuration. */
118 isDefault: boolean | undefined;
119 /** Factory-reset timeout duration. */
120 forcedResetTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * A zero-touch enrollment configuration
128 * (`customers/{customer}/configurations/{configuration}`).
129 *
130 * Configurations have no labels field, so Alchemy stamps ownership into
131 * `configurationName` for `list` / nuke. Parent customer and
132 * configuration id are identity — changing either replaces the
133 * configuration. Contact fields, DPC, extras, default flag, custom
134 * message, and reset timeout update in place. Create requires a
135 * zero-touch customer the caller can administer.
136 *
137 * ### Creating a Configuration
138 * **Example:** Named sales profile
139 * ```typescript
140 * const config = yield* GCP.AndroidDeviceProvisioning.CustomersConfiguration(
141 * "Sales",
142 * {
143 * parent: "customers/123456789",
144 * dpcResourcePath: "customers/123456789/dpcs/abc",
145 * companyName: "Acme",
146 * contactEmail: "it@example.com",
147 * contactPhone: "+1 555 0100",
148 * configurationName: "Sales team",
149 * },
150 * );
151 * ```
152 *
153 * **Example:** Default configuration for new devices
154 * ```typescript
155 * const config = yield* GCP.AndroidDeviceProvisioning.CustomersConfiguration(
156 * "Default",
157 * {
158 * parent: customer,
159 * dpcResourcePath: dpc,
160 * companyName: "Acme",
161 * contactEmail: "it@example.com",
162 * contactPhone: "+1 555 0100",
163 * isDefault: true,
164 * },
165 * );
166 * ```
167 *
168 * ### Updating a Configuration
169 * Re-declare the same logical id with changed props; the engine keeps the
170 * physical resource and updates it in place.
171 *
172 * **Example:** Rename and change the help email
173 * ```typescript
174 * const config = yield* GCP.AndroidDeviceProvisioning.CustomersConfiguration(
175 * "Sales",
176 * {
177 * parent: customer,
178 * dpcResourcePath: dpc,
179 * contactEmail: "help@example.com",
180 * contactPhone: "+1 555 0100",
181 * configurationName: "Field sales",
182 * },
183 * );
184 * ```
185 *
186 * @resource
187 * @category AndroidDeviceProvisioning
188 */
189export const CustomersConfiguration = Resource<CustomersConfiguration>(
190 "GCP.AndroidDeviceProvisioning.CustomersConfiguration",
191);
192
193export class CustomersConfigurationNotResolved extends Data.TaggedError(
194 "GCP.AndroidDeviceProvisioning.CustomersConfigurationNotResolved",
195)<{
196 parent: string;
197 name: string;
198}> {}
199
200const lookupName = (
201 parent: string,
202 configurationId: string | undefined,
203 existingName: string | undefined,
204) => {
205 if (existingName && existingName.length > 0) return existingName;
206 return toConfigurationName(parent, configurationId);
207};
208
209const toAttrs = (
210 row: androiddeviceprovisioning.Configuration,
211 project: string,
212) => {
213 const name = row.name ?? "";
214 return {
215 name,
216 configurationId: row.configurationId ?? lastSegment(name),
217 parent: parentOf(name) || toCustomerName(name),
218 project,
219 configurationName: parseOwnership(row.configurationName).text,
220 dpcResourcePath: row.dpcResourcePath,
221 dpcExtras: row.dpcExtras,
222 companyName: row.companyName,
223 contactEmail: row.contactEmail,
224 contactPhone: row.contactPhone,
225 customMessage: row.customMessage,
226 isDefault: row.isDefault,
227 forcedResetTime: row.forcedResetTime,
228 };
229};
230
231const desiredBody = (input: {
232 news: CustomersConfigurationProps;
233 configurationName: string;
234 dpcResourcePath: string;
235 current?: androiddeviceprovisioning.Configuration;
236}): androiddeviceprovisioning.Configuration => ({
237 configurationName: input.configurationName,
238 dpcResourcePath: input.dpcResourcePath,
239 contactEmail: input.news.contactEmail,
240 contactPhone: input.news.contactPhone,
241 companyName: input.news.companyName,
242 dpcExtras: input.news.dpcExtras ?? input.current?.dpcExtras,
243 isDefault:
244 input.news.isDefault ?? input.current?.isDefault ?? DEFAULT_IS_DEFAULT,
245 customMessage: input.news.customMessage ?? input.current?.customMessage,
246 forcedResetTime: input.news.forcedResetTime ?? input.current?.forcedResetTime,
247});
248
249export const CustomersConfigurationProvider = () =>
250 Provider.succeed(CustomersConfiguration, {
251 stables: ["name", "configurationId", "parent", "project"],
252
253 diff: Effect.fn(function* ({ news, olds, output }) {
254 if (!isResolved(news)) return undefined;
255 return replaceOnIdentity({
256 previousParent: olds?.parent ?? output?.parent,
257 nextParent: news.parent,
258 previousId: olds?.configurationId ?? output?.configurationId,
259 nextId: news.configurationId,
260 });
261 }),
262
263 read: Effect.fn(function* ({ id, olds, output }) {
264 const env = yield* GcpEnvironment.current;
265 const parent = toCustomerName(olds?.parent ?? output?.parent ?? "");
266 const name = lookupName(
267 parent,
268 olds?.configurationId ?? output?.configurationId,
269 output?.name,
270 );
271 let existing = yield* getConfiguration(name);
272 if (existing === undefined) {
273 existing = yield* findOwnedConfiguration(id, parent);
274 }
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, env.project);
277 return (yield* ownedByAlchemy(id, existing.configurationName))
278 ? attrs
279 : Unowned(attrs);
280 }),
281
282 list: () =>
283 Effect.gen(function* () {
284 const env = yield* GcpEnvironment.current;
285 const rows = yield* listOwnedConfigurations();
286 return rows
287 .filter((row) => hasOwnershipMarker(row.configurationName))
288 .map((row) => toAttrs(row, env.project));
289 }),
290
291 reconcile: Effect.fn(function* ({ id, news, output }) {
292 const env = yield* GcpEnvironment.current;
293 const parent = toCustomerName(news.parent);
294 const ownership = yield* ownershipLabels(id);
295 const rawName = yield* toDisplayName(
296 id,
297 news.configurationName,
298 output?.configurationName,
299 );
300 const configurationName = encodeOwnershipLine(
301 ownership,
302 rawName,
303 MAX_CONFIGURATION_NAME_LENGTH,
304 );
305 const dpcResourcePath = news.dpcResourcePath
306 ? toDpcName(parent, news.dpcResourcePath)
307 : (output?.dpcResourcePath ?? (yield* pickDpcName(parent)) ?? "");
308 const name = lookupName(
309 parent,
310 news.configurationId ?? output?.configurationId,
311 output?.name,
312 );
313
314 let current = yield* getConfiguration(name);
315 if (current === undefined) {
316 current = yield* findOwnedConfiguration(id, parent);
317 }
318 if (current === undefined) {
319 current = yield* findConfigurationByName(configurationName, parent);
320 }
321
322 if (current === undefined) {
323 const created = yield* androiddeviceprovisioning
324 .createCustomersConfigurations({
325 parent,
326 body: desiredBody({
327 news,
328 configurationName,
329 dpcResourcePath,
330 }),
331 })
332 .pipe(
333 Effect.catchTag("Conflict", () =>
334 findConfigurationByName(configurationName, parent),
335 ),
336 );
337 current = created ?? undefined;
338 }
339
340 if (current === undefined) {
341 return yield* new CustomersConfigurationNotResolved({
342 parent,
343 name: name || configurationName,
344 });
345 }
346
347 const desiredDpcResourcePath =
348 dpcResourcePath.length > 0
349 ? dpcResourcePath
350 : (current.dpcResourcePath ?? "");
351 const desired = desiredBody({
352 news,
353 configurationName,
354 dpcResourcePath: desiredDpcResourcePath,
355 current,
356 });
357 const currentName = current.name ?? name;
358 const updateMask = updateMaskOf(
359 !sameText(current.configurationName, desired.configurationName)
360 ? "configurationName"
361 : undefined,
362 desiredDpcResourcePath.length > 0 &&
363 !sameText(current.dpcResourcePath, desiredDpcResourcePath)
364 ? "dpcResourcePath"
365 : undefined,
366 news.dpcExtras !== undefined &&
367 !sameText(current.dpcExtras, desired.dpcExtras)
368 ? "dpcExtras"
369 : undefined,
370 !sameText(current.companyName, desired.companyName)
371 ? "companyName"
372 : undefined,
373 !sameText(current.contactEmail, desired.contactEmail)
374 ? "contactEmail"
375 : undefined,
376 !sameText(current.contactPhone, desired.contactPhone)
377 ? "contactPhone"
378 : undefined,
379 news.customMessage !== undefined &&
380 !sameText(current.customMessage, desired.customMessage)
381 ? "customMessage"
382 : undefined,
383 news.isDefault !== undefined &&
384 !sameBoolean(current.isDefault, desired.isDefault)
385 ? "isDefault"
386 : undefined,
387 news.forcedResetTime !== undefined &&
388 !sameText(current.forcedResetTime, desired.forcedResetTime)
389 ? "forcedResetTime"
390 : undefined,
391 );
392
393 if (updateMask.length > 0 && currentName.length > 0) {
394 current = yield* androiddeviceprovisioning.patchCustomersConfigurations(
395 {
396 name: currentName,
397 updateMask,
398 body: desired,
399 },
400 );
401 }
402
403 const fresh =
404 (yield* getConfiguration(current.name ?? currentName)) ?? current;
405 return toAttrs(fresh, env.project);
406 }),
407
408 delete: Effect.fn(function* ({ output }) {
409 if (!output.name) return;
410 yield* androiddeviceprovisioning
411 .deleteCustomersConfigurations({
412 name: output.name,
413 })
414 .pipe(
415 Effect.catchTag("NotFound", () => Effect.void),
416 Effect.catchTag("BadRequest", () => Effect.void),
417 );
418 }),
419 });