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 Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.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 PhoneNumberProps {
20 /**
21 * Two-character ISO country code of the requested number, e.g. `US`.
22 * Changing it replaces the phone number.
23 */
24 isoCountryCode: string;
25 /**
26 * Type of messages sent from the number: `TRANSACTIONAL` for
27 * time-sensitive messages or `PROMOTIONAL` for marketing content.
28 * Changing it replaces the phone number.
29 */
30 messageType: "TRANSACTIONAL" | "PROMOTIONAL";
31 /**
32 * Capabilities the number must support: `SMS`, `VOICE`, and/or `MMS`.
33 * Changing them replaces the phone number.
34 */
35 numberCapabilities: string[];
36 /**
37 * Type of number to lease: `SIMULATOR`, `LONG_CODE`, `TOLL_FREE`, or
38 * `TEN_DLC`. `SIMULATOR` numbers only exchange messages with other
39 * simulator destinations and carry the smallest cost — use them for
40 * testing. Changing it replaces the phone number.
41 */
42 numberType: string;
43 /**
44 * Name of the opt-out list to associate with the number.
45 * @default the account's Default opt-out list
46 */
47 optOutListName?: string;
48 /**
49 * When `true`, `ReleasePhoneNumber` is rejected until protection is
50 * disabled again.
51 * @default false
52 */
53 deletionProtectionEnabled?: boolean;
54 /**
55 * Tags to apply to the phone number. Merged with internal Alchemy tags.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface PhoneNumber extends Resource<
61 "AWS.PinpointSMSVoiceV2.PhoneNumber",
62 PhoneNumberProps,
63 {
64 /**
65 * ID of the phone number.
66 */
67 phoneNumberId: string;
68 /**
69 * ARN of the phone number.
70 */
71 phoneNumberArn: string;
72 /**
73 * The provisioned phone number in E.164 format.
74 */
75 phoneNumber: string;
76 /**
77 * Provisioning status (e.g. `PENDING`, `ACTIVE`).
78 */
79 status: string;
80 /**
81 * Two-letter ISO country code of the number.
82 */
83 isoCountryCode: string;
84 /**
85 * Message type the number is registered for (`TRANSACTIONAL` or
86 * `PROMOTIONAL`).
87 */
88 messageType: string;
89 /**
90 * Capabilities of the number (`SMS`, `VOICE`, `MMS`).
91 */
92 numberCapabilities: string[];
93 /**
94 * Number type (e.g. `LONG_CODE`, `TOLL_FREE`, `TEN_DLC`).
95 */
96 numberType: string;
97 /**
98 * Monthly leasing price in USD.
99 */
100 monthlyLeasingPrice: string;
101 /**
102 * Opt-out list associated with the number.
103 */
104 optOutListName: string;
105 },
106 never,
107 Providers
108> {}
109
110/**
111 * An AWS End User Messaging SMS (Pinpoint SMS Voice v2) origination
112 * phone number leased into your account.
113 *
114 * Requesting a number incurs a monthly leasing fee and most number types
115 * require account-level entitlement (spending limits, registration).
116 * `SIMULATOR` numbers are the cheap, entitlement-free option for testing.
117 * ### Requesting Phone Numbers
118 * **Example:** Simulator Number
119 * ```typescript
120 * import * as PinpointSMSVoiceV2 from "alchemy/AWS/PinpointSMSVoiceV2";
121 *
122 * const number = yield* PinpointSMSVoiceV2.PhoneNumber("TestNumber", {
123 * isoCountryCode: "US",
124 * messageType: "TRANSACTIONAL",
125 * numberCapabilities: ["SMS"],
126 * numberType: "SIMULATOR",
127 * });
128 * ```
129 *
130 * **Example:** Toll-Free Number with a Custom Opt-Out List
131 * ```typescript
132 * const optOuts = yield* PinpointSMSVoiceV2.OptOutList("OptOuts");
133 * const number = yield* PinpointSMSVoiceV2.PhoneNumber("Sender", {
134 * isoCountryCode: "US",
135 * messageType: "TRANSACTIONAL",
136 * numberCapabilities: ["SMS", "VOICE"],
137 * numberType: "TOLL_FREE",
138 * optOutListName: optOuts.optOutListName,
139 * });
140 * ```
141 *
142 * @resource
143 */
144export const PhoneNumber = Resource<PhoneNumber>(
145 "AWS.PinpointSMSVoiceV2.PhoneNumber",
146);
147
148/**
149 * Raised when a phone number cannot be observed after `RequestPhoneNumber`
150 * succeeded, or the API returned a number without its ID.
151 */
152export class SmsVoicePhoneNumberMissing extends Data.TaggedError(
153 "SmsVoicePhoneNumberMissing",
154)<{ message: string }> {}
155
156/**
157 * Poll a freshly-requested number out of `PENDING` on a bounded schedule
158 * (extracted so declaration emit keeps the provider layer type narrow).
159 */
160const untilNotPending = <E, R>(
161 self: Effect.Effect<smsvoice.PhoneNumberInformation | undefined, E, R>,
162): Effect.Effect<smsvoice.PhoneNumberInformation | undefined, E, R> =>
163 Effect.repeat(self, {
164 until: (p) => p === undefined || p.Status !== "PENDING",
165 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
166 });
167
168const toAttrs = (info: smsvoice.PhoneNumberInformation) =>
169 Effect.gen(function* () {
170 if (info.PhoneNumberId === undefined) {
171 return yield* Effect.fail(
172 new SmsVoicePhoneNumberMissing({
173 message: `phone number '${info.PhoneNumberArn}' is missing its ID`,
174 }),
175 );
176 }
177 return {
178 phoneNumberId: info.PhoneNumberId,
179 phoneNumberArn: info.PhoneNumberArn,
180 phoneNumber: info.PhoneNumber,
181 status: info.Status,
182 isoCountryCode: info.IsoCountryCode,
183 messageType: info.MessageType,
184 numberCapabilities: [...info.NumberCapabilities],
185 numberType: info.NumberType,
186 monthlyLeasingPrice: info.MonthlyLeasingPrice,
187 optOutListName: info.OptOutListName,
188 };
189 });
190
191const sameCapabilities = (
192 left: readonly string[],
193 right: readonly string[],
194) => {
195 const l = [...left].sort();
196 const r = [...right].sort();
197 return l.length === r.length && l.every((v, i) => v === r[i]);
198};
199
200export const PhoneNumberProvider = () =>
201 Provider.effect(
202 PhoneNumber,
203 Effect.gen(function* () {
204 const getById = Effect.fn(function* (phoneNumberId: string) {
205 const result = yield* smsvoice
206 .describePhoneNumbers({ PhoneNumberIds: [phoneNumberId] })
207 .pipe(
208 retrySmsVoiceThrottled,
209 Effect.catchTag("ResourceNotFoundException", () =>
210 Effect.succeed(undefined),
211 ),
212 );
213 return result?.PhoneNumbers?.find(
214 (p) => p.PhoneNumberId === phoneNumberId,
215 );
216 });
217
218 return {
219 stables: [
220 "phoneNumberId",
221 "phoneNumberArn",
222 "phoneNumber",
223 "isoCountryCode",
224 "numberType",
225 ],
226
227 read: Effect.fn(function* ({ id, output }) {
228 // Phone number IDs are assigned by AWS — without a cached output
229 // there is no deterministic identity to look up.
230 if (output?.phoneNumberId === undefined) return undefined;
231 const observed = yield* getById(output.phoneNumberId);
232 if (observed === undefined) return undefined;
233 const attrs = yield* toAttrs(observed);
234 const tags = yield* readSmsVoiceTags(observed.PhoneNumberArn);
235 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
236 }),
237
238 diff: Effect.fn(function* ({ news, olds }) {
239 if (!isResolved(news)) return undefined;
240 if (olds === undefined) return undefined;
241 if (
242 olds.isoCountryCode !== news.isoCountryCode ||
243 olds.messageType !== news.messageType ||
244 olds.numberType !== news.numberType ||
245 !sameCapabilities(
246 olds.numberCapabilities ?? [],
247 news.numberCapabilities,
248 )
249 ) {
250 return { action: "replace" } as const;
251 }
252 }),
253
254 reconcile: Effect.fn(function* ({ id, news, output, session }) {
255 const internalTags = yield* createInternalTags(id);
256 const desiredTags = { ...news.tags, ...internalTags };
257
258 // 1. Observe — the ID cache in `output` is the only identity.
259 let observed =
260 output?.phoneNumberId === undefined
261 ? undefined
262 : yield* getById(output.phoneNumberId);
263
264 // 2. Ensure — request a number if missing, then wait out PENDING.
265 if (observed === undefined) {
266 const requested = yield* smsvoice
267 .requestPhoneNumber({
268 IsoCountryCode: news.isoCountryCode,
269 MessageType: news.messageType,
270 NumberCapabilities: news.numberCapabilities,
271 NumberType: news.numberType,
272 OptOutListName: news.optOutListName,
273 DeletionProtectionEnabled: news.deletionProtectionEnabled,
274 Tags: toTagList(desiredTags),
275 })
276 .pipe(retrySmsVoiceThrottled);
277 if (requested.PhoneNumberId === undefined) {
278 return yield* Effect.fail(
279 new SmsVoicePhoneNumberMissing({
280 message: "RequestPhoneNumber returned no PhoneNumberId",
281 }),
282 );
283 }
284 observed = yield* getById(requested.PhoneNumberId).pipe(
285 untilNotPending,
286 );
287 }
288 if (observed === undefined || observed.PhoneNumberId === undefined) {
289 return yield* Effect.fail(
290 new SmsVoicePhoneNumberMissing({
291 message: "phone number not observable after request",
292 }),
293 );
294 }
295 const phoneNumberId = observed.PhoneNumberId;
296
297 // 3. Sync — apply a single update carrying only the drifted
298 // aspects (opt-out list, deletion protection). Fields the API
299 // couples to unmodeled features (e.g. self-managed opt-outs
300 // require a two-way channel) are never sent.
301 const desiredProtection = news.deletionProtectionEnabled ?? false;
302 const optOutDrift =
303 news.optOutListName !== undefined &&
304 observed.OptOutListName !== news.optOutListName;
305 const protectionDrift =
306 observed.DeletionProtectionEnabled !== desiredProtection;
307 if (optOutDrift || protectionDrift) {
308 yield* smsvoice
309 .updatePhoneNumber({
310 PhoneNumberId: phoneNumberId,
311 OptOutListName: optOutDrift ? news.optOutListName : undefined,
312 DeletionProtectionEnabled: protectionDrift
313 ? desiredProtection
314 : undefined,
315 })
316 .pipe(retrySmsVoiceThrottled);
317 }
318
319 // 3b. Sync tags — diff against OBSERVED cloud tags.
320 yield* syncSmsVoiceTags(observed.PhoneNumberArn, desiredTags);
321
322 // 4. Return fresh attributes.
323 const final = yield* getById(phoneNumberId);
324 if (final === undefined) {
325 return yield* Effect.fail(
326 new SmsVoicePhoneNumberMissing({
327 message: `phone number '${phoneNumberId}' vanished during reconcile`,
328 }),
329 );
330 }
331 yield* session.note(final.PhoneNumberArn);
332 return yield* toAttrs(final);
333 }),
334
335 delete: Effect.fn(function* ({ output }) {
336 yield* smsvoice
337 .releasePhoneNumber({ PhoneNumberId: output.phoneNumberId })
338 .pipe(
339 retrySmsVoiceThrottled,
340 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
341 Effect.asVoid,
342 );
343 }),
344
345 list: () =>
346 smsvoice.describePhoneNumbers.items({}).pipe(
347 Stream.runCollect,
348 Effect.flatMap(
349 Effect.forEach(
350 (info) =>
351 toAttrs(info).pipe(
352 // Tolerate a number missing its ID — drop it.
353 Effect.catchTag("SmsVoicePhoneNumberMissing", () =>
354 Effect.succeed(undefined),
355 ),
356 ),
357 { concurrency: 5 },
358 ),
359 ),
360 Effect.map((items) => items.filter((item) => item !== undefined)),
361 ),
362 };
363 }),
364 );