1import * as essentialcontacts from "@distilled.cloud/gcp/essentialcontacts_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12} from "../Labels.ts";
13
14export type NotificationCategory =
15 essentialcontacts.GoogleCloudEssentialcontactsV1ContactNotificationCategorySubscriptionsItemEnum;
16
17export type DistilledContact =
18 essentialcontacts.GoogleCloudEssentialcontactsV1Contact;
19
20export const DEFAULT_LANGUAGE_TAG = "en-US";
21export const DEFAULT_CATEGORIES: NotificationCategory[] = ["ALL"];
22export const UPDATE_MASK = "notificationCategorySubscriptions,languageTag";
23
24const OWNERSHIP_TOKEN = "alc";
25const MAX_LOCAL_PART = 64;
26
27export class FolderRequired extends Data.TaggedError(
28 "GCP.EssentialContacts.FolderRequired",
29)<{
30 project: string;
31}> {}
32
33export class OrganizationRequired extends Data.TaggedError(
34 "GCP.EssentialContacts.OrganizationRequired",
35)<{
36 project: string;
37}> {}
38
39export const lastSegment = (value: string) => {
40 const trimmed = value.replace(/\/+$/, "");
41 const parts = trimmed.split("/");
42 return parts[parts.length - 1] || trimmed;
43};
44
45export const parentOfName = (name: string) => {
46 const parts = name.split("/").filter((part) => part.length > 0);
47 const index = parts.lastIndexOf("contacts");
48 if (index <= 0) return "";
49 return parts.slice(0, index).join("/");
50};
51
52export const contactIdOf = (name: string) => lastSegment(name);
53
54export const projectParent = (project: string) =>
55 project.startsWith("projects/") ? project : `projects/${project}`;
56
57export const folderParent = (folderId: string) =>
58 folderId.startsWith("folders/")
59 ? folderId
60 : `folders/${lastSegment(folderId)}`;
61
62export const organizationParent = (value: string) =>
63 value.startsWith("organizations/")
64 ? value
65 : `organizations/${lastSegment(value)}`;
66
67export const sameText = (left: string | undefined, right: string | undefined) =>
68 (left ?? "") === (right ?? "");
69
70export const normalizeEmail = (email: string | undefined) =>
71 (email ?? "").trim().toLowerCase();
72
73export const sortedCategories = (
74 values: readonly string[] | undefined,
75): NotificationCategory[] =>
76 [...(values ?? [])]
77 .map((value) => value as NotificationCategory)
78 .slice()
79 .sort();
80
81export const sameCategories = (
82 left: readonly string[] | undefined,
83 right: readonly string[] | undefined,
84) =>
85 JSON.stringify(sortedCategories(left)) ===
86 JSON.stringify(sortedCategories(right));
87
88const markerOf = (stack: string, stage: string, id: string) =>
89 `${OWNERSHIP_TOKEN}.${stack}.${stage}.${id}`;
90
91const fitMarker = (labels: Record<string, string>, maxLength: number) => {
92 let stack = labels[alchemyLabelKeys.stack] ?? "x";
93 let stage = labels[alchemyLabelKeys.stage] ?? "x";
94 let id = labels[alchemyLabelKeys.id] ?? "x";
95 let marker = markerOf(stack, stage, id);
96 while (
97 marker.length > maxLength &&
98 (stack.length > 1 || stage.length > 1 || id.length > 1)
99 ) {
100 if (id.length >= stack.length && id.length >= stage.length) {
101 id = id.slice(0, -1);
102 } else if (stack.length >= stage.length) {
103 stack = stack.slice(0, -1);
104 } else {
105 stage = stage.slice(0, -1);
106 }
107 marker = markerOf(stack, stage, id);
108 }
109 return marker.slice(0, maxLength);
110};
111
112const splitEmail = (email: string) => {
113 const trimmed = email.trim();
114 const at = trimmed.lastIndexOf("@");
115 if (at <= 0) return { local: trimmed, domain: "" };
116 return { local: trimmed.slice(0, at), domain: trimmed.slice(at + 1) };
117};
118
119const stripOwnershipLocal = (local: string) => {
120 const token = `+${OWNERSHIP_TOKEN}.`;
121 const index = local.lastIndexOf(token);
122 return index >= 0 ? local.slice(0, index) : local;
123};
124
125/**
126 * Essential Contacts have no labels or description, so Alchemy stamps
127 * ownership into the email local-part as `+alc.{stack}.{stage}.{id}`.
128 */
129export const encodeEmail = (
130 labels: Record<string, string>,
131 email: string,
132): string => {
133 const { local, domain } = splitEmail(email);
134 if (domain.length === 0) return email.trim();
135 const base = stripOwnershipLocal(local);
136 const reserved = base.length + 1;
137 const marker = fitMarker(labels, Math.max(8, MAX_LOCAL_PART - reserved));
138 const next = `${base}+${marker}`.slice(0, MAX_LOCAL_PART);
139 return `${next}@${domain}`;
140};
141
142export const parseEmail = (
143 email: string | undefined,
144): {
145 labels: Record<string, string>;
146 email: string | undefined;
147} => {
148 if (!email) return { labels: {}, email };
149 const { local, domain } = splitEmail(email);
150 const token = `+${OWNERSHIP_TOKEN}.`;
151 const index = local.lastIndexOf(token);
152 if (index < 0) return { labels: {}, email };
153 const tag = local.slice(index + token.length);
154 const [stack, stage, ...idParts] = tag.split(".");
155 const base = local.slice(0, index);
156 const restored =
157 domain.length > 0 ? `${base}@${domain}` : base.length > 0 ? base : email;
158 return {
159 labels: {
160 [alchemyLabelKeys.stack]: stack ?? "",
161 [alchemyLabelKeys.stage]: stage ?? "",
162 [alchemyLabelKeys.id]: idParts.join("."),
163 },
164 email: restored,
165 };
166};
167
168export const hasOwnershipMarker = (email: string | undefined) =>
169 Object.keys(parseEmail(email).labels).some((key) =>
170 key.startsWith("alchemy-"),
171 );
172
173const prefixMatch = (expected: string, observed: string) =>
174 expected === observed ||
175 expected.startsWith(observed) ||
176 observed.startsWith(expected);
177
178export const ownedByAlchemy = (id: string, email: string | undefined) =>
179 Effect.gen(function* () {
180 const expected = yield* createInternalLabels(id);
181 const { labels } = parseEmail(email);
182 if (!hasOwnershipMarker(email)) return false;
183 const exact = yield* hasAlchemyLabels(id, labels);
184 if (exact) return true;
185 return (
186 prefixMatch(
187 expected[alchemyLabelKeys.stack] ?? "",
188 labels[alchemyLabelKeys.stack] ?? "",
189 ) &&
190 prefixMatch(
191 expected[alchemyLabelKeys.stage] ?? "",
192 labels[alchemyLabelKeys.stage] ?? "",
193 ) &&
194 prefixMatch(
195 expected[alchemyLabelKeys.id] ?? "",
196 labels[alchemyLabelKeys.id] ?? "",
197 )
198 );
199 });
200
201export const ownershipLabels = (id: string) => createInternalLabels(id);
202
203export const toEmail = (
204 id: string,
205 requested: string | undefined,
206 existing: string | undefined,
207) =>
208 Effect.gen(function* () {
209 if (requested !== undefined && requested.length > 0) return requested;
210 if (existing !== undefined && existing.length > 0) return existing;
211 const generated = yield* createPhysicalName({
212 id,
213 maxLength: 32,
214 lowercase: true,
215 });
216 const local = generated.replace(/[^a-z0-9]/g, "").slice(0, 32) || "alchemy";
217 return `${local}@example.com`;
218 });
219
220export const toCreateBody = (args: {
221 email: string;
222 languageTag: string;
223 notificationCategorySubscriptions: NotificationCategory[];
224}): DistilledContact => ({
225 email: args.email,
226 languageTag: args.languageTag,
227 notificationCategorySubscriptions: args.notificationCategorySubscriptions,
228});
229
230export type ContactCoreAttrs = {
231 name: string;
232 contactId: string;
233 parent: string;
234 project: string;
235 email: string;
236 languageTag: string;
237 notificationCategorySubscriptions: NotificationCategory[];
238 validationState: string | undefined;
239 validateTime: string | undefined;
240};
241
242export const toCoreAttrs = (
243 contact: DistilledContact,
244 parent: string,
245 project: string,
246): ContactCoreAttrs => {
247 const name = contact.name ?? "";
248 const parsed = parseEmail(contact.email);
249 return {
250 name,
251 contactId: contactIdOf(name),
252 parent: parentOfName(name) || parent,
253 project,
254 email: parsed.email ?? contact.email ?? "",
255 languageTag: contact.languageTag ?? DEFAULT_LANGUAGE_TAG,
256 notificationCategorySubscriptions: sortedCategories(
257 contact.notificationCategorySubscriptions,
258 ),
259 validationState: contact.validationState,
260 validateTime: contact.validateTime,
261 };
262};
263
264export const findOwnedContact = (
265 contacts: readonly DistilledContact[],
266 id: string,
267 name?: string,
268 email?: string,
269) =>
270 Effect.gen(function* () {
271 if (name) {
272 const exact = contacts.find((contact) => contact.name === name);
273 if (exact !== undefined) return exact;
274 }
275 const desired = email
276 ? normalizeEmail(parseEmail(email).email ?? email)
277 : undefined;
278 let owned: DistilledContact | undefined;
279 for (const contact of contacts) {
280 if (!(yield* ownedByAlchemy(id, contact.email))) continue;
281 const observed = normalizeEmail(
282 parseEmail(contact.email).email ?? contact.email ?? "",
283 );
284 if (desired !== undefined) {
285 if (observed === desired) return contact;
286 continue;
287 }
288 if (owned === undefined) owned = contact;
289 }
290 return desired !== undefined ? undefined : owned;
291 });
292
293const emptyList = <A>() => Effect.succeed([] as A[]);
294
295export const collectPages = <A, Page, E, R>(
296 pages: Stream.Stream<Page, E, R>,
297 pick: (page: Page) => readonly A[] | undefined,
298) =>
299 pages.pipe(
300 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
301 Stream.runCollect,
302 Effect.map((chunk) => Array.from(chunk)),
303 );
304
305export const listContacts = <E extends { readonly _tag: string }, R>(
306 pages: Stream.Stream<
307 essentialcontacts.GoogleCloudEssentialcontactsV1ListContactsResponse,
308 E,
309 R
310 >,
311) =>
312 collectPages(pages, (page) => page.contacts).pipe(
313 Effect.catchIf(
314 (error): error is E & { readonly _tag: "NotFound" } =>
315 error._tag === "NotFound",
316 () => emptyList<DistilledContact>(),
317 ),
318 );
319
320const parentOfResource = (name: string) =>
321 name.startsWith("projects/")
322 ? resourcemanager.getProjects({ name }).pipe(
323 Effect.map((resource) => resource.parent),
324 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
325 )
326 : name.startsWith("folders/")
327 ? resourcemanager.getFolders({ name }).pipe(
328 Effect.map((folder) => folder.parent),
329 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
330 )
331 : Effect.succeed(undefined);
332
333export const lookupProjectFolderId = (project: string) =>
334 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
335 Effect.map((resource) => {
336 const parent = resource.parent ?? "";
337 return parent.startsWith("folders/") ? lastSegment(parent) : undefined;
338 }),
339 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
340 );
341
342export const tryResolveFolder = () =>
343 Effect.gen(function* () {
344 const env = yield* GcpEnvironment.current;
345 const folderId = yield* lookupProjectFolderId(env.project);
346 return folderId !== undefined ? folderParent(folderId) : undefined;
347 });
348
349export const resolveFolder = (
350 explicit: string | undefined,
351 existing: string | undefined,
352) =>
353 Effect.gen(function* () {
354 if (explicit !== undefined && explicit.length > 0) {
355 return folderParent(explicit);
356 }
357 if (existing !== undefined && existing.length > 0) {
358 return folderParent(existing);
359 }
360 const resolved = yield* tryResolveFolder();
361 if (resolved === undefined) {
362 const env = yield* GcpEnvironment.current;
363 return yield* new FolderRequired({ project: env.project });
364 }
365 return resolved;
366 });
367
368export const listFolderParents = () =>
369 Effect.gen(function* () {
370 const env = yield* GcpEnvironment.current;
371 const ids = new Set<string>();
372 const fromProject = yield* lookupProjectFolderId(env.project);
373 if (fromProject !== undefined) ids.add(folderParent(fromProject));
374 return [...ids];
375 });
376
377export const tryResolveOrganization = () =>
378 Effect.gen(function* () {
379 const env = yield* GcpEnvironment.current;
380 let current: string | undefined = `projects/${env.project}`;
381 for (let i = 0; i < 8; i++) {
382 if (current === undefined) return undefined;
383 if (current.startsWith("organizations/")) return current;
384 current = yield* parentOfResource(current);
385 }
386 return undefined;
387 });
388
389export const resolveOrganization = (
390 explicit: string | undefined,
391 existing: string | undefined,
392) =>
393 Effect.gen(function* () {
394 if (explicit !== undefined && explicit.length > 0) {
395 return organizationParent(explicit);
396 }
397 if (existing !== undefined && existing.length > 0) {
398 return organizationParent(existing);
399 }
400 const resolved = yield* tryResolveOrganization();
401 if (resolved === undefined) {
402 const env = yield* GcpEnvironment.current;
403 return yield* new OrganizationRequired({ project: env.project });
404 }
405 return resolved;
406 });
407
408export const listOrganizationParents = () =>
409 Effect.gen(function* () {
410 const resolved = yield* tryResolveOrganization();
411 return resolved === undefined ? [] : [resolved];
412 });
413
414export const desiredLanguage = (value: string | undefined) =>
415 value && value.length > 0 ? value : DEFAULT_LANGUAGE_TAG;
416
417export const desiredCategories = (
418 values: readonly NotificationCategory[] | undefined,
419) =>
420 values !== undefined && values.length > 0
421 ? sortedCategories(values)
422 : DEFAULT_CATEGORIES;