1import * as contacts from "@distilled.cloud/aws/ssm-contacts";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11/** How Incident Manager engages the contact through this channel. */
12export type ContactChannelType = "SMS" | "VOICE" | "EMAIL";
13
14export interface ContactChannelProps {
15 /**
16 * The ARN of the contact this channel belongs to. Changing it replaces the
17 * channel.
18 */
19 contactId: string;
20
21 /**
22 * The name of the contact channel.
23 *
24 * @default ${app}-${id}-${stage}-${suffix}
25 */
26 name?: string;
27
28 /**
29 * The channel type: `SMS`, `VOICE`, or `EMAIL`. Changing it replaces the
30 * channel.
31 */
32 type: ContactChannelType;
33
34 /**
35 * The address of the channel: an E.164 phone number (`+15551234567`) for
36 * `SMS`/`VOICE`, or an email address for `EMAIL`.
37 */
38 deliveryAddress: contacts.ContactChannelAddress;
39
40 /**
41 * When `true`, the channel is created without sending an activation code —
42 * the contact's device does not receive a message. Activate later with
43 * `sendActivationCode` + `activateContactChannel`.
44 *
45 * @default false (AWS sends an activation code on create)
46 */
47 deferActivation?: boolean;
48}
49
50/** @resource */
51export interface ContactChannel extends Resource<
52 "AWS.SSMContacts.ContactChannel",
53 ContactChannelProps,
54 {
55 /** ARN of the contact channel. */
56 contactChannelArn: string;
57 /** ARN of the owning contact. */
58 contactArn: string;
59 /** Name of the channel. */
60 name: string;
61 /** The channel type. */
62 type: string;
63 /** `ACTIVATED` or `NOT_ACTIVATED`. */
64 activationStatus: string | undefined;
65 },
66 never,
67 Providers
68> {}
69
70/**
71 * An Incident Manager contact channel — the method (SMS, voice, or email)
72 * that Incident Manager uses to engage a contact during an incident.
73 *
74 * ### Creating Contact Channels
75 * **Example:** Email channel without activation
76 * ```typescript
77 * const email = yield* SSMContacts.ContactChannel("Email", {
78 * contactId: oncall.contactArn,
79 * type: "EMAIL",
80 * deliveryAddress: { SimpleAddress: "oncall@example.com" },
81 * deferActivation: true,
82 * });
83 * ```
84 *
85 * **Example:** SMS channel
86 * ```typescript
87 * const sms = yield* SSMContacts.ContactChannel("Sms", {
88 * contactId: oncall.contactArn,
89 * type: "SMS",
90 * deliveryAddress: { SimpleAddress: "+15551234567" },
91 * });
92 * ```
93 */
94const ContactChannelResource = Resource<ContactChannel>(
95 "AWS.SSMContacts.ContactChannel",
96);
97
98export { ContactChannelResource as ContactChannel };
99
100export const ContactChannelProvider = () =>
101 Provider.effect(
102 ContactChannelResource,
103 Effect.gen(function* () {
104 const createName = Effect.fn(function* (
105 id: string,
106 props: { name?: string },
107 ) {
108 return (
109 props.name ?? (yield* createPhysicalName({ id, maxLength: 200 }))
110 );
111 });
112
113 const getChannel = (arn: string) =>
114 contacts
115 .getContactChannel({ ContactChannelId: arn })
116 .pipe(
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121
122 // A channel's ARN embeds a generated UUID, so without cached output we
123 // fall back to searching the owning contact's channels by name + type.
124 const findChannel = (contactId: string, name: string, type: string) =>
125 contacts.listContactChannels.items({ ContactId: contactId }).pipe(
126 Stream.filter(
127 (channel) =>
128 channel.Name === name && (channel.Type ?? type) === type,
129 ),
130 Stream.take(1),
131 Stream.runCollect,
132 Effect.map((chunk) => Array.from(chunk)[0]),
133 Effect.catchTag("ResourceNotFoundException", () =>
134 Effect.succeed(undefined),
135 ),
136 );
137
138 const buildAttrs = (channel: {
139 ContactChannelArn: string;
140 ContactArn: string;
141 Name: string;
142 Type?: string | undefined;
143 ActivationStatus?: string | undefined;
144 }) => ({
145 contactChannelArn: channel.ContactChannelArn,
146 contactArn: channel.ContactArn,
147 name: channel.Name,
148 type: channel.Type ?? "",
149 activationStatus: channel.ActivationStatus,
150 });
151
152 return ContactChannelResource.Provider.of({
153 stables: ["contactChannelArn", "contactArn", "type"],
154
155 // Sub-resource keyed by its parent contact — enumeration across all
156 // contacts is not meaningful here.
157 list: () => Effect.succeed([]),
158
159 read: Effect.fn(function* ({ id, olds, output }) {
160 if (output !== undefined) {
161 const channel = yield* getChannel(output.contactChannelArn);
162 if (channel === undefined) return undefined;
163 return buildAttrs(channel);
164 }
165 // No cached identifier — recover by searching the parent contact
166 // from prior props. Channels are not taggable, so a match found
167 // this way cannot prove ownership.
168 if (olds?.contactId === undefined) return undefined;
169 const name = yield* createName(id, olds);
170 const found = yield* findChannel(olds.contactId, name, olds.type);
171 return found === undefined ? undefined : Unowned(buildAttrs(found));
172 }),
173
174 // The owning contact and channel type are create-only; name and
175 // delivery address update in place.
176 diff: Effect.fn(function* ({ news, olds }) {
177 if (!isResolved(news)) return undefined;
178 if (olds.contactId !== news.contactId) {
179 return { action: "replace" } as const;
180 }
181 if (olds.type !== news.type) return { action: "replace" } as const;
182 }),
183
184 reconcile: Effect.fn(function* ({ id, news, output, session }) {
185 const name = output?.name ?? (yield* createName(id, news));
186
187 // 1. OBSERVE — by cached ARN first, then by (contact, name, type).
188 let channel = output
189 ? yield* getChannel(output.contactChannelArn)
190 : undefined;
191 if (channel === undefined) {
192 const found = yield* findChannel(news.contactId, name, news.type);
193 if (found !== undefined) {
194 channel = yield* getChannel(found.ContactChannelArn);
195 }
196 }
197
198 // 2. ENSURE — create if missing; tolerate the already-exists race
199 // by re-searching.
200 if (channel === undefined) {
201 yield* session.note(`creating contact channel ${name}`);
202 const arn = yield* contacts
203 .createContactChannel({
204 ContactId: news.contactId,
205 Name: name,
206 Type: news.type,
207 DeliveryAddress: news.deliveryAddress,
208 DeferActivation: news.deferActivation,
209 })
210 .pipe(
211 Effect.map((r) => r.ContactChannelArn),
212 Effect.catchTag("ConflictException", () =>
213 findChannel(news.contactId, name, news.type).pipe(
214 Effect.map((found) => found!.ContactChannelArn),
215 ),
216 ),
217 );
218 channel = (yield* getChannel(arn))!;
219 }
220
221 // 3. SYNC name + delivery address — observed vs desired.
222 const nameDelta = channel.Name !== name;
223 const addressDelta =
224 (channel.DeliveryAddress.SimpleAddress ?? "") !==
225 (news.deliveryAddress.SimpleAddress ?? "");
226 if (nameDelta || addressDelta) {
227 yield* contacts.updateContactChannel({
228 ContactChannelId: channel.ContactChannelArn,
229 Name: name,
230 DeliveryAddress: news.deliveryAddress,
231 });
232 }
233
234 // 4. RETURN fresh attributes.
235 const final = (yield* getChannel(channel.ContactChannelArn))!;
236 yield* session.note(final.ContactChannelArn);
237 return buildAttrs(final);
238 }),
239
240 delete: Effect.fn(function* ({ output }) {
241 yield* contacts
242 .deleteContactChannel({
243 ContactChannelId: output.contactChannelArn,
244 })
245 .pipe(
246 Effect.asVoid,
247 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
248 );
249 }),
250 });
251 }),
252 );