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 { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 readSmsVoiceTags,
14 retrySmsVoiceThrottled,
15 syncSmsVoiceTags,
16 toTagList,
17} from "./internal.ts";
18
19export interface OptOutListProps {
20 /**
21 * Name of the opt-out list (`[A-Za-z0-9_-]+`, 1-64 characters).
22 * Changing the name replaces the opt-out list.
23 * @default ${app}-${stage}-${id}
24 */
25 optOutListName?: string;
26 /**
27 * Tags to apply to the opt-out list. Merged with internal Alchemy tags.
28 */
29 tags?: Record<string, string>;
30}
31
32export interface OptOutList extends Resource<
33 "AWS.PinpointSMSVoiceV2.OptOutList",
34 OptOutListProps,
35 {
36 /**
37 * Name of the opt-out list.
38 */
39 optOutListName: string;
40 /**
41 * ARN of the opt-out list.
42 */
43 optOutListArn: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * An AWS End User Messaging SMS (Pinpoint SMS Voice v2) opt-out list — a
51 * list of destination phone numbers that opted out of receiving your SMS
52 * or voice messages.
53 *
54 * When an end user replies with a supported opt-out keyword (STOP,
55 * CANCEL, OPTOUT, ...), their number is added to the list automatically
56 * and further messages to it are suppressed.
57 * ### Creating Opt-Out Lists
58 * **Example:** Basic Opt-Out List
59 * ```typescript
60 * import * as PinpointSMSVoiceV2 from "alchemy/AWS/PinpointSMSVoiceV2";
61 *
62 * const optOuts = yield* PinpointSMSVoiceV2.OptOutList("OptOuts");
63 * ```
64 *
65 * **Example:** Named Opt-Out List with Tags
66 * ```typescript
67 * const optOuts = yield* PinpointSMSVoiceV2.OptOutList("OptOuts", {
68 * optOutListName: "marketing-opt-outs",
69 * tags: { team: "growth" },
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const OptOutList = Resource<OptOutList>(
76 "AWS.PinpointSMSVoiceV2.OptOutList",
77);
78
79/**
80 * Raised when an opt-out list cannot be observed immediately after it
81 * was created — the create call succeeded (or raced a peer) but the
82 * follow-up describe found nothing.
83 */
84export class SmsVoiceOptOutListMissing extends Data.TaggedError(
85 "SmsVoiceOptOutListMissing",
86)<{ message: string }> {}
87
88export const OptOutListProvider = () =>
89 Provider.effect(
90 OptOutList,
91 Effect.gen(function* () {
92 const toName = (id: string, props: OptOutListProps) =>
93 props.optOutListName
94 ? Effect.succeed(props.optOutListName)
95 : createPhysicalName({ id, maxLength: 64 });
96
97 /**
98 * Observe an opt-out list by name. `DescribeOptOutLists` raises a
99 * typed `ResourceNotFoundException` for an unknown name.
100 */
101 const getByName = Effect.fn(function* (name: string) {
102 const result = yield* smsvoice
103 .describeOptOutLists({ OptOutListNames: [name] })
104 .pipe(
105 retrySmsVoiceThrottled,
106 Effect.catchTag("ResourceNotFoundException", () =>
107 Effect.succeed(undefined),
108 ),
109 );
110 return result?.OptOutLists?.find((l) => l.OptOutListName === name);
111 });
112
113 return {
114 stables: ["optOutListName", "optOutListArn"],
115
116 read: Effect.fn(function* ({ id, olds, output }) {
117 const name =
118 output?.optOutListName ?? (yield* toName(id, olds ?? {}));
119 const observed = yield* getByName(name);
120 if (observed === undefined) return undefined;
121 const attrs = {
122 optOutListName: observed.OptOutListName,
123 optOutListArn: observed.OptOutListArn,
124 };
125 const tags = yield* readSmsVoiceTags(observed.OptOutListArn);
126 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
127 }),
128
129 diff: Effect.fn(function* ({ id, news, olds }) {
130 if (!isResolved(news)) return undefined;
131 const oldName = yield* toName(id, olds ?? {});
132 const newName = yield* toName(id, news);
133 if (oldName !== newName) return { action: "replace" } as const;
134 }),
135
136 reconcile: Effect.fn(function* ({ id, news, output, session }) {
137 const name = output?.optOutListName ?? (yield* toName(id, news));
138 const internalTags = yield* createInternalTags(id);
139 const desiredTags = { ...news.tags, ...internalTags };
140
141 // 1. Observe — cloud state is authoritative.
142 let observed = yield* getByName(name);
143
144 // 2. Ensure — create if missing; a ConflictException is a race
145 // with a peer reconciler, so fall through to re-observe.
146 if (observed === undefined) {
147 yield* smsvoice
148 .createOptOutList({
149 OptOutListName: name,
150 Tags: toTagList(desiredTags),
151 })
152 .pipe(
153 retrySmsVoiceThrottled,
154 Effect.catchTag("ConflictException", () => Effect.void),
155 Effect.asVoid,
156 );
157 observed = yield* getByName(name);
158 }
159 if (observed === undefined) {
160 return yield* Effect.fail(
161 new SmsVoiceOptOutListMissing({
162 message: `opt-out list '${name}' not observable after create`,
163 }),
164 );
165 }
166
167 // 3. Sync tags — diff against OBSERVED cloud tags.
168 yield* syncSmsVoiceTags(observed.OptOutListArn, desiredTags);
169
170 yield* session.note(observed.OptOutListArn);
171 return {
172 optOutListName: observed.OptOutListName,
173 optOutListArn: observed.OptOutListArn,
174 };
175 }),
176
177 delete: Effect.fn(function* ({ output }) {
178 yield* smsvoice
179 .deleteOptOutList({ OptOutListName: output.optOutListName })
180 .pipe(
181 retrySmsVoiceThrottled,
182 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
183 Effect.asVoid,
184 );
185 }),
186
187 list: () =>
188 smsvoice.describeOptOutLists.items({}).pipe(
189 Stream.runCollect,
190 Effect.map((chunk) =>
191 Array.from(chunk)
192 // The `Default` opt-out list is service-managed: End User
193 // Messaging recreates it automatically and then rejects
194 // DeleteOptOutList with ConflictException
195 // RESOURCE_MODIFICATION_NOT_ALLOWED (verified live) — keep
196 // it out of enumeration for account-wide teardown (nuke).
197 .filter((l) => l.OptOutListName !== "Default")
198 .map((l) => ({
199 optOutListName: l.OptOutListName,
200 optOutListArn: l.OptOutListArn,
201 })),
202 ),
203 ),
204 };
205 }),
206 );