1import * as smsvoice from "@distilled.cloud/aws/pinpoint-sms-voice-v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
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";
10import { retrySmsVoiceThrottled } from "./internal.ts";
11
12export interface CloudWatchLogsDestinationProps {
13 /**
14 * ARN of an IAM role that End User Messaging SMS assumes to write to
15 * the log group. The role must trust `sms-voice.amazonaws.com`.
16 */
17 iamRoleArn: string;
18 /**
19 * ARN of the CloudWatch Logs log group that receives the events.
20 */
21 logGroupArn: string;
22}
23
24export interface KinesisFirehoseDestinationProps {
25 /**
26 * ARN of an IAM role that End User Messaging SMS assumes to put
27 * records on the delivery stream. The role must trust
28 * `sms-voice.amazonaws.com`.
29 */
30 iamRoleArn: string;
31 /**
32 * ARN of the Kinesis Data Firehose delivery stream that receives the
33 * events.
34 */
35 deliveryStreamArn: string;
36}
37
38export interface SnsDestinationProps {
39 /**
40 * ARN of the SNS topic that receives the events.
41 */
42 topicArn: string;
43}
44
45export interface EventDestinationProps {
46 /**
47 * Name of the configuration set the event destination is attached to.
48 * Changing it replaces the event destination.
49 */
50 configurationSetName: string;
51 /**
52 * Name of the event destination (`[A-Za-z0-9_-]+`, 1-64 characters).
53 * Changing the name replaces the event destination.
54 * @default ${app}-${stage}-${id}
55 */
56 eventDestinationName?: string;
57 /**
58 * The message event types the destination receives, e.g. `ALL`,
59 * `TEXT_ALL`, `TEXT_SENT`, `TEXT_DELIVERED`, `VOICE_ALL`.
60 */
61 matchingEventTypes: string[];
62 /**
63 * Whether the event destination is enabled. Disabled destinations
64 * receive no events.
65 * @default true
66 */
67 enabled?: boolean;
68 /**
69 * Deliver events to a CloudWatch Logs log group. Exactly one of
70 * `cloudWatchLogsDestination`, `kinesisFirehoseDestination`, or
71 * `snsDestination` must be set.
72 */
73 cloudWatchLogsDestination?: CloudWatchLogsDestinationProps;
74 /**
75 * Deliver events to a Kinesis Data Firehose delivery stream. Exactly
76 * one of `cloudWatchLogsDestination`, `kinesisFirehoseDestination`, or
77 * `snsDestination` must be set.
78 */
79 kinesisFirehoseDestination?: KinesisFirehoseDestinationProps;
80 /**
81 * Deliver events to an SNS topic. Exactly one of
82 * `cloudWatchLogsDestination`, `kinesisFirehoseDestination`, or
83 * `snsDestination` must be set.
84 */
85 snsDestination?: SnsDestinationProps;
86}
87
88export interface EventDestination extends Resource<
89 "AWS.PinpointSMSVoiceV2.EventDestination",
90 EventDestinationProps,
91 {
92 /**
93 * Configuration set the destination belongs to.
94 */
95 configurationSetName: string;
96 /**
97 * ARN of the owning configuration set.
98 */
99 configurationSetArn: string;
100 /**
101 * Name of the event destination.
102 */
103 eventDestinationName: string;
104 /**
105 * Whether event delivery is enabled.
106 */
107 enabled: boolean;
108 /**
109 * Event types routed to the destination (e.g. `ALL`, `TEXT_DELIVERED`).
110 */
111 matchingEventTypes: string[];
112 },
113 never,
114 Providers
115> {}
116
117/**
118 * An AWS End User Messaging SMS (Pinpoint SMS Voice v2) event
119 * destination — routes message events (sends, deliveries, failures) from
120 * a `ConfigurationSet` to CloudWatch Logs, Kinesis Data Firehose, or SNS.
121 *
122 * Each configuration set holds up to five event destinations; each event
123 * destination references exactly one delivery target.
124 * ### Creating Event Destinations
125 * **Example:** Stream all events to SNS
126 * ```typescript
127 * import * as PinpointSMSVoiceV2 from "alchemy/AWS/PinpointSMSVoiceV2";
128 * import * as SNS from "alchemy/AWS/SNS";
129 *
130 * const configSet = yield* PinpointSMSVoiceV2.ConfigurationSet("Messaging");
131 * const events = yield* SNS.Topic("SmsEvents");
132 * const destination = yield* PinpointSMSVoiceV2.EventDestination("Events", {
133 * configurationSetName: configSet.configurationSetName,
134 * matchingEventTypes: ["ALL"],
135 * snsDestination: { topicArn: events.topicArn },
136 * });
137 * ```
138 *
139 * **Example:** CloudWatch Logs destination
140 * ```typescript
141 * const destination = yield* PinpointSMSVoiceV2.EventDestination("Logs", {
142 * configurationSetName: configSet.configurationSetName,
143 * matchingEventTypes: ["TEXT_ALL"],
144 * cloudWatchLogsDestination: {
145 * iamRoleArn: role.roleArn,
146 * logGroupArn: logGroup.logGroupArn,
147 * },
148 * });
149 * ```
150 *
151 * **Example:** Disable a destination without deleting it
152 * ```typescript
153 * const destination = yield* PinpointSMSVoiceV2.EventDestination("Events", {
154 * configurationSetName: configSet.configurationSetName,
155 * matchingEventTypes: ["ALL"],
156 * snsDestination: { topicArn: events.topicArn },
157 * enabled: false,
158 * });
159 * ```
160 *
161 * @resource
162 */
163export const EventDestination = Resource<EventDestination>(
164 "AWS.PinpointSMSVoiceV2.EventDestination",
165);
166
167/**
168 * Raised when an event destination cannot be observed immediately after
169 * it was created — the create call succeeded (or raced a peer) but the
170 * follow-up describe found nothing.
171 */
172export class SmsVoiceEventDestinationMissing extends Data.TaggedError(
173 "SmsVoiceEventDestinationMissing",
174)<{ message: string }> {}
175
176const sameStringSet = (
177 left: readonly string[] | undefined,
178 right: readonly string[],
179) => {
180 const l = [...(left ?? [])].sort();
181 const r = [...right].sort();
182 return l.length === r.length && l.every((v, i) => v === r[i]);
183};
184
185const toWireDestinations = (props: {
186 cloudWatchLogsDestination?: CloudWatchLogsDestinationProps | undefined;
187 kinesisFirehoseDestination?: KinesisFirehoseDestinationProps | undefined;
188 snsDestination?: SnsDestinationProps | undefined;
189}) => ({
190 CloudWatchLogsDestination: props.cloudWatchLogsDestination
191 ? {
192 IamRoleArn: props.cloudWatchLogsDestination.iamRoleArn,
193 LogGroupArn: props.cloudWatchLogsDestination.logGroupArn,
194 }
195 : undefined,
196 KinesisFirehoseDestination: props.kinesisFirehoseDestination
197 ? {
198 IamRoleArn: props.kinesisFirehoseDestination.iamRoleArn,
199 DeliveryStreamArn: props.kinesisFirehoseDestination.deliveryStreamArn,
200 }
201 : undefined,
202 SnsDestination: props.snsDestination
203 ? { TopicArn: props.snsDestination.topicArn }
204 : undefined,
205});
206
207export const EventDestinationProvider = () =>
208 Provider.effect(
209 EventDestination,
210 Effect.gen(function* () {
211 const toName = (
212 id: string,
213 props: { eventDestinationName?: string | undefined },
214 ) =>
215 props.eventDestinationName
216 ? Effect.succeed(props.eventDestinationName)
217 : createPhysicalName({ id, maxLength: 64 });
218
219 /** Observe the parent configuration set (undefined when gone). */
220 const getConfigurationSet = Effect.fn(function* (name: string) {
221 const result = yield* smsvoice
222 .describeConfigurationSets({ ConfigurationSetNames: [name] })
223 .pipe(
224 retrySmsVoiceThrottled,
225 Effect.catchTag("ResourceNotFoundException", () =>
226 Effect.succeed(undefined),
227 ),
228 );
229 return result?.ConfigurationSets?.find(
230 (cs) => cs.ConfigurationSetName === name,
231 );
232 });
233
234 return {
235 stables: [
236 "configurationSetName",
237 "configurationSetArn",
238 "eventDestinationName",
239 ],
240
241 read: Effect.fn(function* ({ id, olds, output }) {
242 const configurationSetName =
243 output?.configurationSetName ?? olds?.configurationSetName;
244 if (configurationSetName === undefined) return undefined;
245 const eventDestinationName =
246 output?.eventDestinationName ?? (yield* toName(id, olds ?? {}));
247 const set = yield* getConfigurationSet(configurationSetName);
248 const observed = set?.EventDestinations?.find(
249 (ed) => ed.EventDestinationName === eventDestinationName,
250 );
251 if (set === undefined || observed === undefined) return undefined;
252 // Event destinations are not taggable, so there is no ownership
253 // marker to check — existence is the whole story.
254 return {
255 configurationSetName: set.ConfigurationSetName,
256 configurationSetArn: set.ConfigurationSetArn,
257 eventDestinationName: observed.EventDestinationName,
258 enabled: observed.Enabled,
259 matchingEventTypes: [...observed.MatchingEventTypes],
260 };
261 }),
262
263 diff: Effect.fn(function* ({ id, news, olds }) {
264 if (!isResolved(news)) return undefined;
265 if (
266 olds?.configurationSetName !== undefined &&
267 olds.configurationSetName !== news.configurationSetName
268 ) {
269 return { action: "replace" } as const;
270 }
271 const oldName = yield* toName(id, olds ?? {});
272 const newName = yield* toName(id, news);
273 if (oldName !== newName) return { action: "replace" } as const;
274 }),
275
276 reconcile: Effect.fn(function* ({ id, news, output, session }) {
277 const configurationSetName = news.configurationSetName;
278 const eventDestinationName =
279 output?.eventDestinationName ?? (yield* toName(id, news));
280 const desiredEnabled = news.enabled ?? true;
281 const desiredDestinations = toWireDestinations(news);
282
283 // 1. Observe — the event destination lives inside its parent
284 // configuration set's describe output.
285 const set = yield* getConfigurationSet(configurationSetName);
286 let observed = set?.EventDestinations?.find(
287 (ed) => ed.EventDestinationName === eventDestinationName,
288 );
289
290 // 2. Ensure — create if missing; a ConflictException is a race
291 // with a peer reconciler, so fall through to the sync step.
292 if (observed === undefined) {
293 yield* smsvoice
294 .createEventDestination({
295 ConfigurationSetName: configurationSetName,
296 EventDestinationName: eventDestinationName,
297 MatchingEventTypes: news.matchingEventTypes,
298 ...desiredDestinations,
299 })
300 .pipe(
301 retrySmsVoiceThrottled,
302 Effect.catchTag("ConflictException", () => Effect.void),
303 Effect.asVoid,
304 );
305 }
306
307 // Re-observe so the sync step diffs actual cloud state (and the
308 // returned attributes carry the parent's ARN).
309 const finalSet = yield* getConfigurationSet(configurationSetName);
310 observed = finalSet?.EventDestinations?.find(
311 (ed) => ed.EventDestinationName === eventDestinationName,
312 );
313 if (finalSet === undefined || observed === undefined) {
314 return yield* Effect.fail(
315 new SmsVoiceEventDestinationMissing({
316 message: `event destination '${eventDestinationName}' on configuration set '${configurationSetName}' not observable after create`,
317 }),
318 );
319 }
320
321 // 3. Sync — apply a single update when any mutable aspect
322 // (enabled, event types, delivery target) drifted.
323 const drifted =
324 observed.Enabled !== desiredEnabled ||
325 !sameStringSet(
326 observed.MatchingEventTypes,
327 news.matchingEventTypes,
328 ) ||
329 JSON.stringify({
330 cw: observed.CloudWatchLogsDestination,
331 kf: observed.KinesisFirehoseDestination,
332 sns: observed.SnsDestination,
333 }) !==
334 JSON.stringify({
335 cw: desiredDestinations.CloudWatchLogsDestination,
336 kf: desiredDestinations.KinesisFirehoseDestination,
337 sns: desiredDestinations.SnsDestination,
338 });
339 if (drifted) {
340 yield* smsvoice
341 .updateEventDestination({
342 ConfigurationSetName: configurationSetName,
343 EventDestinationName: eventDestinationName,
344 Enabled: desiredEnabled,
345 MatchingEventTypes: news.matchingEventTypes,
346 ...desiredDestinations,
347 })
348 .pipe(retrySmsVoiceThrottled);
349 }
350
351 yield* session.note(
352 `${finalSet.ConfigurationSetArn}/${eventDestinationName}`,
353 );
354 return {
355 configurationSetName: finalSet.ConfigurationSetName,
356 configurationSetArn: finalSet.ConfigurationSetArn,
357 eventDestinationName,
358 enabled: desiredEnabled,
359 matchingEventTypes: [...news.matchingEventTypes],
360 };
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 yield* smsvoice
365 .deleteEventDestination({
366 ConfigurationSetName: output.configurationSetName,
367 EventDestinationName: output.eventDestinationName,
368 })
369 .pipe(
370 retrySmsVoiceThrottled,
371 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
372 Effect.asVoid,
373 );
374 }),
375
376 // Event destinations are enumerable by flattening every
377 // configuration set's embedded destination list.
378 list: () =>
379 smsvoice.describeConfigurationSets.items({}).pipe(
380 Stream.runCollect,
381 Effect.map((chunk) =>
382 Array.from(chunk).flatMap((cs) =>
383 (cs.EventDestinations ?? []).map((ed) => ({
384 configurationSetName: cs.ConfigurationSetName,
385 configurationSetArn: cs.ConfigurationSetArn,
386 eventDestinationName: ed.EventDestinationName,
387 enabled: ed.Enabled,
388 matchingEventTypes: [...ed.MatchingEventTypes],
389 })),
390 ),
391 ),
392 ),
393 };
394 }),
395 );