1import * as essentialcontacts from "@distilled.cloud/gcp/essentialcontacts_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 desiredCategories,
12 desiredLanguage,
13 encodeEmail,
14 findOwnedContact,
15 hasOwnershipMarker,
16 listContacts,
17 normalizeEmail,
18 ownedByAlchemy,
19 ownershipLabels,
20 projectParent,
21 sameCategories,
22 sameText,
23 toCoreAttrs,
24 toCreateBody,
25 toEmail,
26 UPDATE_MASK,
27 type NotificationCategory,
28} from "./internal.ts";
29
30export type ContactProps = {
31 /**
32 * Email address that receives Google Cloud notifications. Does not
33 * need to be a Google Account. Immutable — changing it replaces the
34 * contact. If omitted, a unique `example.com` address is generated.
35 * Essential Contacts have no labels field, so Alchemy stamps
36 * ownership into a `+alc.{stack}.{stage}.{id}` plus-tag and strips it
37 * from attributes.
38 */
39 email?: string;
40 /**
41 * Preferred notification language as a BCP-47 / ISO 639-1 tag
42 * (`en-US`, `en-GB`, `ja`, …).
43 * @default "en-US"
44 */
45 languageTag?: string;
46 /**
47 * Notification categories this contact subscribes to.
48 * @default ["ALL"]
49 */
50 notificationCategorySubscriptions?: NotificationCategory[];
51};
52
53export type Contact = Resource<
54 "GCP.EssentialContacts.Contact",
55 ContactProps,
56 {
57 /** Full resource name `projects/{project}/contacts/{contact}`. */
58 name: string;
59 /** Server-assigned contact id (last path segment). */
60 contactId: string;
61 /** Parent resource `projects/{project}`. */
62 parent: string;
63 /** Project id used when the contact was reconciled. */
64 project: string;
65 /** User email with the Alchemy plus-tag stripped. */
66 email: string;
67 /** Preferred notification language. */
68 languageTag: string;
69 /** Subscribed notification categories. */
70 notificationCategorySubscriptions: NotificationCategory[];
71 /** Server-reported validation state. */
72 validationState: string | undefined;
73 /** RFC3339 time the validation state was last updated. */
74 validateTime: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A project-level Google Cloud Essential Contact.
82 *
83 * Essential Contacts have no labels field, so Alchemy stamps ownership
84 * into the email plus-tag for `list` / nuke. Email is identity —
85 * changing it replaces the contact. Language and notification
86 * categories update in place.
87 *
88 * ### Creating a Contact
89 * **Example:** Technical contact
90 * ```typescript
91 * const contact = yield* GCP.EssentialContacts.Contact("Ops", {
92 * email: "ops@example.com",
93 * languageTag: "en-US",
94 * notificationCategorySubscriptions: ["TECHNICAL"],
95 * });
96 * ```
97 *
98 * **Example:** Subscribe to every category
99 * ```typescript
100 * const contact = yield* GCP.EssentialContacts.Contact("Ops", {
101 * email: "ops@example.com",
102 * notificationCategorySubscriptions: ["ALL"],
103 * });
104 * ```
105 *
106 * ### Updating a Contact
107 * **Example:** Change language and categories
108 * ```typescript
109 * const contact = yield* GCP.EssentialContacts.Contact("Ops", {
110 * email: "ops@example.com",
111 * languageTag: "en-GB",
112 * notificationCategorySubscriptions: ["SECURITY", "TECHNICAL"],
113 * });
114 * ```
115 *
116 * @resource
117 * @category EssentialContacts
118 */
119export const Contact = Resource<Contact>("GCP.EssentialContacts.Contact");
120
121export class ContactNotResolved extends Data.TaggedError(
122 "GCP.EssentialContacts.ContactNotResolved",
123)<{
124 name: string;
125}> {}
126
127const getByName = (name: string) =>
128 name.length === 0
129 ? Effect.succeed(undefined)
130 : essentialcontacts
131 .getProjectsContacts({ name })
132 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
133
134const listAt = (parent: string) =>
135 parent.length === 0
136 ? Effect.succeed([])
137 : listContacts(
138 essentialcontacts.listProjectsContacts.pages({
139 parent,
140 pageSize: 200,
141 }),
142 );
143
144export const ContactProvider = () =>
145 Provider.succeed(Contact, {
146 stables: ["name", "contactId", "parent", "project", "email"],
147
148 diff: Effect.fn(function* ({ news, olds, output }) {
149 if (!isResolved(news)) return undefined;
150 const previousEmail = olds?.email ?? output?.email;
151 if (
152 news.email !== undefined &&
153 previousEmail !== undefined &&
154 normalizeEmail(news.email) !== normalizeEmail(previousEmail)
155 ) {
156 return { action: "replace" as const, deleteFirst: false };
157 }
158 return undefined;
159 }),
160
161 read: Effect.fn(function* ({ id, olds, output }) {
162 const env = yield* GcpEnvironment.current;
163 const parent = projectParent(env.project);
164 let existing = yield* getByName(output?.name ?? "");
165 if (existing === undefined) {
166 existing = yield* findOwnedContact(
167 yield* listAt(parent),
168 id,
169 output?.name,
170 olds?.email ?? output?.email,
171 );
172 }
173 if (existing === undefined) return undefined;
174 const attrs = toCoreAttrs(existing, parent, env.project);
175 return (yield* ownedByAlchemy(id, existing.email))
176 ? attrs
177 : Unowned(attrs);
178 }),
179
180 list: () =>
181 Effect.gen(function* () {
182 const env = yield* GcpEnvironment.current;
183 const parent = projectParent(env.project);
184 const contacts = yield* listAt(parent);
185 return contacts
186 .filter((contact) => hasOwnershipMarker(contact.email))
187 .map((contact) => toCoreAttrs(contact, parent, env.project));
188 }),
189
190 reconcile: Effect.fn(function* ({ id, news, output }) {
191 const env = yield* GcpEnvironment.current;
192 const parent = projectParent(env.project);
193 const ownership = yield* ownershipLabels(id);
194 const userEmail = yield* toEmail(id, news.email, output?.email);
195 const stampedEmail = encodeEmail(ownership, userEmail);
196 const languageTag = desiredLanguage(news.languageTag);
197 const categories = desiredCategories(
198 news.notificationCategorySubscriptions,
199 );
200 const body = toCreateBody({
201 email: stampedEmail,
202 languageTag,
203 notificationCategorySubscriptions: categories,
204 });
205
206 let current = yield* getByName(output?.name ?? "");
207 if (current === undefined) {
208 current = yield* findOwnedContact(
209 yield* listAt(parent),
210 id,
211 output?.name,
212 userEmail,
213 );
214 }
215
216 if (current === undefined) {
217 const created = yield* essentialcontacts
218 .createProjectsContacts({ parent, body })
219 .pipe(
220 Effect.catchTag("Conflict", () =>
221 listAt(parent).pipe(
222 Effect.flatMap((contacts) =>
223 findOwnedContact(contacts, id, undefined, userEmail),
224 ),
225 ),
226 ),
227 );
228 current = created ?? undefined;
229 if (current?.name) {
230 current = (yield* getByName(current.name)) ?? current;
231 }
232 }
233
234 if (current === undefined) {
235 return yield* new ContactNotResolved({
236 name: output?.name ?? `${parent}/contacts`,
237 });
238 }
239
240 const languageChanged = !sameText(current.languageTag, languageTag);
241 const categoriesChanged = !sameCategories(
242 current.notificationCategorySubscriptions,
243 categories,
244 );
245 if (languageChanged || categoriesChanged) {
246 current = yield* essentialcontacts.patchProjectsContacts({
247 name: current.name ?? "",
248 updateMask: UPDATE_MASK,
249 body: {
250 languageTag,
251 notificationCategorySubscriptions: categories,
252 },
253 });
254 }
255
256 return toCoreAttrs(current, parent, env.project);
257 }),
258
259 delete: Effect.fn(function* ({ output }) {
260 if (!output.name) return;
261 yield* essentialcontacts
262 .deleteProjectsContacts({ name: output.name })
263 .pipe(Effect.catchTag("NotFound", () => Effect.void));
264 }),
265 });