1import * as ccaip from "@distilled.cloud/gcp/contactcenteraiplatform_v1alpha1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 ContactCenterNotResolved,
19 DEFAULT_INSTANCE_SIZE,
20 fingerprint,
21 isTerminated,
22 listOwnedContactCenters,
23 locationParent,
24 normalizeLocation,
25 parseName,
26 releaseChannelOf,
27 replaceOnIdentity,
28 retryTransient,
29 sameBool,
30 sameJson,
31 sameText,
32 toDomainPrefix,
33 toPhysicalId,
34 updateMaskOf,
35 userLabels,
36 waitUntilExists,
37 waitUntilGone,
38 waitUntilReady,
39} from "./internal.ts";
40import { waitForDeleteOperation, waitForOperation } from "./operations.ts";
41
42export type ContactCenterProps = {
43 /**
44 * Contact Center id (the `{contactCenter}` segment of
45 * `projects/{project}/locations/{location}/contactCenters/{contactCenter}`).
46 * If omitted, a unique RFC1035 name is generated. Immutable — changing
47 * it replaces the instance.
48 */
49 contactCenterId?: string;
50 /**
51 * Location (`us-central1`, …). Immutable — changing it replaces the
52 * instance. `US-CENTRAL1` is accepted and normalized to `us-central1`.
53 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
54 */
55 location?: string;
56 /**
57 * User-facing display name. Required by the API; Alchemy falls back to
58 * the generated contact center id.
59 */
60 displayName?: string;
61 /**
62 * Customer domain prefix (2–16 RFC1035 characters). Used as the
63 * instance subdomain. Immutable — changing it replaces the instance.
64 * If omitted, a unique prefix is generated.
65 */
66 customerDomainPrefix?: string;
67 /**
68 * Instance size (`DEV_SMALL`, `STANDARD_SMALL`, `TRIAL_SMALL`, …).
69 * Immutable — changing it replaces the instance.
70 * @default "DEV_SMALL"
71 */
72 instanceSize?: ccaip.InstanceConfigInstanceSizeEnum | (string & {});
73 /**
74 * Full instance configuration. Wins over `instanceSize` when set.
75 * Immutable — changing it replaces the instance.
76 */
77 instanceConfig?: ccaip.InstanceConfig;
78 /**
79 * User labels. Alchemy ownership labels are merged in automatically.
80 */
81 labels?: Record<string, string>;
82 /**
83 * Email of the first admin user.
84 */
85 userEmail?: string;
86 /**
87 * First admin given/family name.
88 */
89 adminUser?: ccaip.AdminUser;
90 /**
91 * Customer-managed KMS key that encrypts user input. Immutable —
92 * changing it replaces the instance.
93 */
94 kmsKey?: string;
95 /**
96 * When true, users can be created in the CCAIP instance in addition to
97 * Cloud Identity.
98 * @default false
99 */
100 ccaipManagedUsers?: boolean;
101 /**
102 * SAML parameters that set up Google as IdP.
103 */
104 samlParams?: ccaip.SAMLParams;
105 /**
106 * Feature flags (for example agent desktop).
107 */
108 featureConfig?: ccaip.FeatureConfig;
109 /**
110 * VPC-SC private ingress/egress settings.
111 */
112 privateAccess?: ccaip.PrivateAccess;
113 /**
114 * Whether advanced reporting is enabled.
115 * @default false
116 */
117 advancedReportingEnabled?: boolean;
118 /**
119 * Release channel. `early` is for test instances, `normal` lags early
120 * by two days, `critical` only updates outside `critical.peakHours`.
121 */
122 releaseChannel?: "early" | "normal" | "critical";
123 /**
124 * Peak hours used when `releaseChannel` is `critical`.
125 */
126 critical?: ccaip.Critical;
127};
128
129export type ContactCenter = Resource<
130 "GCP.ContactCenterAIPlatform.ContactCenter",
131 ContactCenterProps,
132 {
133 /** Full resource name `projects/{project}/locations/{location}/contactCenters/{contactCenter}`. */
134 name: string;
135 /** Contact Center id (last path segment). */
136 contactCenterId: string;
137 /** Location id. */
138 location: string;
139 /** Project id. */
140 project: string;
141 /** User-facing display name. */
142 displayName: string | undefined;
143 /** Customer domain prefix. */
144 customerDomainPrefix: string | undefined;
145 /** Instance size. */
146 instanceSize: string | undefined;
147 /** User labels (Alchemy ownership labels stripped). */
148 labels: Record<string, string>;
149 /** First admin email. */
150 userEmail: string | undefined;
151 /** First admin given/family name. */
152 adminUser: ccaip.AdminUser | undefined;
153 /** Customer-managed KMS key. */
154 kmsKey: string | undefined;
155 /** Whether CCAIP-managed users are enabled. */
156 ccaipManagedUsers: boolean;
157 /** SAML IdP parameters. */
158 samlParams: ccaip.SAMLParams | undefined;
159 /** Feature flags. */
160 featureConfig: ccaip.FeatureConfig | undefined;
161 /** Private networking settings. */
162 privateAccess: ccaip.PrivateAccess | undefined;
163 /** Whether advanced reporting is enabled. */
164 advancedReportingEnabled: boolean;
165 /** Release channel currently applied. */
166 releaseChannel: "early" | "normal" | "critical" | undefined;
167 /** Peak hours for the critical channel. */
168 critical: ccaip.Critical | undefined;
169 /** Server-reported state (`STATE_DEPLOYED`, …). */
170 state: string | undefined;
171 /** UJET release version. */
172 releaseVersion: string | undefined;
173 /** Access URIs for the deployed instance. */
174 uris: ccaip.URIs | undefined;
175 /** Private-path component names. */
176 privateComponents: string[] | undefined;
177 /** RFC3339 creation timestamp. */
178 createTime: string | undefined;
179 /** RFC3339 last-update timestamp. */
180 updateTime: string | undefined;
181 /** RFC3339 soft-delete timestamp. */
182 deleteTime: string | undefined;
183 /** RFC3339 expiry timestamp. */
184 expireTime: string | undefined;
185 /** RFC3339 hard-delete timestamp. */
186 purgeTime: string | undefined;
187 },
188 never,
189 Providers
190>;
191
192/**
193 * A Contact Center AI Platform (CCAIP) instance.
194 *
195 * Create, update, and delete are long-running operations. Location,
196 * contact center id, `customerDomainPrefix`, `instanceConfig`, and
197 * `kmsKey` are immutable. Display name, labels, admin user, SAML, feature
198 * flags, private access, advanced reporting, and release channel update
199 * in place.
200 *
201 * Provisioning is entitlement-gated and typically takes several minutes.
202 *
203 * ### Creating a Contact Center
204 * **Example:** Generated name
205 * ```typescript
206 * const center = yield* GCP.ContactCenterAIPlatform.ContactCenter("Support", {
207 * displayName: "support",
208 * instanceSize: "DEV_SMALL",
209 * });
210 * ```
211 *
212 * **Example:** Explicit id, domain prefix, and labels
213 * ```typescript
214 * const center = yield* GCP.ContactCenterAIPlatform.ContactCenter("Support", {
215 * contactCenterId: "support-desk",
216 * location: "us-central1",
217 * displayName: "Support desk",
218 * customerDomainPrefix: "supdesk",
219 * instanceSize: "DEV_SMALL",
220 * labels: { env: "prod" },
221 * });
222 * ```
223 *
224 * ### Updating a Contact Center
225 * **Example:** Display name and labels
226 * ```typescript
227 * const center = yield* GCP.ContactCenterAIPlatform.ContactCenter("Support", {
228 * customerDomainPrefix: "supdesk",
229 * displayName: "Support desk v2",
230 * labels: { env: "prod", team: "cx" },
231 * });
232 * ```
233 *
234 * @resource
235 * @category ContactCenterAIPlatform
236 */
237export const ContactCenter = Resource<ContactCenter>(
238 "GCP.ContactCenterAIPlatform.ContactCenter",
239);
240
241const resourceName = (
242 project: string,
243 location: string,
244 contactCenterId: string,
245) => `${locationParent(project, location)}/contactCenters/${contactCenterId}`;
246
247const instanceConfigOf = (news: ContactCenterProps): ccaip.InstanceConfig =>
248 news.instanceConfig ?? {
249 instanceSize: news.instanceSize ?? DEFAULT_INSTANCE_SIZE,
250 };
251
252const channelBody = (news: ContactCenterProps) => {
253 const channel =
254 news.releaseChannel ??
255 (news.critical !== undefined ? "critical" : undefined);
256 return {
257 early: channel === "early" ? {} : undefined,
258 normal: channel === "normal" ? {} : undefined,
259 critical: channel === "critical" ? (news.critical ?? {}) : news.critical,
260 };
261};
262
263const toAttrs = (
264 item: ccaip.ContactCenter,
265 project: string,
266 region: string,
267) => {
268 const name = item.name ?? "";
269 const parsed = parseName(name, region);
270 return {
271 name,
272 contactCenterId: parsed.id,
273 location: parsed.location,
274 project: parsed.project || project,
275 displayName: item.displayName,
276 customerDomainPrefix: item.customerDomainPrefix,
277 instanceSize: item.instanceConfig?.instanceSize,
278 labels: userLabels(item.labels),
279 userEmail: item.userEmail,
280 adminUser: item.adminUser,
281 kmsKey: item.kmsKey,
282 ccaipManagedUsers: item.ccaipManagedUsers === true,
283 samlParams: item.samlParams,
284 featureConfig: item.featureConfig,
285 privateAccess: item.privateAccess,
286 advancedReportingEnabled: item.advancedReportingEnabled === true,
287 releaseChannel: releaseChannelOf(item),
288 critical: item.critical,
289 state: item.state,
290 releaseVersion: item.releaseVersion,
291 uris: item.uris,
292 privateComponents: item.privateComponents,
293 createTime: item.createTime,
294 updateTime: item.updateTime,
295 deleteTime: item.deleteTime,
296 expireTime: item.expireTime,
297 purgeTime: item.purgeTime,
298 };
299};
300
301const getByName = (name: string) =>
302 name.length === 0
303 ? Effect.succeed(undefined)
304 : ccaip
305 .getProjectsLocationsContactCenters({ name })
306 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
307
308const getLive = (name: string) =>
309 getByName(name).pipe(
310 Effect.map((item) =>
311 item === undefined || isTerminated(item.state) ? undefined : item,
312 ),
313 );
314
315export const ContactCenterProvider = () =>
316 Provider.succeed(ContactCenter, {
317 stables: [
318 "name",
319 "contactCenterId",
320 "location",
321 "project",
322 "customerDomainPrefix",
323 "createTime",
324 ],
325
326 diff: Effect.fn(function* ({ news, olds, output }) {
327 if (!isResolved(news)) return undefined;
328 const env = yield* GcpEnvironment.current;
329 const previousLocation = normalizeLocation(
330 olds?.location ?? output?.location,
331 env.region,
332 );
333 const nextLocation = normalizeLocation(
334 news.location ?? olds?.location ?? output?.location,
335 env.region,
336 );
337 const previousSize =
338 olds?.instanceConfig?.instanceSize ??
339 olds?.instanceSize ??
340 output?.instanceSize;
341 const nextSize = news.instanceConfig?.instanceSize ?? news.instanceSize;
342 const previousPrefix =
343 olds?.customerDomainPrefix ?? output?.customerDomainPrefix;
344 const previousKms = olds?.kmsKey ?? output?.kmsKey;
345 return replaceOnIdentity({
346 previousId: olds?.contactCenterId ?? output?.contactCenterId,
347 nextId: news.contactCenterId,
348 previousLocation,
349 nextLocation,
350 extra:
351 (previousPrefix !== undefined &&
352 news.customerDomainPrefix !== undefined &&
353 previousPrefix !== news.customerDomainPrefix) ||
354 (previousSize !== undefined &&
355 nextSize !== undefined &&
356 previousSize !== nextSize) ||
357 (previousKms !== undefined &&
358 news.kmsKey !== undefined &&
359 previousKms !== news.kmsKey) ||
360 (olds?.instanceConfig !== undefined &&
361 news.instanceConfig !== undefined &&
362 fingerprint(olds.instanceConfig) !==
363 fingerprint(news.instanceConfig)),
364 });
365 }),
366
367 read: Effect.fn(function* ({ id, olds, output }) {
368 const env = yield* GcpEnvironment.current;
369 const location = normalizeLocation(
370 olds?.location ?? output?.location,
371 env.region,
372 );
373 const contactCenterId = yield* toPhysicalId(
374 id,
375 olds?.contactCenterId,
376 output?.contactCenterId,
377 );
378 const name =
379 output?.name ?? resourceName(env.project, location, contactCenterId);
380 const existing = yield* getLive(name);
381 if (existing === undefined) {
382 return undefined;
383 }
384 const attrs = toAttrs(existing, env.project, env.region);
385 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
386 ? attrs
387 : Unowned(attrs);
388 }),
389
390 list: () =>
391 Effect.gen(function* () {
392 const env = yield* GcpEnvironment.current;
393 const items: ccaip.ContactCenter[] = yield* listOwnedContactCenters(
394 env.project,
395 env.region,
396 );
397 return items
398 .filter((item) => !isTerminated(item.state))
399 .map((item) => toAttrs(item, env.project, env.region));
400 }),
401
402 reconcile: Effect.fn(function* ({ id, news, output }) {
403 const env = yield* GcpEnvironment.current;
404 const location = normalizeLocation(
405 news.location ?? output?.location,
406 env.region,
407 );
408 const contactCenterId = yield* toPhysicalId(
409 id,
410 news.contactCenterId,
411 output?.contactCenterId,
412 );
413 const name =
414 output?.name ?? resourceName(env.project, location, contactCenterId);
415 const parent = locationParent(env.project, location);
416 const customerDomainPrefix = yield* toDomainPrefix(
417 news.customerDomainPrefix,
418 output?.customerDomainPrefix,
419 id,
420 );
421 const displayName = news.displayName ?? contactCenterId;
422 const instanceConfig = instanceConfigOf(news);
423 const desiredLabels = {
424 ...toLabels(news.labels),
425 ...(yield* createInternalLabels(id)),
426 };
427 const channel = channelBody(news);
428
429 let current = yield* getLive(name);
430
431 if (current === undefined) {
432 const created = yield* retryTransient(
433 ccaip.createProjectsLocationsContactCenters({
434 parent,
435 contactCenterId,
436 body: {
437 displayName,
438 customerDomainPrefix,
439 instanceConfig,
440 labels: desiredLabels,
441 userEmail: news.userEmail,
442 adminUser: news.adminUser,
443 kmsKey: news.kmsKey,
444 ccaipManagedUsers: news.ccaipManagedUsers,
445 samlParams: news.samlParams,
446 featureConfig: news.featureConfig,
447 privateAccess: news.privateAccess,
448 advancedReportingEnabled: news.advancedReportingEnabled,
449 early: channel.early,
450 normal: channel.normal,
451 critical: channel.critical,
452 },
453 }),
454 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
455 if (created !== undefined) {
456 yield* waitForOperation(created);
457 }
458 current = yield* waitUntilExists(getLive(name), name);
459 }
460
461 if (current === undefined) {
462 return yield* new ContactCenterNotResolved({ name });
463 }
464
465 const currentName = current.name ?? name;
466 let live: ccaip.ContactCenter = yield* waitUntilReady(
467 getLive(currentName),
468 currentName,
469 (item) => item.state,
470 ).pipe(
471 Effect.catchIf(
472 (error) =>
473 error._tag === "GCP.ContactCenterAIPlatform.ContactCenterNotReady",
474 () => waitUntilExists(getLive(currentName), currentName),
475 ),
476 );
477
478 const observedLabels = tagRecord(live.labels);
479 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
480 const labelsChanged = upsert.length > 0 || removed.length > 0;
481 const displayChanged = !sameText(live.displayName, displayName);
482 const emailChanged = !sameText(live.userEmail, news.userEmail);
483 const adminChanged = !sameJson(live.adminUser, news.adminUser);
484 const managedChanged = !sameBool(
485 live.ccaipManagedUsers,
486 news.ccaipManagedUsers,
487 );
488 const samlChanged =
489 news.samlParams !== undefined &&
490 !sameJson(live.samlParams, news.samlParams);
491 const featureChanged =
492 news.featureConfig !== undefined &&
493 !sameJson(live.featureConfig, news.featureConfig);
494 const privateChanged =
495 news.privateAccess !== undefined &&
496 !sameJson(live.privateAccess, news.privateAccess);
497 const reportingChanged = !sameBool(
498 live.advancedReportingEnabled,
499 news.advancedReportingEnabled,
500 );
501 const desiredChannel =
502 news.releaseChannel ??
503 (news.critical !== undefined ? "critical" : undefined);
504 const channelChanged =
505 desiredChannel !== undefined &&
506 releaseChannelOf(live) !== desiredChannel;
507 const criticalChanged =
508 news.critical !== undefined && !sameJson(live.critical, news.critical);
509
510 const mask = updateMaskOf(
511 labelsChanged && "labels",
512 displayChanged && "displayName",
513 emailChanged && "userEmail",
514 adminChanged && "adminUser",
515 managedChanged && "ccaipManagedUsers",
516 samlChanged && "samlParams",
517 featureChanged && "featureConfig",
518 privateChanged && "privateAccess",
519 reportingChanged && "advancedReportingEnabled",
520 channelChanged && desiredChannel === "early" && "early",
521 channelChanged && desiredChannel === "normal" && "normal",
522 (channelChanged && desiredChannel === "critical") || criticalChanged
523 ? "critical"
524 : undefined,
525 );
526
527 if (mask.length > 0) {
528 const patched = yield* retryTransient(
529 ccaip.patchProjectsLocationsContactCenters({
530 name: currentName,
531 updateMask: mask,
532 body: {
533 labels: desiredLabels,
534 displayName,
535 userEmail: news.userEmail,
536 adminUser: news.adminUser,
537 ccaipManagedUsers: news.ccaipManagedUsers,
538 samlParams: news.samlParams,
539 featureConfig: news.featureConfig,
540 privateAccess: news.privateAccess,
541 advancedReportingEnabled: news.advancedReportingEnabled,
542 early: channel.early,
543 normal: channel.normal,
544 critical: channel.critical,
545 },
546 }),
547 ).pipe(
548 Effect.retry({
549 while: (error) => error._tag === "Conflict",
550 times: 8,
551 schedule: Schedule.spaced("2 seconds"),
552 }),
553 );
554 yield* waitForOperation(patched);
555 live = yield* waitUntilExists(getLive(currentName), currentName);
556 }
557
558 return toAttrs(live, env.project, env.region);
559 }),
560
561 delete: Effect.fn(function* ({ output }) {
562 if (!output.name) return;
563 const deleted = yield* retryTransient(
564 ccaip.deleteProjectsLocationsContactCenters({ name: output.name }),
565 ).pipe(
566 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
567 Effect.retry({
568 while: (error) => error._tag === "Conflict",
569 times: 8,
570 schedule: Schedule.spaced("5 seconds"),
571 }),
572 );
573 if (deleted !== undefined) {
574 yield* waitForDeleteOperation(deleted);
575 }
576 yield* waitUntilGone(getLive(output.name), output.name);
577 }),
578 });
579
580export { ContactCenterFailed, ContactCenterNotResolved } from "./internal.ts";