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 { 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 { resourceNameFromOperation, waitForOperation } from "./operations.ts";
13import {
14 encodeOwnership,
15 encodeOwnershipLine,
16 hasOwnershipMarker,
17 lastSegment,
18 locationOf,
19 locationParent,
20 ownedByAlchemy,
21 parentOf,
22 parseOwnership,
23} from "./ownership.ts";
24
25export type IssueModelsIssueProps = {
26 /**
27 * Parent IssueModel resource name
28 * (`projects/{project}/locations/{location}/issueModels/{issue_model}`).
29 * Immutable — changing it replaces the issue.
30 */
31 parent: string;
32 /**
33 * Representative name.
34 */
35 displayName?: string;
36 /**
37 * Representative description. Issues have no labels field, so Alchemy
38 * ownership is stored in a `[alchemy …]` prefix and stripped from
39 * attributes.
40 */
41 displayDescription?: string;
42};
43
44export type IssueModelsIssue = Resource<
45 "GCP.ContactCenterInsights.IssueModelsIssue",
46 IssueModelsIssueProps,
47 {
48 /** Full resource name. */
49 name: string;
50 /** Issue id (last path segment). */
51 issueId: string;
52 /** Parent issue model resource name. */
53 parent: string;
54 /** Region id. */
55 location: string;
56 /** Project id. */
57 project: string;
58 /** User-facing display name. */
59 displayName: string | undefined;
60 /** User description with the Alchemy ownership prefix stripped. */
61 displayDescription: string | undefined;
62 /** Sample representative utterances. */
63 sampleUtterances: string[];
64 /** RFC3339 creation timestamp. */
65 createTime: string | undefined;
66 /** RFC3339 last-update timestamp. */
67 updateTime: string | undefined;
68 },
69 never,
70 Providers
71>;
72
73/**
74 * An issue under a Contact Center Insights issue model.
75 *
76 * Parent issue model is immutable. Issues have no labels field — Alchemy
77 * stamps ownership into `displayDescription`. Display name and description
78 * update in place. Create is a long-running operation; the parent model
79 * must be undeployed.
80 *
81 * ### Creating an Issue
82 * **Example:** Billing issue under a model
83 * ```typescript
84 * const issue = yield* GCP.ContactCenterInsights.IssueModelsIssue("Billing", {
85 * parent: model.name,
86 * displayName: "billing",
87 * displayDescription: "questions about invoices",
88 * });
89 * ```
90 *
91 * @resource
92 * @category ContactCenterInsights
93 */
94export const IssueModelsIssue = Resource<IssueModelsIssue>(
95 "GCP.ContactCenterInsights.IssueModelsIssue",
96);
97
98export class IssueModelsIssueNotResolved extends Data.TaggedError(
99 "GCP.ContactCenterInsights.IssueModelsIssueNotResolved",
100)<{
101 name: string;
102}> {}
103
104const toAttrs = (
105 issue: cci.GoogleCloudContactcenterinsightsV1Issue,
106 project: string,
107) => {
108 const name = issue.name ?? "";
109 const parsed = parseOwnership(issue.displayDescription);
110 return {
111 name,
112 issueId: lastSegment(name),
113 parent: parentOf(name),
114 location: locationOf(name),
115 project,
116 displayName: issue.displayName,
117 displayDescription: parsed.text,
118 sampleUtterances: issue.sampleUtterances ?? [],
119 createTime: issue.createTime,
120 updateTime: issue.updateTime,
121 };
122};
123
124const getByName = (name: string) =>
125 name.length === 0
126 ? Effect.succeed(undefined)
127 : cci
128 .getProjectsLocationsIssueModelsIssues({ name })
129 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
130
131const waitUntilExists = (name: string) =>
132 getByName(name).pipe(
133 Effect.flatMap((issue) =>
134 issue
135 ? Effect.succeed(issue)
136 : Effect.fail(new IssueModelsIssueNotResolved({ name })),
137 ),
138 Effect.retry({
139 while: (error) =>
140 error._tag === "GCP.ContactCenterInsights.IssueModelsIssueNotResolved",
141 times: 8,
142 schedule: Schedule.spaced("2 seconds"),
143 }),
144 );
145
146const listModels = (parent: string) =>
147 cci.listProjectsLocationsIssueModels({ parent }).pipe(
148 Effect.map((page) =>
149 (page.issueModels ?? [])
150 .map((model) => model.name ?? "")
151 .filter((name) => name.length > 0),
152 ),
153 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
154 );
155
156const listAtParent = (parent: string, project: string) =>
157 cci.listProjectsLocationsIssueModelsIssues({ parent }).pipe(
158 Effect.map((page) =>
159 (page.issues ?? [])
160 .filter((issue) => hasOwnershipMarker(issue.displayDescription))
161 .map((issue) => toAttrs(issue, project)),
162 ),
163 Effect.catchTag("NotFound", () => Effect.succeed([])),
164 );
165
166const findOwned = (
167 parent: string,
168 displayDescription: string,
169 displayName: string | undefined,
170) =>
171 cci.listProjectsLocationsIssueModelsIssues({ parent }).pipe(
172 Effect.map((page) =>
173 (page.issues ?? []).find(
174 (issue) =>
175 issue.displayDescription === displayDescription ||
176 (displayName !== undefined && issue.displayName === displayName),
177 ),
178 ),
179 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
180 );
181
182export const IssueModelsIssueProvider = () =>
183 Provider.succeed(IssueModelsIssue, {
184 stables: ["name", "issueId", "parent", "location", "project", "createTime"],
185
186 diff: Effect.fn(function* ({ news, olds, output }) {
187 if (!isResolved(news)) return undefined;
188 const previousParent = olds?.parent ?? output?.parent;
189 if (previousParent !== undefined && news.parent !== previousParent) {
190 return { action: "replace" as const, deleteFirst: false };
191 }
192 return undefined;
193 }),
194
195 read: Effect.fn(function* ({ id, olds, output }) {
196 const env = yield* GcpEnvironment.current;
197 const existing = yield* getByName(output?.name ?? "");
198 if (existing !== undefined) {
199 const attrs = toAttrs(existing, env.project);
200 return (yield* ownedByAlchemy(id, existing.displayDescription))
201 ? attrs
202 : Unowned(attrs);
203 }
204 if (olds?.parent === undefined) return undefined;
205 const ownership = yield* createInternalLabels(id);
206 const displayDescription = encodeOwnership(
207 ownership,
208 olds.displayDescription,
209 );
210 const found = yield* findOwned(
211 olds.parent,
212 displayDescription,
213 olds.displayName,
214 );
215 if (found === undefined) return undefined;
216 const attrs = toAttrs(found, env.project);
217 return (yield* ownedByAlchemy(id, found.displayDescription))
218 ? attrs
219 : Unowned(attrs);
220 }),
221
222 list: () =>
223 Effect.gen(function* () {
224 const env = yield* GcpEnvironment.current;
225 const models = yield* listModels(
226 locationParent(env.project, env.region),
227 );
228 const pages = yield* Effect.forEach(
229 models,
230 (parent) => listAtParent(parent, env.project),
231 { concurrency: 4 },
232 );
233 return pages.flat();
234 }),
235
236 reconcile: Effect.fn(function* ({ id, news, output }) {
237 const env = yield* GcpEnvironment.current;
238 const ownership = yield* createInternalLabels(id);
239 const displayDescription = encodeOwnership(
240 ownership,
241 news.displayDescription,
242 );
243 const displayName =
244 news.displayName ??
245 encodeOwnershipLine(ownership, news.displayDescription);
246
247 let current = yield* getByName(output?.name ?? "");
248 if (current === undefined) {
249 current = yield* findOwned(
250 news.parent,
251 displayDescription,
252 displayName,
253 );
254 }
255
256 if (current === undefined) {
257 const created = yield* cci
258 .createProjectsLocationsIssueModelsIssues({
259 parent: news.parent,
260 body: {
261 displayName,
262 displayDescription,
263 },
264 })
265 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
266 if (created !== undefined) {
267 const done = yield* waitForOperation(created);
268 const createdName = resourceNameFromOperation(done) ?? "";
269 if (createdName.length > 0) {
270 current = yield* waitUntilExists(createdName);
271 }
272 }
273 if (current === undefined) {
274 current = yield* findOwned(
275 news.parent,
276 displayDescription,
277 displayName,
278 );
279 }
280 }
281
282 if (current === undefined) {
283 return yield* new IssueModelsIssueNotResolved({
284 name: output?.name ?? `${news.parent}/issues/-`,
285 });
286 }
287
288 const name = current.name ?? "";
289 const displayChanged = (current.displayName ?? "") !== displayName;
290 const descriptionChanged =
291 (current.displayDescription ?? "") !== displayDescription;
292
293 if (displayChanged || descriptionChanged) {
294 current = yield* cci.patchProjectsLocationsIssueModelsIssues({
295 name,
296 updateMask: [
297 displayChanged ? "display_name" : undefined,
298 descriptionChanged ? "display_description" : undefined,
299 ]
300 .filter((field): field is string => field !== undefined)
301 .join(","),
302 body: {
303 name,
304 displayName,
305 displayDescription,
306 },
307 });
308 }
309
310 return toAttrs(current, env.project);
311 }),
312
313 delete: Effect.fn(function* ({ output }) {
314 yield* cci
315 .deleteProjectsLocationsIssueModelsIssues({ name: output.name })
316 .pipe(Effect.catchTag("NotFound", () => Effect.void));
317 }),
318 });