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 lastSegment,
17 listContacts,
18 listOrganizationParents,
19 normalizeEmail,
20 organizationParent,
21 ownedByAlchemy,
22 ownershipLabels,
23 resolveOrganization,
24 sameCategories,
25 sameText,
26 toCoreAttrs,
27 toCreateBody,
28 toEmail,
29 UPDATE_MASK,
30 type NotificationCategory,
31} from "./internal.ts";
32
33export type OrganizationContactProps = {
34 /**
35 * Parent organization (`organizations/{organization}` or the numeric
36 * id). Defaults to the project's Resource
37 * Manager ancestor organization. Immutable — changing it replaces the
38 * contact.
39 */
40 organization?: string;
41 /**
42 * Email address that receives Google Cloud notifications. Does not
43 * need to be a Google Account. Immutable — changing it replaces the
44 * contact. If omitted, a unique `example.com` address is generated.
45 * Essential Contacts have no labels field, so Alchemy stamps
46 * ownership into a `+alc.{stack}.{stage}.{id}` plus-tag and strips it
47 * from attributes.
48 */
49 email?: string;
50 /**
51 * Preferred notification language as a BCP-47 / ISO 639-1 tag
52 * (`en-US`, `en-GB`, `ja`, …).
53 * @default "en-US"
54 */
55 languageTag?: string;
56 /**
57 * Notification categories this contact subscribes to.
58 * @default ["ALL"]
59 */
60 notificationCategorySubscriptions?: NotificationCategory[];
61};
62
63export type OrganizationContact = Resource<
64 "GCP.EssentialContacts.OrganizationContact",
65 OrganizationContactProps,
66 {
67 /** Full resource name `organizations/{organization}/contacts/{contact}`. */
68 name: string;
69 /** Server-assigned contact id (last path segment). */
70 contactId: string;
71 /** Parent resource `organizations/{organization}`. */
72 parent: string;
73 /** Organization resource name. */
74 organization: string;
75 /** Organization id (last path segment). */
76 organizationId: string;
77 /** Project id used when the contact was reconciled. */
78 project: string;
79 /** User email with the Alchemy plus-tag stripped. */
80 email: string;
81 /** Preferred notification language. */
82 languageTag: string;
83 /** Subscribed notification categories. */
84 notificationCategorySubscriptions: NotificationCategory[];
85 /** Server-reported validation state. */
86 validationState: string | undefined;
87 /** RFC3339 time the validation state was last updated. */
88 validateTime: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * An organization-level Google Cloud Essential Contact.
96 *
97 * Essential Contacts have no labels field, so Alchemy stamps ownership
98 * into the email plus-tag for `list` / nuke. Organization and email are
99 * identity — changing either replaces the contact. Language and
100 * notification categories update in place.
101 *
102 * ### Creating an Organization Contact
103 * **Example:** Technical contact on an organization
104 * ```typescript
105 * const contact = yield* GCP.EssentialContacts.OrganizationContact("Ops", {
106 * organization: "organizations/123",
107 * email: "ops@example.com",
108 * languageTag: "en-US",
109 * notificationCategorySubscriptions: ["TECHNICAL"],
110 * });
111 * ```
112 *
113 * **Example:** Subscribe to every category
114 * ```typescript
115 * const contact = yield* GCP.EssentialContacts.OrganizationContact("Ops", {
116 * organization: "123",
117 * email: "ops@example.com",
118 * notificationCategorySubscriptions: ["ALL"],
119 * });
120 * ```
121 *
122 * ### Updating an Organization Contact
123 * **Example:** Change language and categories
124 * ```typescript
125 * const contact = yield* GCP.EssentialContacts.OrganizationContact("Ops", {
126 * organization: "organizations/123",
127 * email: "ops@example.com",
128 * languageTag: "en-GB",
129 * notificationCategorySubscriptions: ["SECURITY", "TECHNICAL"],
130 * });
131 * ```
132 *
133 * @resource
134 * @category EssentialContacts
135 */
136export const OrganizationContact = Resource<OrganizationContact>(
137 "GCP.EssentialContacts.OrganizationContact",
138);
139
140export class OrganizationContactNotResolved extends Data.TaggedError(
141 "GCP.EssentialContacts.OrganizationContactNotResolved",
142)<{
143 name: string;
144}> {}
145
146const getByName = (name: string) =>
147 name.length === 0
148 ? Effect.succeed(undefined)
149 : essentialcontacts
150 .getOrganizationsContacts({ name })
151 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
152
153const listAt = (parent: string) =>
154 parent.length === 0
155 ? Effect.succeed([])
156 : listContacts(
157 essentialcontacts.listOrganizationsContacts.pages({
158 parent,
159 pageSize: 200,
160 }),
161 );
162
163const toAttrs = (
164 contact: essentialcontacts.GoogleCloudEssentialcontactsV1Contact,
165 parent: string,
166 project: string,
167) => {
168 const core = toCoreAttrs(contact, parent, project);
169 const organization = core.parent || parent;
170 return {
171 ...core,
172 organization,
173 organizationId: lastSegment(organization),
174 };
175};
176
177export const OrganizationContactProvider = () =>
178 Provider.succeed(OrganizationContact, {
179 stables: [
180 "name",
181 "contactId",
182 "parent",
183 "organization",
184 "organizationId",
185 "project",
186 "email",
187 ],
188
189 diff: Effect.fn(function* ({ news, olds, output }) {
190 if (!isResolved(news)) return undefined;
191 const previousEmail = olds?.email ?? output?.email;
192 if (
193 news.email !== undefined &&
194 previousEmail !== undefined &&
195 normalizeEmail(news.email) !== normalizeEmail(previousEmail)
196 ) {
197 return { action: "replace" as const, deleteFirst: false };
198 }
199 const previousOrg = olds?.organization ?? output?.organization;
200 if (
201 news.organization !== undefined &&
202 previousOrg !== undefined &&
203 organizationParent(news.organization) !==
204 organizationParent(previousOrg)
205 ) {
206 return { action: "replace" as const, deleteFirst: false };
207 }
208 return undefined;
209 }),
210
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const env = yield* GcpEnvironment.current;
213 const parent = yield* resolveOrganization(
214 olds?.organization ?? output?.organization,
215 output?.parent,
216 );
217 let existing = yield* getByName(output?.name ?? "");
218 if (existing === undefined) {
219 existing = yield* findOwnedContact(
220 yield* listAt(parent),
221 id,
222 output?.name,
223 olds?.email ?? output?.email,
224 );
225 }
226 if (existing === undefined) return undefined;
227 const attrs = toAttrs(existing, parent, env.project);
228 return (yield* ownedByAlchemy(id, existing.email))
229 ? attrs
230 : Unowned(attrs);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const parents = yield* listOrganizationParents();
237 const pages = yield* Effect.forEach(parents, listAt, {
238 concurrency: 4,
239 });
240 return pages.flatMap((contacts, index) =>
241 contacts
242 .filter((contact) => hasOwnershipMarker(contact.email))
243 .map((contact) =>
244 toAttrs(contact, parents[index] ?? "", env.project),
245 ),
246 );
247 }),
248
249 reconcile: Effect.fn(function* ({ id, news, output }) {
250 const env = yield* GcpEnvironment.current;
251 const parent = yield* resolveOrganization(
252 news.organization ?? output?.organization,
253 output?.parent,
254 );
255 const ownership = yield* ownershipLabels(id);
256 const userEmail = yield* toEmail(id, news.email, output?.email);
257 const stampedEmail = encodeEmail(ownership, userEmail);
258 const languageTag = desiredLanguage(news.languageTag);
259 const categories = desiredCategories(
260 news.notificationCategorySubscriptions,
261 );
262 const body = toCreateBody({
263 email: stampedEmail,
264 languageTag,
265 notificationCategorySubscriptions: categories,
266 });
267
268 let current = yield* getByName(output?.name ?? "");
269 if (current === undefined) {
270 current = yield* findOwnedContact(
271 yield* listAt(parent),
272 id,
273 output?.name,
274 userEmail,
275 );
276 }
277
278 if (current === undefined) {
279 const created = yield* essentialcontacts
280 .createOrganizationsContacts({ parent, body })
281 .pipe(
282 Effect.catchTag("Conflict", () =>
283 listAt(parent).pipe(
284 Effect.flatMap((contacts) =>
285 findOwnedContact(contacts, id, undefined, userEmail),
286 ),
287 ),
288 ),
289 );
290 current = created ?? undefined;
291 if (current?.name) {
292 current = (yield* getByName(current.name)) ?? current;
293 }
294 }
295
296 if (current === undefined) {
297 return yield* new OrganizationContactNotResolved({
298 name: output?.name ?? `${parent}/contacts`,
299 });
300 }
301
302 const languageChanged = !sameText(current.languageTag, languageTag);
303 const categoriesChanged = !sameCategories(
304 current.notificationCategorySubscriptions,
305 categories,
306 );
307 if (languageChanged || categoriesChanged) {
308 current = yield* essentialcontacts.patchOrganizationsContacts({
309 name: current.name ?? "",
310 updateMask: UPDATE_MASK,
311 body: {
312 languageTag,
313 notificationCategorySubscriptions: categories,
314 },
315 });
316 }
317
318 return toAttrs(current, parent, env.project);
319 }),
320
321 delete: Effect.fn(function* ({ output }) {
322 if (!output.name) return;
323 yield* essentialcontacts
324 .deleteOrganizationsContacts({ name: output.name })
325 .pipe(Effect.catchTag("NotFound", () => Effect.void));
326 }),
327 });