1import * as Lambda from "@/AWS/Lambda";
2import * as PinpointSMSVoiceV2 from "@/AWS/PinpointSMSVoiceV2";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "optout-handler.ts");
11
12/**
13 * Destination number the fixture opts out/in — the US simulator success
14 * number, so no real end user is ever affected.
15 */
16export const TEST_DESTINATION = "+14254147755";
17
18export class SmsVoiceOptOutTestFunction extends Lambda.Function<Lambda.Function>()(
19 "SmsVoiceOptOutTestFunction",
20) {}
21
22export default SmsVoiceOptOutTestFunction.make(
23 {
24 main,
25 functionUrl: true,
26 timeout: Duration.seconds(30),
27 },
28 Effect.gen(function* () {
29 const optOuts = yield* PinpointSMSVoiceV2.OptOutList("BindingsOptOuts", {
30 tags: { fixture: "smsvoice-bindings" },
31 });
32
33 const putOptedOut = yield* PinpointSMSVoiceV2.PutOptedOutNumber(optOuts);
34 const describeOptedOut =
35 yield* PinpointSMSVoiceV2.DescribeOptedOutNumbers(optOuts);
36 const deleteOptedOut =
37 yield* PinpointSMSVoiceV2.DeleteOptedOutNumber(optOuts);
38 const carrierLookup = yield* PinpointSMSVoiceV2.CarrierLookup();
39 const putFeedback = yield* PinpointSMSVoiceV2.PutMessageFeedback();
40
41 return {
42 fetch: Effect.gen(function* () {
43 const request = yield* HttpServerRequest;
44 const pathname = new URL(request.originalUrl).pathname;
45
46 // Cheap readiness route — no AWS call.
47 if (request.method === "GET" && pathname === "/ping") {
48 return yield* HttpServerResponse.json({ ok: true });
49 }
50
51 if (request.method === "POST" && pathname === "/opt-out") {
52 const result = yield* putOptedOut({
53 OptedOutNumber: TEST_DESTINATION,
54 });
55 return yield* HttpServerResponse.json({
56 optedOutNumber: result.OptedOutNumber,
57 endUserOptedOut: result.EndUserOptedOut,
58 });
59 }
60
61 if (request.method === "POST" && pathname === "/opt-out-check") {
62 const result = yield* describeOptedOut({
63 OptedOutNumbers: [TEST_DESTINATION],
64 }).pipe(
65 Effect.map((r) => ({
66 count: (r.OptedOutNumbers ?? []).length,
67 numbers: (r.OptedOutNumbers ?? []).map((n) => n.OptedOutNumber),
68 })),
69 // Filtering for a number that isn't in the list raises the
70 // typed not-found tag — semantically "0 opted out".
71 Effect.catchTag("ResourceNotFoundException", () =>
72 Effect.succeed({
73 count: 0,
74 numbers: [] as (string | undefined)[],
75 }),
76 ),
77 );
78 return yield* HttpServerResponse.json(result);
79 }
80
81 if (request.method === "POST" && pathname === "/opt-out-delete") {
82 const result = yield* deleteOptedOut({
83 OptedOutNumber: TEST_DESTINATION,
84 }).pipe(
85 Effect.map((r) => ({
86 ok: true as const,
87 deleted: r.OptedOutNumber,
88 })),
89 Effect.catchTag(
90 [
91 "AccessDeniedException",
92 "ResourceNotFoundException",
93 "ValidationException",
94 "ThrottlingException",
95 "InternalServerException",
96 ],
97 (e) =>
98 Effect.succeed({
99 ok: false as const,
100 tag: e._tag,
101 message: e.message,
102 }),
103 ),
104 );
105 return yield* HttpServerResponse.json(result);
106 }
107
108 if (request.method === "POST" && pathname === "/carrier-lookup") {
109 // The lookup may reject the simulator number as unsupported —
110 // surface the typed tag so the test can assert IAM allowed the
111 // call (an AccessDeniedException would prove a broken grant).
112 const result = yield* carrierLookup({
113 PhoneNumber: TEST_DESTINATION,
114 }).pipe(
115 Effect.map((info) => ({
116 ok: true as const,
117 e164PhoneNumber: info.E164PhoneNumber,
118 phoneNumberType: info.PhoneNumberType,
119 })),
120 Effect.catchTag(
121 [
122 "AccessDeniedException",
123 "ValidationException",
124 "ServiceQuotaExceededException",
125 "ThrottlingException",
126 "InternalServerException",
127 ],
128 (e) =>
129 Effect.succeed({
130 ok: false as const,
131 tag: e._tag,
132 message: e.message,
133 }),
134 ),
135 );
136 return yield* HttpServerResponse.json(result);
137 }
138
139 if (request.method === "POST" && pathname === "/feedback-probe") {
140 // No real MessageId exists ungated — assert the typed
141 // ResourceNotFoundException surfaces, proving the grant and the
142 // request wiring (a broken grant would AccessDeniedException).
143 const result = yield* putFeedback({
144 MessageId: "00000000-0000-0000-0000-000000000000",
145 MessageFeedbackStatus: "RECEIVED",
146 }).pipe(
147 Effect.map((r) => ({ ok: true as const, messageId: r.MessageId })),
148 Effect.catchTag(
149 [
150 "AccessDeniedException",
151 "ResourceNotFoundException",
152 "ValidationException",
153 "ThrottlingException",
154 "InternalServerException",
155 ],
156 (e) =>
157 Effect.succeed({
158 ok: false as const,
159 tag: e._tag,
160 message: e.message,
161 }),
162 ),
163 );
164 return yield* HttpServerResponse.json(result);
165 }
166
167 return yield* HttpServerResponse.json(
168 { error: "Not found", method: request.method, pathname },
169 { status: 404 },
170 );
171 }).pipe(Effect.orDie),
172 };
173 }).pipe(
174 Effect.provide(
175 Layer.mergeAll(
176 PinpointSMSVoiceV2.PutOptedOutNumberHttp,
177 PinpointSMSVoiceV2.DescribeOptedOutNumbersHttp,
178 PinpointSMSVoiceV2.DeleteOptedOutNumberHttp,
179 PinpointSMSVoiceV2.CarrierLookupHttp,
180 PinpointSMSVoiceV2.PutMessageFeedbackHttp,
181 ),
182 ),
183 ),
184);