1import * as cci from "@distilled.cloud/gcp/contactcenterinsights_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 hasOwnershipMarker,
14 lastSegment,
15 locationOf,
16 locationParent,
17 parentOf,
18 toResourceId,
19} from "./ownership.ts";
20
21export type QaScorecardsRevisionProps = {
22 /**
23 * Parent QaScorecard resource name
24 * (`projects/{project}/locations/{location}/qaScorecards/{qa_scorecard}`).
25 * Immutable — changing it replaces the revision.
26 */
27 parent: string;
28 /**
29 * Revision id (the `{revision}` segment). If omitted, a unique id is
30 * generated. Must match `^[a-z0-9-]{4,64}$`. Immutable — changing it
31 * replaces the revision.
32 */
33 qaScorecardRevisionId?: string;
34};
35
36export type QaScorecardsRevision = Resource<
37 "GCP.ContactCenterInsights.QaScorecardsRevision",
38 QaScorecardsRevisionProps,
39 {
40 /** Full resource name. */
41 name: string;
42 /** Revision id (last path segment). */
43 qaScorecardRevisionId: string;
44 /** Parent scorecard resource name. */
45 parent: string;
46 /** Location id. */
47 location: string;
48 /** Project id. */
49 project: string;
50 /** Server-reported revision state (`EDITABLE`, `READY`, …). */
51 state: string | undefined;
52 /** Alternate ids such as `latest`. */
53 alternateIds: string[];
54 /** Scorecard snapshot captured at revision creation. */
55 snapshot: cci.GoogleCloudContactcenterinsightsV1QaScorecard | undefined;
56 /** RFC3339 creation timestamp. */
57 createTime: string | undefined;
58 },
59 never,
60 Providers
61>;
62
63/**
64 * A revision of a Contact Center AI Insights QA scorecard. Changing a
65 * published scorecard invalidates existing results, so updates create a
66 * new revision instead of mutating the current one.
67 *
68 * Parent scorecard and revision id are immutable. Revisions have no
69 * labels or display name — Alchemy stamps ownership into
70 * `snapshot.description` on create (and list also matches revisions whose
71 * parent scorecard snapshot already carries an Alchemy marker). There is
72 * no update API; reconcile is observe-ensure only. A `READY` revision is
73 * undeployed before delete.
74 *
75 * ### Creating a Scorecard Revision
76 * **Example:** Editable revision under a scorecard
77 * ```typescript
78 * const revision = yield* GCP.ContactCenterInsights.QaScorecardsRevision(
79 * "V1",
80 * { parent: card.name },
81 * );
82 * ```
83 *
84 * @resource
85 * @category ContactCenterInsights
86 */
87export const QaScorecardsRevision = Resource<QaScorecardsRevision>(
88 "GCP.ContactCenterInsights.QaScorecardsRevision",
89);
90
91export class QaScorecardsRevisionNotResolved extends Data.TaggedError(
92 "GCP.ContactCenterInsights.QaScorecardsRevisionNotResolved",
93)<{
94 name: string;
95}> {}
96
97const resourceName = (parent: string, qaScorecardRevisionId: string) =>
98 `${parent}/revisions/${qaScorecardRevisionId}`;
99
100const toAttrs = (
101 revision: cci.GoogleCloudContactcenterinsightsV1QaScorecardRevision,
102 project: string,
103) => {
104 const name = revision.name ?? "";
105 return {
106 name,
107 qaScorecardRevisionId: lastSegment(name),
108 parent: parentOf(name),
109 location: locationOf(name),
110 project,
111 state: revision.state,
112 alternateIds: revision.alternateIds ?? [],
113 snapshot: revision.snapshot,
114 createTime: revision.createTime,
115 };
116};
117
118const getByName = (name: string) =>
119 name.length === 0
120 ? Effect.succeed(undefined)
121 : cci
122 .getProjectsLocationsQaScorecardsRevisions({ name })
123 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
124
125const listScorecards = (parent: string) =>
126 cci.listProjectsLocationsQaScorecards.pages({ parent, pageSize: 100 }).pipe(
127 Stream.flatMap((page) => Stream.fromIterable(page.qaScorecards ?? [])),
128 Stream.filter((card) => hasOwnershipMarker(card.description)),
129 Stream.map((card) => card.name ?? ""),
130 Stream.filter((name) => name.length > 0),
131 Stream.runCollect,
132 Effect.map((chunk) => Array.from(chunk)),
133 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
134 );
135
136const listRaw = (parent: string) =>
137 cci.listProjectsLocationsQaScorecardsRevisions
138 .pages({ parent, pageSize: 100 })
139 .pipe(
140 Stream.flatMap((page) =>
141 Stream.fromIterable(page.qaScorecardRevisions ?? []),
142 ),
143 Stream.runCollect,
144 Effect.map((chunk) => Array.from(chunk)),
145 Effect.catchTag("NotFound", () => Effect.succeed([])),
146 );
147
148const findExisting = (parent: string, qaScorecardRevisionId: string) =>
149 listRaw(parent).pipe(
150 Effect.map(
151 (revisions) =>
152 revisions.find(
153 (revision) =>
154 lastSegment(revision.name ?? "") === qaScorecardRevisionId,
155 ) ??
156 revisions.find((revision) =>
157 (revision.alternateIds ?? []).includes("latest"),
158 ) ??
159 revisions[0],
160 ),
161 );
162
163const listAtParent = (parent: string, project: string) =>
164 listRaw(parent).pipe(
165 Effect.map((revisions) =>
166 revisions.map((revision) => toAttrs(revision, project)),
167 ),
168 );
169
170export const QaScorecardsRevisionProvider = () =>
171 Provider.succeed(QaScorecardsRevision, {
172 stables: [
173 "name",
174 "qaScorecardRevisionId",
175 "parent",
176 "location",
177 "project",
178 "createTime",
179 ],
180
181 diff: Effect.fn(function* ({ news, olds, output }) {
182 if (!isResolved(news)) return undefined;
183 const previousParent = olds?.parent ?? output?.parent;
184 if (previousParent !== undefined && news.parent !== previousParent) {
185 return { action: "replace" as const, deleteFirst: false };
186 }
187 const previousId =
188 olds?.qaScorecardRevisionId ?? output?.qaScorecardRevisionId;
189 if (
190 previousId !== undefined &&
191 news.qaScorecardRevisionId !== undefined &&
192 news.qaScorecardRevisionId !== previousId
193 ) {
194 return { action: "replace" as const, deleteFirst: true };
195 }
196 return undefined;
197 }),
198
199 read: Effect.fn(function* ({ id, olds, output }) {
200 const env = yield* GcpEnvironment.current;
201 const qaScorecardRevisionId = yield* toResourceId(
202 id,
203 olds?.qaScorecardRevisionId,
204 output?.qaScorecardRevisionId,
205 );
206 const name =
207 output?.name ??
208 (olds?.parent !== undefined
209 ? resourceName(olds.parent, qaScorecardRevisionId)
210 : "");
211 const existing = yield* getByName(name);
212 if (existing === undefined) return undefined;
213 const attrs = toAttrs(existing, env.project);
214 return hasOwnershipMarker(existing.snapshot?.description) ||
215 output?.name === existing.name
216 ? attrs
217 : Unowned(attrs);
218 }),
219
220 list: () =>
221 Effect.gen(function* () {
222 const env = yield* GcpEnvironment.current;
223 const scorecards = yield* listScorecards(
224 locationParent(env.project, env.region),
225 );
226 const pages = yield* Effect.forEach(
227 scorecards,
228 (parent) => listAtParent(parent, env.project),
229 { concurrency: 4 },
230 );
231 return pages.flat();
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const qaScorecardRevisionId = yield* toResourceId(
237 id,
238 news.qaScorecardRevisionId,
239 output?.qaScorecardRevisionId,
240 );
241 const name = resourceName(news.parent, qaScorecardRevisionId);
242
243 let current = yield* getByName(output?.name ?? name);
244 if (current === undefined) {
245 current = yield* findExisting(news.parent, qaScorecardRevisionId);
246 }
247
248 if (current === undefined) {
249 const created = yield* cci
250 .createProjectsLocationsQaScorecardsRevisions({
251 parent: news.parent,
252 qaScorecardRevisionId,
253 body: {},
254 })
255 .pipe(
256 Effect.retry({
257 while: (error) => error._tag === "QaScorecardPreconditionFailed",
258 times: 5,
259 schedule: Schedule.exponential("250 millis"),
260 }),
261 Effect.catchIf(
262 (error) => error._tag === "Conflict",
263 () => getByName(name),
264 ),
265 // The scorecard's current revision is still settling; adopt it.
266 Effect.catchTag("QaScorecardPreconditionFailed", () =>
267 findExisting(news.parent, qaScorecardRevisionId),
268 ),
269 );
270 current = created ?? undefined;
271 }
272
273 if (current === undefined) {
274 return yield* new QaScorecardsRevisionNotResolved({ name });
275 }
276
277 return toAttrs(current, env.project);
278 }),
279
280 delete: Effect.fn(function* ({ output }) {
281 const existing = yield* getByName(output.name);
282 if (existing === undefined) return;
283 if (existing.state === "READY") {
284 yield* cci
285 .undeployProjectsLocationsQaScorecardsRevisions({
286 name: output.name,
287 body: {},
288 })
289 .pipe(
290 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
291 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
292 );
293 }
294 yield* cci
295 .deleteProjectsLocationsQaScorecardsRevisions({
296 name: output.name,
297 force: true,
298 })
299 .pipe(
300 Effect.catchTag("NotFound", () => Effect.void),
301 // The only revision goes away with its scorecard.
302 Effect.catchTag("QaScorecardOnlyRevision", () => Effect.void),
303 );
304 }),
305 });