1import * as notifications from "@distilled.cloud/aws/notifications";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { pinNotificationsRegion } from "./internal.ts";
10
11export interface ChannelAssociationProps {
12 /**
13 * The ARN of the {@link NotificationConfiguration} to deliver from.
14 * Changing the configuration replaces the association.
15 */
16 notificationConfigurationArn: string;
17
18 /**
19 * The ARN of the delivery channel to associate. Supported channels are
20 * email contacts (`AWS.NotificationsContacts.EmailContact`), Amazon Q
21 * Developer in chat applications (AWS Chatbot) channels, and AWS Console
22 * Mobile Application devices. Changing the channel replaces the
23 * association.
24 *
25 * An email contact can be associated while still `inactive` (unverified);
26 * AWS begins delivering to it once the address owner activates it.
27 */
28 channelArn: string;
29}
30
31export interface ChannelAssociation extends Resource<
32 "AWS.Notifications.ChannelAssociation",
33 ChannelAssociationProps,
34 {
35 /** The ARN of the associated delivery channel. */
36 channelArn: string;
37 /** The ARN of the parent notification configuration. */
38 notificationConfigurationArn: string;
39 },
40 never,
41 Providers
42> {}
43
44/**
45 * An AWS User Notifications **channel association** — attaches a delivery
46 * channel (an email contact, an Amazon Q Developer chat channel, or a
47 * Console Mobile Application device) to a
48 * {@link NotificationConfiguration}, so matching events are actually
49 * delivered somewhere beyond the Console notification center.
50 *
51 * The association is existence-only (there is nothing mutable): changing
52 * either ARN replaces it.
53 *
54 * ### Associating a Channel
55 * **Example:** Deliver notifications to an email contact
56 * ```typescript
57 * import * as Notifications from "alchemy/AWS/Notifications";
58 * import * as NotificationsContacts from "alchemy/AWS/NotificationsContacts";
59 *
60 * const config = yield* Notifications.NotificationConfiguration("Alerts", {
61 * description: "Deployment alerts",
62 * });
63 * const contact = yield* NotificationsContacts.EmailContact("OnCall", {
64 * emailAddress: "oncall@example.com",
65 * });
66 * const association = yield* Notifications.ChannelAssociation("OnCallEmail", {
67 * notificationConfigurationArn: config.notificationConfigurationArn,
68 * channelArn: contact.emailContactArn,
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const ChannelAssociation = Resource<ChannelAssociation>(
75 "AWS.Notifications.ChannelAssociation",
76);
77
78export const ChannelAssociationProvider = () =>
79 Provider.effect(
80 ChannelAssociation,
81 Effect.gen(function* () {
82 // The association has no ARN of its own — identity is the
83 // (configuration, channel) pair, observed via ListChannels.
84 const findAssociation = Effect.fn(function* (
85 notificationConfigurationArn: string,
86 channelArn: string,
87 ) {
88 return yield* pinNotificationsRegion(
89 notifications.listChannels
90 .items({ notificationConfigurationArn })
91 .pipe(
92 Stream.filter((arn) => arn === channelArn),
93 Stream.runHead,
94 Effect.map(Option.getOrUndefined),
95 // The parent configuration may already be gone.
96 Effect.catchTag("ResourceNotFoundException", () =>
97 Effect.succeed(undefined),
98 ),
99 ),
100 );
101 });
102
103 return ChannelAssociation.Provider.of({
104 stables: ["channelArn", "notificationConfigurationArn"],
105
106 // Sub-resource keyed by its parent notification configuration —
107 // there is no account-wide enumeration without a parent ARN.
108 list: () => Effect.succeed([]),
109
110 read: Effect.fn(function* ({ olds, output }) {
111 const configArn =
112 output?.notificationConfigurationArn ??
113 olds?.notificationConfigurationArn;
114 const channelArn = output?.channelArn ?? olds?.channelArn;
115 if (configArn === undefined || channelArn === undefined) {
116 return undefined;
117 }
118 const found = yield* findAssociation(configArn, channelArn);
119 // Associations are not taggable — identity is the pair itself.
120 return found !== undefined
121 ? { channelArn, notificationConfigurationArn: configArn }
122 : undefined;
123 }),
124
125 diff: Effect.fn(function* ({ news, olds }) {
126 if (!isResolved(news)) return undefined;
127 if (
128 news.notificationConfigurationArn !==
129 olds.notificationConfigurationArn ||
130 news.channelArn !== olds.channelArn
131 ) {
132 return { action: "replace" } as const;
133 }
134 return undefined;
135 }),
136
137 reconcile: Effect.fn(function* ({ news, session }) {
138 // OBSERVE — existence-only: is the channel already associated?
139 const existing = yield* findAssociation(
140 news.notificationConfigurationArn,
141 news.channelArn,
142 );
143
144 // ENSURE — associate when missing; a ConflictException means a
145 // peer associated the same pair concurrently.
146 if (existing === undefined) {
147 yield* pinNotificationsRegion(
148 notifications
149 .associateChannel({
150 arn: news.channelArn,
151 notificationConfigurationArn:
152 news.notificationConfigurationArn,
153 })
154 .pipe(Effect.catchTag("ConflictException", () => Effect.void)),
155 );
156 }
157
158 // No SYNC step — the association has no mutable aspects.
159 yield* session.note(news.channelArn);
160 return {
161 channelArn: news.channelArn,
162 notificationConfigurationArn: news.notificationConfigurationArn,
163 };
164 }),
165
166 delete: Effect.fn(function* ({ output }) {
167 // Idempotent — a missing association (or an already-deleted
168 // parent configuration) is not an error.
169 yield* pinNotificationsRegion(
170 notifications
171 .disassociateChannel({
172 arn: output.channelArn,
173 notificationConfigurationArn:
174 output.notificationConfigurationArn,
175 })
176 .pipe(
177 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
178 ),
179 );
180 }),
181 });
182 }),
183 );