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 normalizeLocation,
19 ownedByAlchemy,
20 parseOwnership,
21 sameText,
22 toResourceId,
23 updateMaskOf,
24} from "./ownership.ts";
25
26export type QaScorecardProps = {
27 /**
28 * Scorecard id (the `{qa_scorecard}` segment of
29 * `projects/{project}/locations/{location}/qaScorecards/{qa_scorecard}`).
30 * If omitted, a unique id is generated. Must match `^[a-z0-9-]{4,64}$`.
31 * Immutable — changing it replaces the scorecard.
32 */
33 qaScorecardId?: string;
34 /**
35 * Location (`us-central1`, …). Immutable — changing it replaces the
36 * scorecard. `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 * User-facing display name.
42 */
43 displayName?: string;
44 /**
45 * Human-readable description. Scorecards have no labels field, so
46 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
47 * from attributes.
48 */
49 description?: string;
50 /**
51 * Whether this scorecard is the project default. Default scorecards
52 * cannot be deleted.
53 * @default false
54 */
55 isDefault?: boolean;
56};
57
58export type QaScorecard = Resource<
59 "GCP.ContactCenterInsights.QaScorecard",
60 QaScorecardProps,
61 {
62 /** Full resource name `projects/{project}/locations/{location}/qaScorecards/{qa_scorecard}`. */
63 name: string;
64 /** Scorecard id (last path segment). */
65 qaScorecardId: string;
66 /** Location id. */
67 location: string;
68 /** Project id. */
69 project: string;
70 /** User-facing display name. */
71 displayName: string | undefined;
72 /** User description with the Alchemy ownership prefix stripped. */
73 description: string | undefined;
74 /** Whether this is the project default scorecard. */
75 isDefault: boolean;
76 /** Server-reported scorecard source. */
77 source: string | undefined;
78 /** RFC3339 creation timestamp. */
79 createTime: string | undefined;
80 /** RFC3339 last-update timestamp. */
81 updateTime: string | undefined;
82 },
83 never,
84 Providers
85>;
86
87/**
88 * A Contact Center AI Insights QA scorecard — a collection of questions
89 * scored during analysis.
90 *
91 * Scorecards have no labels field — Alchemy stamps ownership into the
92 * description. Location and id are immutable. Display name and
93 * description update in place. Delete uses `force` so child revisions
94 * and questions are removed.
95 *
96 * ### Creating a QA Scorecard
97 * **Example:** Generated id
98 * ```typescript
99 * const card = yield* GCP.ContactCenterInsights.QaScorecard("Quality", {
100 * displayName: "quality",
101 * description: "call quality",
102 * });
103 * ```
104 *
105 * @resource
106 * @category ContactCenterInsights
107 */
108export const QaScorecard = Resource<QaScorecard>(
109 "GCP.ContactCenterInsights.QaScorecard",
110);
111
112export class QaScorecardNotResolved extends Data.TaggedError(
113 "GCP.ContactCenterInsights.QaScorecardNotResolved",
114)<{
115 name: string;
116}> {}
117
118const resourceName = (
119 project: string,
120 location: string,
121 qaScorecardId: string,
122) => `${locationParent(project, location)}/qaScorecards/${qaScorecardId}`;
123
124const toAttrs = (
125 card: cci.GoogleCloudContactcenterinsightsV1QaScorecard,
126 project: string,
127) => {
128 const name = card.name ?? "";
129 const parsed = parseOwnership(card.description);
130 return {
131 name,
132 qaScorecardId: lastSegment(name),
133 location: locationOf(name),
134 project,
135 displayName: card.displayName,
136 description: parsed.text,
137 isDefault: card.isDefault === true,
138 source: card.source,
139 createTime: card.createTime,
140 updateTime: card.updateTime,
141 };
142};
143
144const getByName = (name: string) =>
145 name.length === 0
146 ? Effect.succeed(undefined)
147 : cci
148 .getProjectsLocationsQaScorecards({ name })
149 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
150
151const listAt = (parent: string, project: string) =>
152 cci.listProjectsLocationsQaScorecards.pages({ parent, pageSize: 100 }).pipe(
153 Stream.flatMap((page) => Stream.fromIterable(page.qaScorecards ?? [])),
154 Stream.filter((card) => hasOwnershipMarker(card.description)),
155 Stream.map((card) => toAttrs(card, project)),
156 Stream.runCollect,
157 Effect.map((chunk) => Array.from(chunk)),
158 Effect.catchTag("NotFound", () => Effect.succeed([])),
159 );
160
161export const QaScorecardProvider = () =>
162 Provider.succeed(QaScorecard, {
163 stables: ["name", "qaScorecardId", "location", "project", "createTime"],
164
165 diff: Effect.fn(function* ({ news, olds, output }) {
166 if (!isResolved(news)) return undefined;
167 const previousLocation = olds?.location ?? output?.location;
168 if (
169 previousLocation !== undefined &&
170 news.location !== undefined &&
171 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
172 ) {
173 return { action: "replace" as const, deleteFirst: false };
174 }
175 const previousId = olds?.qaScorecardId ?? output?.qaScorecardId;
176 if (
177 previousId !== undefined &&
178 news.qaScorecardId !== undefined &&
179 news.qaScorecardId !== previousId
180 ) {
181 return { action: "replace" as const, deleteFirst: true };
182 }
183 return undefined;
184 }),
185
186 read: Effect.fn(function* ({ id, olds, output }) {
187 const env = yield* GcpEnvironment.current;
188 const qaScorecardId = yield* toResourceId(
189 id,
190 olds?.qaScorecardId,
191 output?.qaScorecardId,
192 );
193 const location = normalizeLocation(
194 olds?.location ?? output?.location ?? env.region,
195 );
196 const name =
197 output?.name ?? resourceName(env.project, location, qaScorecardId);
198 const existing = yield* getByName(name);
199 if (existing === undefined) return undefined;
200 const attrs = toAttrs(existing, env.project);
201 return (yield* ownedByAlchemy(id, existing.description))
202 ? attrs
203 : Unowned(attrs);
204 }),
205
206 list: () =>
207 Effect.gen(function* () {
208 const env = yield* GcpEnvironment.current;
209 return yield* listAt(
210 locationParent(env.project, env.region),
211 env.project,
212 );
213 }),
214
215 reconcile: Effect.fn(function* ({ id, news, output }) {
216 const env = yield* GcpEnvironment.current;
217 const location = normalizeLocation(
218 news.location ?? output?.location ?? env.region,
219 );
220 const parent = locationParent(env.project, location);
221 const qaScorecardId = yield* toResourceId(
222 id,
223 news.qaScorecardId,
224 output?.qaScorecardId,
225 );
226 const name = resourceName(env.project, location, qaScorecardId);
227 const ownership = yield* createInternalLabels(id);
228 const description = encodeOwnership(ownership, news.description);
229 const displayName = news.displayName ?? qaScorecardId;
230 const isDefault = news.isDefault === true;
231
232 let current = yield* getByName(output?.name ?? name);
233
234 if (current === undefined) {
235 const created = yield* cci
236 .createProjectsLocationsQaScorecards({
237 parent,
238 qaScorecardId,
239 body: {
240 displayName,
241 description,
242 isDefault,
243 },
244 })
245 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
246 current = created ?? undefined;
247 }
248
249 if (current === undefined) {
250 return yield* new QaScorecardNotResolved({ name });
251 }
252
253 const currentName = current.name ?? name;
254 const displayChanged = !sameText(current.displayName, displayName);
255 const descriptionChanged = (current.description ?? "") !== description;
256
257 if (displayChanged || descriptionChanged) {
258 current = yield* cci.patchProjectsLocationsQaScorecards({
259 name: currentName,
260 updateMask: updateMaskOf(
261 displayChanged ? "display_name" : undefined,
262 descriptionChanged ? "description" : undefined,
263 ),
264 body: {
265 name: currentName,
266 displayName,
267 description,
268 },
269 });
270 }
271
272 return toAttrs(current, env.project);
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 yield* cci
277 .deleteProjectsLocationsQaScorecards({
278 name: output.name,
279 force: true,
280 })
281 .pipe(Effect.catchTag("NotFound", () => Effect.void));
282 }),
283 });