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 type { DateRangeConfig } from "./Dashboard.ts";
13import {
14 encodeOwnership,
15 hasOwnershipMarker,
16 lastSegment,
17 locationOf,
18 locationParent,
19 ownedByAlchemy,
20 parentOf,
21 parseOwnership,
22 sameJson,
23 toResourceId,
24} from "./ownership.ts";
25
26export type DashboardsChartProps = {
27 /**
28 * Parent Dashboard resource name
29 * (`projects/{project}/locations/{location}/dashboards/{dashboard}`).
30 * Immutable — changing it replaces the chart.
31 */
32 parent: string;
33 /**
34 * Chart id (the `{chart}` segment). If omitted, a unique id is generated.
35 * Immutable — changing it replaces the chart. 4-64 characters matching
36 * `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`.
37 */
38 chartId?: string;
39 /**
40 * User-facing display name.
41 */
42 displayName?: string;
43 /**
44 * Chart description. Charts have no labels field, so Alchemy ownership
45 * is stored in a `[alchemy …]` prefix and stripped from attributes.
46 */
47 description?: string;
48 /**
49 * Filter applied to the chart.
50 */
51 filter?: string;
52 /**
53 * Width in grid units.
54 */
55 width?: number;
56 /**
57 * Height in grid units.
58 */
59 height?: number;
60 /**
61 * Visualization (`BAR`, `LINE`, `AREA`, `PIE`, `TABLE`, …).
62 */
63 chartVisualizationType?:
64 | "CHART_VISUALIZATION_TYPE_UNSPECIFIED"
65 | "BAR"
66 | "LINE"
67 | "AREA"
68 | "PIE"
69 | "SCATTER"
70 | "TABLE"
71 | "SCORE_CARD"
72 | "SUNBURST"
73 | "GAUGE"
74 | "SANKEY";
75 /**
76 * Date range applied to the chart.
77 */
78 dateRangeConfig?: DateRangeConfig;
79 /**
80 * Query or generative-insights data source.
81 */
82 dataSource?: cci.GoogleCloudContactcenterinsightsV1ChartDataSource;
83 /**
84 * Click action (redirect or conversation filter).
85 */
86 action?: cci.GoogleCloudContactcenterinsightsV1ChartAction;
87};
88
89export type DashboardsChart = Resource<
90 "GCP.ContactCenterInsights.DashboardsChart",
91 DashboardsChartProps,
92 {
93 /** Full resource name. */
94 name: string;
95 /** Chart id (last path segment). */
96 chartId: string;
97 /** Parent dashboard resource name. */
98 parent: string;
99 /** Region id. */
100 location: string;
101 /** Project id. */
102 project: string;
103 /** User-facing display name. */
104 displayName: string | undefined;
105 /** User description with the Alchemy ownership prefix stripped. */
106 description: string | undefined;
107 /** Chart filter. */
108 filter: string | undefined;
109 /** Width in grid units. */
110 width: number | undefined;
111 /** Height in grid units. */
112 height: number | undefined;
113 /** Server-assigned chart type. */
114 chartType: string | undefined;
115 /** Visualization type. */
116 chartVisualizationType: string | undefined;
117 /** Date range config. */
118 dateRangeConfig: DateRangeConfig | undefined;
119 /** Data source. */
120 dataSource:
121 | cci.GoogleCloudContactcenterinsightsV1ChartDataSource
122 | undefined;
123 /** Click action. */
124 action: cci.GoogleCloudContactcenterinsightsV1ChartAction | undefined;
125 /** RFC3339 creation timestamp. */
126 createTime: string | undefined;
127 /** RFC3339 last-update timestamp. */
128 updateTime: string | undefined;
129 },
130 never,
131 Providers
132>;
133
134/**
135 * A chart widget on a Contact Center Insights dashboard.
136 *
137 * Parent dashboard and chart id are immutable. Charts have no labels
138 * field — Alchemy stamps ownership into the description. Display name,
139 * description, filter, size, visualization, and data source update in
140 * place.
141 *
142 * ### Creating a Chart
143 * **Example:** Bar chart on a dashboard
144 * ```typescript
145 * const chart = yield* GCP.ContactCenterInsights.DashboardsChart("Volume", {
146 * parent: dashboard.name,
147 * displayName: "volume",
148 * chartVisualizationType: "BAR",
149 * });
150 * ```
151 *
152 * @resource
153 * @category ContactCenterInsights
154 */
155export const DashboardsChart = Resource<DashboardsChart>(
156 "GCP.ContactCenterInsights.DashboardsChart",
157);
158
159export class DashboardsChartNotResolved extends Data.TaggedError(
160 "GCP.ContactCenterInsights.DashboardsChartNotResolved",
161)<{
162 name: string;
163}> {}
164
165const resourceName = (parent: string, chartId: string) =>
166 `${parent}/charts/${chartId}`;
167
168const optionalString = (value: string | undefined): string | undefined => value;
169
170const toDateRangeConfig = (
171 config: cci.GoogleCloudContactcenterinsightsV1DateRangeConfig | undefined,
172): DateRangeConfig | undefined => {
173 if (config === undefined) return undefined;
174 return {
175 relativeDateRange:
176 config.relativeDateRange === undefined
177 ? undefined
178 : {
179 quantity: config.relativeDateRange.quantity,
180 unit: config.relativeDateRange.unit,
181 },
182 absoluteDateRange:
183 config.absoluteDateRange === undefined
184 ? undefined
185 : {
186 startTime: config.absoluteDateRange.startTime,
187 endTime: config.absoluteDateRange.endTime,
188 },
189 };
190};
191
192const toAttrs = (
193 chart: cci.GoogleCloudContactcenterinsightsV1Chart,
194 project: string,
195) => {
196 const name = chart.name ?? "";
197 const parsed = parseOwnership(chart.description);
198 return {
199 name,
200 chartId: lastSegment(name),
201 parent: parentOf(name),
202 location: locationOf(name),
203 project,
204 displayName: chart.displayName,
205 description: parsed.text,
206 filter: chart.filter,
207 width: chart.width,
208 height: chart.height,
209 chartType: optionalString(chart.chartType),
210 chartVisualizationType: optionalString(chart.chartVisualizationType),
211 dateRangeConfig: toDateRangeConfig(chart.dateRangeConfig),
212 dataSource: chart.dataSource,
213 action: chart.action,
214 createTime: chart.createTime,
215 updateTime: chart.updateTime,
216 };
217};
218
219const getByName = (name: string) =>
220 name.length === 0
221 ? Effect.succeed(undefined)
222 : cci
223 .getProjectsLocationsDashboardsCharts({ name })
224 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
225
226const listDashboards = (parent: string) =>
227 cci.listProjectsLocationsDashboards.pages({ parent, pageSize: 100 }).pipe(
228 Stream.flatMap((page) => Stream.fromIterable(page.dashboards ?? [])),
229 Stream.map((dashboard) => dashboard.name ?? ""),
230 Stream.filter((name) => name.length > 0),
231 Stream.runCollect,
232 Effect.map((chunk) => Array.from(chunk)),
233 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
234 );
235
236const listAtParent = (parent: string, project: string) =>
237 cci.listProjectsLocationsDashboardsCharts({ parent }).pipe(
238 Effect.map((page) =>
239 (page.charts ?? [])
240 .filter((chart) => hasOwnershipMarker(chart.description))
241 .map((chart) => toAttrs(chart, project)),
242 ),
243 Effect.catchTag("NotFound", () => Effect.succeed([])),
244 );
245
246export const DashboardsChartProvider = () =>
247 Provider.succeed(DashboardsChart, {
248 stables: ["name", "chartId", "parent", "location", "project", "createTime"],
249
250 diff: Effect.fn(function* ({ news, olds, output }) {
251 if (!isResolved(news)) return undefined;
252 const previousParent = olds?.parent ?? output?.parent;
253 if (previousParent !== undefined && news.parent !== previousParent) {
254 return { action: "replace" as const, deleteFirst: false };
255 }
256 const previousId = olds?.chartId ?? output?.chartId;
257 if (
258 previousId !== undefined &&
259 news.chartId !== undefined &&
260 news.chartId !== previousId
261 ) {
262 return { action: "replace" as const, deleteFirst: true };
263 }
264 return undefined;
265 }),
266
267 read: Effect.fn(function* ({ id, olds, output }) {
268 const env = yield* GcpEnvironment.current;
269 const chartId = yield* toResourceId(id, olds?.chartId, output?.chartId);
270 const name =
271 output?.name ??
272 (olds?.parent !== undefined ? resourceName(olds.parent, chartId) : "");
273 const existing = yield* getByName(name);
274 if (existing === undefined) return undefined;
275 const attrs = toAttrs(existing, env.project);
276 return (yield* ownedByAlchemy(id, existing.description))
277 ? attrs
278 : Unowned(attrs);
279 }),
280
281 list: () =>
282 Effect.gen(function* () {
283 const env = yield* GcpEnvironment.current;
284 const dashboards = yield* listDashboards(
285 locationParent(env.project, env.region),
286 );
287 const pages = yield* Effect.forEach(
288 dashboards,
289 (parent) => listAtParent(parent, env.project),
290 { concurrency: 4 },
291 );
292 return pages.flat();
293 }),
294
295 reconcile: Effect.fn(function* ({ id, news, output }) {
296 const env = yield* GcpEnvironment.current;
297 const chartId = yield* toResourceId(id, news.chartId, output?.chartId);
298 const name = resourceName(news.parent, chartId);
299 const ownership = yield* createInternalLabels(id);
300 const description = encodeOwnership(ownership, news.description);
301 const displayName = news.displayName ?? chartId;
302
303 let current = yield* getByName(output?.name ?? name);
304
305 if (current === undefined) {
306 const created = yield* cci
307 .createProjectsLocationsDashboardsCharts({
308 parent: news.parent,
309 chartId,
310 body: {
311 displayName,
312 description,
313 filter: news.filter,
314 width: news.width,
315 height: news.height,
316 chartVisualizationType: news.chartVisualizationType,
317 dateRangeConfig: news.dateRangeConfig,
318 dataSource: news.dataSource,
319 action: news.action,
320 },
321 })
322 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
323 current = created ?? undefined;
324 }
325
326 if (current === undefined) {
327 return yield* new DashboardsChartNotResolved({ name });
328 }
329
330 const displayChanged = (current.displayName ?? "") !== displayName;
331 const descriptionChanged = (current.description ?? "") !== description;
332 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
333 const widthChanged = (current.width ?? 0) !== (news.width ?? 0);
334 const heightChanged = (current.height ?? 0) !== (news.height ?? 0);
335 const vizChanged =
336 (current.chartVisualizationType ?? "") !==
337 (news.chartVisualizationType ?? "");
338 const dateRangeChanged = !sameJson(
339 current.dateRangeConfig,
340 news.dateRangeConfig,
341 );
342 const dataSourceChanged = !sameJson(current.dataSource, news.dataSource);
343 const actionChanged = !sameJson(current.action, news.action);
344
345 if (
346 displayChanged ||
347 descriptionChanged ||
348 filterChanged ||
349 widthChanged ||
350 heightChanged ||
351 vizChanged ||
352 dateRangeChanged ||
353 dataSourceChanged ||
354 actionChanged
355 ) {
356 current = yield* cci.patchProjectsLocationsDashboardsCharts({
357 name: current.name ?? name,
358 updateMask: "*",
359 body: {
360 name: current.name ?? name,
361 displayName,
362 description,
363 filter: news.filter,
364 width: news.width,
365 height: news.height,
366 chartVisualizationType: news.chartVisualizationType,
367 dateRangeConfig: news.dateRangeConfig,
368 dataSource: news.dataSource,
369 action: news.action,
370 },
371 });
372 }
373
374 return toAttrs(current, env.project);
375 }),
376
377 delete: Effect.fn(function* ({ output }) {
378 yield* cci
379 .deleteProjectsLocationsDashboardsCharts({ name: output.name })
380 .pipe(Effect.catchTag("NotFound", () => Effect.void));
381 }),
382 });