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 encodeOwnership,
14 hasOwnershipMarker,
15 lastSegment,
16 locationOf,
17 locationParent,
18 ownedByAlchemy,
19 parseOwnership,
20 sameJson,
21 toResourceId,
22} from "./ownership.ts";
23
24export type DateRangeConfig = {
25 /** Rolling window measured in `unit`. */
26 relativeDateRange?: {
27 /** Number of units in the past. */
28 quantity?: string;
29 /** Calendar unit (`DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR`). */
30 unit?:
31 | "TIME_UNIT_UNSPECIFIED"
32 | "DAY"
33 | "WEEK"
34 | "MONTH"
35 | "QUARTER"
36 | "YEAR"
37 | (string & {});
38 };
39 /** Inclusive start/end timestamps. */
40 absoluteDateRange?: {
41 startTime?: string;
42 endTime?: string;
43 };
44};
45
46export type DashboardProps = {
47 /**
48 * Region (`us-central1`, …). Immutable — changing it replaces the
49 * dashboard.
50 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Dashboard id (the `{dashboard}` segment). If omitted, a unique id is
55 * generated from the stack, stage, and logical id. Immutable — changing
56 * it replaces the dashboard. 4-64 characters matching
57 * `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`.
58 */
59 dashboardId?: string;
60 /**
61 * User-facing display name.
62 */
63 displayName?: string;
64 /**
65 * Human-readable description. Dashboards have no labels field, so
66 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
67 * from attributes.
68 */
69 description?: string;
70 /**
71 * Filter applied to every chart on the dashboard.
72 */
73 filter?: string;
74 /**
75 * Date range applied to every chart on the dashboard.
76 */
77 dateRangeConfig?: DateRangeConfig;
78 /**
79 * Root widget container describing the dashboard layout.
80 */
81 rootContainer?: cci.GoogleCloudContactcenterinsightsV1Container;
82};
83
84export type Dashboard = Resource<
85 "GCP.ContactCenterInsights.Dashboard",
86 DashboardProps,
87 {
88 /** Full resource name. */
89 name: string;
90 /** Dashboard id (last path segment). */
91 dashboardId: string;
92 /** Region id. */
93 location: string;
94 /** Project id. */
95 project: string;
96 /** User-facing display name. */
97 displayName: string | undefined;
98 /** User description with the Alchemy ownership prefix stripped. */
99 description: string | undefined;
100 /** Dashboard-wide filter. */
101 filter: string | undefined;
102 /** Dashboard-wide date range. */
103 dateRangeConfig: DateRangeConfig | undefined;
104 /** Root layout container. */
105 rootContainer: cci.GoogleCloudContactcenterinsightsV1Container | undefined;
106 /** Whether the dashboard is a predefined read-only dashboard. */
107 readOnly: boolean;
108 /** RFC3339 creation timestamp. */
109 createTime: string | undefined;
110 /** RFC3339 last-update timestamp. */
111 updateTime: string | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A Contact Center Insights dashboard of conversation charts.
119 *
120 * Dashboards have no labels field — Alchemy stamps ownership into the
121 * description. Location and dashboard id are immutable. Display name,
122 * description, filter, date range, and root container update in place.
123 *
124 * ### Creating a Dashboard
125 * **Example:** Generated id
126 * ```typescript
127 * const dashboard = yield* GCP.ContactCenterInsights.Dashboard("Overview", {
128 * displayName: "overview",
129 * description: "call quality",
130 * });
131 * ```
132 *
133 * **Example:** Named dashboard
134 * ```typescript
135 * const dashboard = yield* GCP.ContactCenterInsights.Dashboard("Overview", {
136 * dashboardId: "quality-overview",
137 * displayName: "quality",
138 * });
139 * ```
140 *
141 * @resource
142 * @category ContactCenterInsights
143 */
144export const Dashboard = Resource<Dashboard>(
145 "GCP.ContactCenterInsights.Dashboard",
146);
147
148export class DashboardNotResolved extends Data.TaggedError(
149 "GCP.ContactCenterInsights.DashboardNotResolved",
150)<{
151 name: string;
152}> {}
153
154const resourceName = (parent: string, dashboardId: string) =>
155 `${parent}/dashboards/${dashboardId}`;
156
157const toDateRangeConfig = (
158 config: cci.GoogleCloudContactcenterinsightsV1DateRangeConfig | undefined,
159): DateRangeConfig | undefined => {
160 if (config === undefined) return undefined;
161 return {
162 relativeDateRange:
163 config.relativeDateRange === undefined
164 ? undefined
165 : {
166 quantity: config.relativeDateRange.quantity,
167 unit: config.relativeDateRange.unit,
168 },
169 absoluteDateRange:
170 config.absoluteDateRange === undefined
171 ? undefined
172 : {
173 startTime: config.absoluteDateRange.startTime,
174 endTime: config.absoluteDateRange.endTime,
175 },
176 };
177};
178
179const toAttrs = (
180 dashboard: cci.GoogleCloudContactcenterinsightsV1Dashboard,
181 project: string,
182) => {
183 const name = dashboard.name ?? "";
184 const parsed = parseOwnership(dashboard.description);
185 return {
186 name,
187 dashboardId: lastSegment(name),
188 location: locationOf(name),
189 project,
190 displayName: dashboard.displayName,
191 description: parsed.text,
192 filter: dashboard.filter,
193 dateRangeConfig: toDateRangeConfig(dashboard.dateRangeConfig),
194 rootContainer: dashboard.rootContainer,
195 readOnly: dashboard.readOnly === true,
196 createTime: dashboard.createTime,
197 updateTime: dashboard.updateTime,
198 };
199};
200
201const getByName = (name: string) =>
202 name.length === 0
203 ? Effect.succeed(undefined)
204 : cci
205 .getProjectsLocationsDashboards({ name })
206 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
207
208const listAt = (parent: string, project: string) =>
209 cci.listProjectsLocationsDashboards.pages({ parent, pageSize: 100 }).pipe(
210 Stream.flatMap((page) => Stream.fromIterable(page.dashboards ?? [])),
211 Stream.filter((dashboard) => hasOwnershipMarker(dashboard.description)),
212 Stream.map((dashboard) => toAttrs(dashboard, project)),
213 Stream.runCollect,
214 Effect.map((chunk) => Array.from(chunk)),
215 Effect.catchTag("NotFound", () => Effect.succeed([])),
216 );
217
218export const DashboardProvider = () =>
219 Provider.succeed(Dashboard, {
220 stables: ["name", "dashboardId", "location", "project", "createTime"],
221
222 diff: Effect.fn(function* ({ news, olds, output }) {
223 if (!isResolved(news)) return undefined;
224 const previousLocation = olds?.location ?? output?.location;
225 const nextLocation = news.location;
226 if (
227 previousLocation !== undefined &&
228 nextLocation !== undefined &&
229 previousLocation !== nextLocation
230 ) {
231 return { action: "replace" as const, deleteFirst: false };
232 }
233 const previousId = olds?.dashboardId ?? output?.dashboardId;
234 if (
235 previousId !== undefined &&
236 news.dashboardId !== undefined &&
237 news.dashboardId !== previousId
238 ) {
239 return { action: "replace" as const, deleteFirst: true };
240 }
241 return undefined;
242 }),
243
244 read: Effect.fn(function* ({ id, olds, output }) {
245 const env = yield* GcpEnvironment.current;
246 const dashboardId = yield* toResourceId(
247 id,
248 olds?.dashboardId,
249 output?.dashboardId,
250 );
251 const location = olds?.location ?? output?.location ?? env.region;
252 const name =
253 output?.name ??
254 resourceName(locationParent(env.project, location), dashboardId);
255 const existing = yield* getByName(name);
256 if (existing === undefined) return undefined;
257 const attrs = toAttrs(existing, env.project);
258 return (yield* ownedByAlchemy(id, existing.description))
259 ? attrs
260 : Unowned(attrs);
261 }),
262
263 list: () =>
264 Effect.gen(function* () {
265 const env = yield* GcpEnvironment.current;
266 return yield* listAt(
267 locationParent(env.project, env.region),
268 env.project,
269 );
270 }),
271
272 reconcile: Effect.fn(function* ({ id, news, output }) {
273 const env = yield* GcpEnvironment.current;
274 const location = news.location ?? output?.location ?? env.region;
275 const parent = locationParent(env.project, location);
276 const dashboardId = yield* toResourceId(
277 id,
278 news.dashboardId,
279 output?.dashboardId,
280 );
281 const name = resourceName(parent, dashboardId);
282 const ownership = yield* createInternalLabels(id);
283 const description = encodeOwnership(ownership, news.description);
284 const displayName = news.displayName ?? dashboardId;
285
286 let current = yield* getByName(output?.name ?? name);
287
288 if (current === undefined) {
289 const created = yield* cci
290 .createProjectsLocationsDashboards({
291 parent,
292 dashboardId,
293 body: {
294 displayName,
295 description,
296 filter: news.filter,
297 dateRangeConfig: news.dateRangeConfig,
298 rootContainer: news.rootContainer,
299 },
300 })
301 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
302 current = created ?? undefined;
303 }
304
305 if (current === undefined) {
306 return yield* new DashboardNotResolved({ name });
307 }
308
309 const displayChanged = (current.displayName ?? "") !== displayName;
310 const descriptionChanged = (current.description ?? "") !== description;
311 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
312 const dateRangeChanged = !sameJson(
313 current.dateRangeConfig,
314 news.dateRangeConfig,
315 );
316 const rootChanged = !sameJson(current.rootContainer, news.rootContainer);
317
318 if (
319 displayChanged ||
320 descriptionChanged ||
321 filterChanged ||
322 dateRangeChanged ||
323 rootChanged
324 ) {
325 current = yield* cci.patchProjectsLocationsDashboards({
326 name: current.name ?? name,
327 updateMask: [
328 displayChanged ? "display_name" : undefined,
329 descriptionChanged ? "description" : undefined,
330 filterChanged ? "filter" : undefined,
331 dateRangeChanged ? "date_range_config" : undefined,
332 rootChanged ? "root_container" : undefined,
333 ]
334 .filter((field): field is string => field !== undefined)
335 .join(","),
336 body: {
337 name: current.name ?? name,
338 displayName,
339 description,
340 filter: news.filter,
341 dateRangeConfig: news.dateRangeConfig,
342 rootContainer: news.rootContainer,
343 },
344 });
345 }
346
347 return toAttrs(current, env.project);
348 }),
349
350 delete: Effect.fn(function* ({ output }) {
351 yield* cci
352 .deleteProjectsLocationsDashboards({ name: output.name })
353 .pipe(Effect.catchTag("NotFound", () => Effect.void));
354 }),
355 });