1import * as cci from "@distilled.cloud/gcp/contactcenterinsights_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnershipLine,
14 hasOwnershipMarker,
15 lastSegment,
16 locationOf,
17 locationParent,
18 MAX_VIEW_DISPLAY_NAME_LENGTH,
19 normalizeLocation,
20 ownedByAlchemy,
21 parseOwnership,
22 sameText,
23 toResourceId,
24 updateMaskOf,
25} from "./ownership.ts";
26
27export type ViewProps = {
28 /**
29 * View id (the `{view}` segment of
30 * `projects/{project}/locations/{location}/views/{view}`). If omitted, a
31 * unique id is generated. Immutable — changing it replaces the view.
32 */
33 viewId?: string;
34 /**
35 * Location (`us-central1`, …). Immutable — changing it replaces the
36 * view. `US-CENTRAL1` is accepted and normalized to `us-central1`.
37 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
38 */
39 location?: string;
40 /**
41 * Display name (max 64 characters including Alchemy's ownership
42 * marker). Views have no labels field, so ownership is stored in a
43 * `[alchemy …]` prefix and stripped from attributes.
44 */
45 displayName?: string;
46 /**
47 * Filter reducing conversation results to a subset. See
48 * https://cloud.google.com/contact-center/insights/docs/filtering.
49 */
50 value?: string;
51};
52
53export type View = Resource<
54 "GCP.ContactCenterInsights.View",
55 ViewProps,
56 {
57 /** Full resource name `projects/{project}/locations/{location}/views/{view}`. */
58 name: string;
59 /** View id (last path segment). */
60 viewId: string;
61 /** Location id. */
62 location: string;
63 /** Project id. */
64 project: string;
65 /** User display name with the Alchemy ownership prefix stripped. */
66 displayName: string | undefined;
67 /** Conversation filter. */
68 value: string | undefined;
69 /** RFC3339 creation timestamp. */
70 createTime: string | undefined;
71 /** RFC3339 last-update timestamp. */
72 updateTime: string | undefined;
73 },
74 never,
75 Providers
76>;
77
78/**
79 * A Contact Center AI Insights saved view — a named conversation filter.
80 *
81 * Views have no labels field — Alchemy stamps ownership into the display
82 * name. Location and id are immutable. Display name and filter value
83 * update in place.
84 *
85 * ### Creating a View
86 * **Example:** Chat-only view
87 * ```typescript
88 * const view = yield* GCP.ContactCenterInsights.View("Chat", {
89 * displayName: "chat",
90 * value: 'medium="CHAT"',
91 * });
92 * ```
93 *
94 * @resource
95 * @category ContactCenterInsights
96 */
97export const View = Resource<View>("GCP.ContactCenterInsights.View");
98
99export class ViewNotResolved extends Data.TaggedError(
100 "GCP.ContactCenterInsights.ViewNotResolved",
101)<{
102 name: string;
103}> {}
104
105const resourceName = (project: string, location: string, viewId: string) =>
106 `${locationParent(project, location)}/views/${viewId}`;
107
108const toAttrs = (
109 view: cci.GoogleCloudContactcenterinsightsV1View,
110 project: string,
111) => {
112 const name = view.name ?? "";
113 const parsed = parseOwnership(view.displayName);
114 return {
115 name,
116 viewId: lastSegment(name),
117 location: locationOf(name),
118 project,
119 displayName: parsed.text,
120 value: view.value,
121 createTime: view.createTime,
122 updateTime: view.updateTime,
123 };
124};
125
126const getByName = (name: string) =>
127 name.length === 0
128 ? Effect.succeed(undefined)
129 : cci
130 .getProjectsLocationsViews({ name })
131 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
132
133const listAt = (parent: string, project: string) =>
134 cci.listProjectsLocationsViews.pages({ parent, pageSize: 100 }).pipe(
135 Stream.flatMap((page) => Stream.fromIterable(page.views ?? [])),
136 Stream.filter((view) => hasOwnershipMarker(view.displayName)),
137 Stream.map((view) => toAttrs(view, project)),
138 Stream.runCollect,
139 Effect.map((chunk) => Array.from(chunk)),
140 Effect.catchTag("NotFound", () => Effect.succeed([])),
141 );
142
143const findByDisplayName = (parent: string, displayName: string) =>
144 cci.listProjectsLocationsViews.pages({ parent, pageSize: 100 }).pipe(
145 Stream.flatMap((page) => Stream.fromIterable(page.views ?? [])),
146 Stream.filter((view) => view.displayName === displayName),
147 Stream.runHead,
148 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
149 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
150 );
151
152export const ViewProvider = () =>
153 Provider.succeed(View, {
154 stables: ["name", "viewId", "location", "project", "createTime"],
155
156 diff: Effect.fn(function* ({ news, olds, output }) {
157 if (!isResolved(news)) return undefined;
158 const previousLocation = olds?.location ?? output?.location;
159 if (
160 previousLocation !== undefined &&
161 news.location !== undefined &&
162 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
163 ) {
164 return { action: "replace" as const, deleteFirst: false };
165 }
166 const previousId = olds?.viewId ?? output?.viewId;
167 if (
168 previousId !== undefined &&
169 news.viewId !== undefined &&
170 news.viewId !== previousId
171 ) {
172 return { action: "replace" as const, deleteFirst: false };
173 }
174 return undefined;
175 }),
176
177 read: Effect.fn(function* ({ id, olds, output }) {
178 const env = yield* GcpEnvironment.current;
179 const viewId = yield* toResourceId(id, olds?.viewId, output?.viewId);
180 const location = normalizeLocation(
181 olds?.location ?? output?.location ?? env.region,
182 );
183 const name = output?.name ?? resourceName(env.project, location, viewId);
184 let existing = yield* getByName(name);
185 if (existing === undefined && output?.name === undefined) {
186 const ownership = yield* createInternalLabels(id);
187 existing = yield* findByDisplayName(
188 locationParent(env.project, location),
189 encodeOwnershipLine(
190 ownership,
191 olds?.displayName,
192 MAX_VIEW_DISPLAY_NAME_LENGTH,
193 ),
194 );
195 }
196 if (existing === undefined) return undefined;
197 const attrs = toAttrs(existing, env.project);
198 return (yield* ownedByAlchemy(id, existing.displayName))
199 ? attrs
200 : Unowned(attrs);
201 }),
202
203 list: () =>
204 Effect.gen(function* () {
205 const env = yield* GcpEnvironment.current;
206 return yield* listAt(
207 locationParent(env.project, env.region),
208 env.project,
209 );
210 }),
211
212 reconcile: Effect.fn(function* ({ id, news, output }) {
213 const env = yield* GcpEnvironment.current;
214 const location = normalizeLocation(
215 news.location ?? output?.location ?? env.region,
216 );
217 const parent = locationParent(env.project, location);
218 const viewId = yield* toResourceId(id, news.viewId, output?.viewId);
219 const name = output?.name ?? resourceName(env.project, location, viewId);
220 const ownership = yield* createInternalLabels(id);
221 const displayName = encodeOwnershipLine(
222 ownership,
223 news.displayName,
224 MAX_VIEW_DISPLAY_NAME_LENGTH,
225 );
226
227 let current = yield* getByName(name);
228 if (current === undefined) {
229 current = yield* findByDisplayName(parent, displayName);
230 }
231
232 if (current === undefined) {
233 const created = yield* cci
234 .createProjectsLocationsViews({
235 parent,
236 body: {
237 name,
238 displayName,
239 value: news.value,
240 },
241 })
242 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
243 current = created ?? (yield* findByDisplayName(parent, displayName));
244 }
245
246 if (current === undefined) {
247 return yield* new ViewNotResolved({ name });
248 }
249
250 const currentName = current.name ?? name;
251 const displayChanged = (current.displayName ?? "") !== displayName;
252 const valueChanged = !sameText(current.value, news.value);
253
254 if (displayChanged || valueChanged) {
255 current = yield* cci.patchProjectsLocationsViews({
256 name: currentName,
257 updateMask: updateMaskOf(
258 displayChanged ? "display_name" : undefined,
259 valueChanged ? "value" : undefined,
260 ),
261 body: {
262 name: currentName,
263 displayName,
264 value: news.value,
265 },
266 });
267 }
268
269 return toAttrs(current, env.project);
270 }),
271
272 delete: Effect.fn(function* ({ output }) {
273 yield* cci
274 .deleteProjectsLocationsViews({ name: output.name })
275 .pipe(Effect.catchTag("NotFound", () => Effect.void));
276 }),
277 });