1import * as account from "@distilled.cloud/aws/account";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import { unwrapSensitive as unwrap } from "./internal.ts";
8
9/**
10 * The category of alternate contact. Each account has at most one contact of
11 * each type.
12 */
13export type AlternateContactType = "BILLING" | "OPERATIONS" | "SECURITY";
14
15const ALL_CONTACT_TYPES: AlternateContactType[] = [
16 "BILLING",
17 "OPERATIONS",
18 "SECURITY",
19];
20
21export interface AlternateContactProps {
22 /**
23 * Which alternate contact slot to set: `BILLING`, `OPERATIONS`, or
24 * `SECURITY`. Each account holds at most one contact per type; changing the
25 * type replaces the resource.
26 */
27 alternateContactType: AlternateContactType;
28 /** Name of the alternate contact. */
29 name: string;
30 /** Title of the alternate contact. */
31 title: string;
32 /** Email address of the alternate contact. */
33 emailAddress: string;
34 /** Phone number of the alternate contact. */
35 phoneNumber: string;
36 /**
37 * Account ID to operate on. Only usable from an Organizations management or
38 * delegated-admin account with trusted access enabled; omit to target the
39 * calling account.
40 */
41 accountId?: string;
42}
43
44export interface AlternateContact extends Resource<
45 "AWS.Account.AlternateContact",
46 AlternateContactProps,
47 {
48 alternateContactType: AlternateContactType;
49 name: string;
50 title: string;
51 emailAddress: string;
52 phoneNumber: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * An alternate contact for an AWS account. AWS accounts support one alternate
60 * contact for each of the `BILLING`, `OPERATIONS`, and `SECURITY` categories.
61 * These are account-global singletons: setting one overwrites any existing
62 * contact of the same type, and deleting removes it entirely.
63 *
64 * ### Setting an Alternate Contact
65 * **Example:** Operations Contact
66 * ```typescript
67 * const contact = yield* AlternateContact("OpsContact", {
68 * alternateContactType: "OPERATIONS",
69 * name: "Ops Team",
70 * title: "On-Call Engineer",
71 * emailAddress: "ops@example.com",
72 * phoneNumber: "+15555550123",
73 * });
74 * ```
75 *
76 * **Example:** Billing Contact for an Organizations Member Account
77 * ```typescript
78 * const contact = yield* AlternateContact("BillingContact", {
79 * alternateContactType: "BILLING",
80 * name: "Finance",
81 * title: "AP Clerk",
82 * emailAddress: "ap@example.com",
83 * phoneNumber: "+15555550124",
84 * accountId: "123456789012",
85 * });
86 * ```
87 *
88 * @resource
89 */
90export const AlternateContact = Resource<AlternateContact>(
91 "AWS.Account.AlternateContact",
92);
93
94export const AlternateContactProvider = () =>
95 Provider.effect(
96 AlternateContact,
97 Effect.gen(function* () {
98 const observe = (
99 alternateContactType: AlternateContactType,
100 accountId: string | undefined,
101 ) =>
102 account
103 .getAlternateContact({
104 AlternateContactType: alternateContactType,
105 AccountId: accountId,
106 })
107 .pipe(
108 Effect.map((r) => r.AlternateContact),
109 Effect.catchTag("ResourceNotFoundException", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113
114 return {
115 // Account-global singleton setting: nuke must not wipe real account
116 // contacts it never created.
117 nuke: { skip: true },
118 stables: [],
119 // Changing the contact type targets a different account-global slot, so
120 // it must replace rather than mutate the old slot in place.
121 diff: Effect.fn(function* ({ olds, news }) {
122 if (!isResolved(news)) return;
123 if (
124 (olds?.alternateContactType ?? news.alternateContactType) !==
125 news.alternateContactType
126 ) {
127 return { action: "replace" } as const;
128 }
129 }),
130 read: Effect.fn(function* ({ olds, output }) {
131 const type =
132 output?.alternateContactType ?? olds?.alternateContactType;
133 if (!type) return undefined;
134 const accountId = olds?.accountId;
135 const contact = yield* observe(type, accountId);
136 if (!contact) return undefined;
137 return {
138 alternateContactType: type,
139 name: unwrap(contact.Name) ?? output?.name ?? "",
140 title: unwrap(contact.Title) ?? output?.title ?? "",
141 emailAddress:
142 unwrap(contact.EmailAddress) ?? output?.emailAddress ?? "",
143 phoneNumber:
144 unwrap(contact.PhoneNumber) ?? output?.phoneNumber ?? "",
145 };
146 }),
147 reconcile: Effect.fn(function* ({ news, session }) {
148 yield* account.putAlternateContact({
149 AlternateContactType: news.alternateContactType,
150 Name: news.name,
151 Title: news.title,
152 EmailAddress: news.emailAddress,
153 PhoneNumber: news.phoneNumber,
154 AccountId: news.accountId,
155 });
156 yield* session.note(
157 `${news.alternateContactType}:${news.emailAddress}`,
158 );
159 return {
160 alternateContactType: news.alternateContactType,
161 name: news.name,
162 title: news.title,
163 emailAddress: news.emailAddress,
164 phoneNumber: news.phoneNumber,
165 };
166 }),
167 // Account-global singleton: enumerate each of the three contact slots
168 // and return whichever ones exist.
169 list: () =>
170 Effect.gen(function* () {
171 const contacts = yield* Effect.forEach(
172 ALL_CONTACT_TYPES,
173 (type) =>
174 observe(type, undefined).pipe(
175 Effect.map((contact) =>
176 contact
177 ? {
178 alternateContactType: type,
179 name: unwrap(contact.Name) ?? "",
180 title: unwrap(contact.Title) ?? "",
181 emailAddress: unwrap(contact.EmailAddress) ?? "",
182 phoneNumber: unwrap(contact.PhoneNumber) ?? "",
183 }
184 : undefined,
185 ),
186 ),
187 { concurrency: 3 },
188 );
189 return contacts.filter((c) => c !== undefined);
190 }),
191 delete: Effect.fn(function* ({ output, olds }) {
192 yield* account
193 .deleteAlternateContact({
194 AlternateContactType: output.alternateContactType,
195 AccountId: olds.accountId,
196 })
197 .pipe(
198 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
199 );
200 }),
201 };
202 }),
203 );