@doeza/sms-service 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1616 -0
- package/README.md +139 -0
- package/examples/README.md +4 -0
- package/examples/nextjs-supabase/.env.example +10 -0
- package/examples/nextjs-supabase/README.md +211 -0
- package/examples/nextjs-supabase/src/app/api/sms/callbacks/reply/route.ts +12 -0
- package/examples/nextjs-supabase/src/app/api/sms/callbacks/status/route.ts +12 -0
- package/examples/nextjs-supabase/src/app/api/sms/send/route.ts +133 -0
- package/examples/nextjs-supabase/src/lib/sms/repository.ts +250 -0
- package/examples/nextjs-supabase/src/lib/sms/send-request.ts +56 -0
- package/examples/nextjs-supabase/src/lib/sms/service.ts +43 -0
- package/examples/nextjs-supabase/src/lib/sms/webhook-auth.ts +19 -0
- package/examples/nextjs-supabase/src/lib/supabase/admin.ts +25 -0
- package/examples/nextjs-supabase/src/lib/supabase/server.ts +28 -0
- package/examples/nextjs-supabase/supabase/schema.sql +399 -0
- package/examples/send.mjs +18 -0
- package/http.d.ts +9 -0
- package/http.js +75 -0
- package/index.d.ts +70 -0
- package/index.js +176 -0
- package/package.json +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
begin;
|
|
2
|
+
|
|
3
|
+
create extension if not exists pgcrypto;
|
|
4
|
+
|
|
5
|
+
create type public.sms_request_state as enum (
|
|
6
|
+
'queued',
|
|
7
|
+
'sending',
|
|
8
|
+
'accepted',
|
|
9
|
+
'partial',
|
|
10
|
+
'rejected',
|
|
11
|
+
'uncertain',
|
|
12
|
+
'failed'
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
create type public.sms_recipient_state as enum (
|
|
16
|
+
'pending',
|
|
17
|
+
'accepted',
|
|
18
|
+
'rejected'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
create type public.sms_attempt_state as enum (
|
|
22
|
+
'sending',
|
|
23
|
+
'accepted',
|
|
24
|
+
'partial',
|
|
25
|
+
'rejected',
|
|
26
|
+
'uncertain',
|
|
27
|
+
'failed'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
create table public.sms_requests (
|
|
31
|
+
id uuid primary key default gen_random_uuid(),
|
|
32
|
+
owner_id uuid not null references auth.users(id) on delete cascade,
|
|
33
|
+
idempotency_key text not null,
|
|
34
|
+
client_message_id text not null unique,
|
|
35
|
+
content text not null,
|
|
36
|
+
state public.sms_request_state not null default 'queued',
|
|
37
|
+
response_code integer,
|
|
38
|
+
error_code text,
|
|
39
|
+
error_http_status integer,
|
|
40
|
+
sending_started_at timestamptz,
|
|
41
|
+
provider_accepted_at timestamptz,
|
|
42
|
+
completed_at timestamptz,
|
|
43
|
+
created_at timestamptz not null default now(),
|
|
44
|
+
updated_at timestamptz not null default now(),
|
|
45
|
+
constraint sms_requests_idempotency_key_nonblank check (length(btrim(idempotency_key)) between 1 and 200),
|
|
46
|
+
constraint sms_requests_content_nonblank check (length(btrim(content)) > 0),
|
|
47
|
+
constraint sms_requests_owner_idempotency_unique unique (owner_id, idempotency_key)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
create table public.sms_recipients (
|
|
51
|
+
id uuid primary key default gen_random_uuid(),
|
|
52
|
+
request_id uuid not null references public.sms_requests(id) on delete cascade,
|
|
53
|
+
to_number text not null,
|
|
54
|
+
state public.sms_recipient_state not null default 'pending',
|
|
55
|
+
provider_message_id text,
|
|
56
|
+
provider_error_code text,
|
|
57
|
+
created_at timestamptz not null default now(),
|
|
58
|
+
updated_at timestamptz not null default now(),
|
|
59
|
+
constraint sms_recipients_international_number check (to_number ~ '^[1-9][0-9]{4,14}$'),
|
|
60
|
+
constraint sms_recipients_request_number_unique unique (request_id, to_number)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
create unique index sms_recipients_provider_message_id_unique
|
|
64
|
+
on public.sms_recipients(provider_message_id)
|
|
65
|
+
where provider_message_id is not null;
|
|
66
|
+
|
|
67
|
+
create index sms_recipients_request_id_idx on public.sms_recipients(request_id);
|
|
68
|
+
|
|
69
|
+
create table public.sms_attempts (
|
|
70
|
+
id uuid primary key default gen_random_uuid(),
|
|
71
|
+
request_id uuid not null references public.sms_requests(id) on delete cascade,
|
|
72
|
+
attempt_no integer not null,
|
|
73
|
+
state public.sms_attempt_state not null default 'sending',
|
|
74
|
+
error_code text,
|
|
75
|
+
error_http_status integer,
|
|
76
|
+
started_at timestamptz not null default now(),
|
|
77
|
+
finished_at timestamptz,
|
|
78
|
+
constraint sms_attempts_positive_number check (attempt_no > 0),
|
|
79
|
+
constraint sms_attempts_request_number_unique unique (request_id, attempt_no)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
create index sms_attempts_request_id_idx on public.sms_attempts(request_id);
|
|
83
|
+
|
|
84
|
+
create table public.sms_status_events (
|
|
85
|
+
id uuid primary key default gen_random_uuid(),
|
|
86
|
+
dedupe_key text not null unique,
|
|
87
|
+
request_id uuid references public.sms_requests(id) on delete set null,
|
|
88
|
+
recipient_id uuid references public.sms_recipients(id) on delete set null,
|
|
89
|
+
provider_message_id text not null,
|
|
90
|
+
provider_request_id text,
|
|
91
|
+
client_message_id text,
|
|
92
|
+
integration_name text,
|
|
93
|
+
to_number text not null,
|
|
94
|
+
from_number text,
|
|
95
|
+
status_code integer not null,
|
|
96
|
+
status text not null,
|
|
97
|
+
status_description text,
|
|
98
|
+
provider_timestamp text not null,
|
|
99
|
+
normalized_payload jsonb not null,
|
|
100
|
+
received_at timestamptz not null default now()
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
create index sms_status_events_request_id_idx on public.sms_status_events(request_id);
|
|
104
|
+
create index sms_status_events_recipient_id_idx on public.sms_status_events(recipient_id);
|
|
105
|
+
create index sms_status_events_provider_message_id_idx on public.sms_status_events(provider_message_id);
|
|
106
|
+
|
|
107
|
+
create table public.sms_replies (
|
|
108
|
+
id uuid primary key default gen_random_uuid(),
|
|
109
|
+
dedupe_key text not null unique,
|
|
110
|
+
request_id uuid references public.sms_requests(id) on delete set null,
|
|
111
|
+
recipient_id uuid references public.sms_recipients(id) on delete set null,
|
|
112
|
+
reply_message_id text,
|
|
113
|
+
provider_message_id text,
|
|
114
|
+
integration_name text,
|
|
115
|
+
from_number text not null,
|
|
116
|
+
to_number text not null,
|
|
117
|
+
provider_timestamp bigint not null,
|
|
118
|
+
text text not null,
|
|
119
|
+
charset text,
|
|
120
|
+
udh text,
|
|
121
|
+
network integer,
|
|
122
|
+
keyword text,
|
|
123
|
+
normalized_payload jsonb not null,
|
|
124
|
+
received_at timestamptz not null default now(),
|
|
125
|
+
constraint sms_replies_text_nonblank check (length(btrim(text)) > 0)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
create unique index sms_replies_reply_message_id_unique
|
|
129
|
+
on public.sms_replies(reply_message_id)
|
|
130
|
+
where reply_message_id is not null;
|
|
131
|
+
|
|
132
|
+
create index sms_replies_request_id_idx on public.sms_replies(request_id);
|
|
133
|
+
create index sms_replies_recipient_id_idx on public.sms_replies(recipient_id);
|
|
134
|
+
create index sms_replies_provider_message_id_idx on public.sms_replies(provider_message_id);
|
|
135
|
+
|
|
136
|
+
create or replace function public.set_updated_at()
|
|
137
|
+
returns trigger
|
|
138
|
+
language plpgsql
|
|
139
|
+
set search_path = public
|
|
140
|
+
as $$
|
|
141
|
+
begin
|
|
142
|
+
new.updated_at = now();
|
|
143
|
+
return new;
|
|
144
|
+
end;
|
|
145
|
+
$$;
|
|
146
|
+
|
|
147
|
+
create trigger sms_requests_set_updated_at
|
|
148
|
+
before update on public.sms_requests
|
|
149
|
+
for each row execute function public.set_updated_at();
|
|
150
|
+
|
|
151
|
+
create trigger sms_recipients_set_updated_at
|
|
152
|
+
before update on public.sms_recipients
|
|
153
|
+
for each row execute function public.set_updated_at();
|
|
154
|
+
|
|
155
|
+
create or replace function public.create_sms_request(
|
|
156
|
+
p_owner_id uuid,
|
|
157
|
+
p_idempotency_key text,
|
|
158
|
+
p_content text,
|
|
159
|
+
p_recipients text[]
|
|
160
|
+
)
|
|
161
|
+
returns uuid
|
|
162
|
+
language plpgsql
|
|
163
|
+
security definer
|
|
164
|
+
set search_path = public
|
|
165
|
+
as $$
|
|
166
|
+
declare
|
|
167
|
+
v_request_id uuid;
|
|
168
|
+
v_existing_content text;
|
|
169
|
+
v_existing_recipients text[];
|
|
170
|
+
v_recipients text[];
|
|
171
|
+
begin
|
|
172
|
+
if p_owner_id is null then
|
|
173
|
+
raise exception 'owner_id is required' using errcode = '22023';
|
|
174
|
+
end if;
|
|
175
|
+
|
|
176
|
+
if p_idempotency_key is null or length(btrim(p_idempotency_key)) not between 1 and 200 then
|
|
177
|
+
raise exception 'idempotency_key must contain 1-200 characters' using errcode = '22023';
|
|
178
|
+
end if;
|
|
179
|
+
|
|
180
|
+
if p_content is null or length(btrim(p_content)) = 0 then
|
|
181
|
+
raise exception 'content cannot be blank' using errcode = '22023';
|
|
182
|
+
end if;
|
|
183
|
+
|
|
184
|
+
if p_recipients is null or cardinality(p_recipients) = 0 then
|
|
185
|
+
raise exception 'at least one recipient is required' using errcode = '22023';
|
|
186
|
+
end if;
|
|
187
|
+
|
|
188
|
+
select array_agg(distinct recipient order by recipient)
|
|
189
|
+
into v_recipients
|
|
190
|
+
from unnest(p_recipients) as recipient;
|
|
191
|
+
|
|
192
|
+
if exists (
|
|
193
|
+
select 1
|
|
194
|
+
from unnest(v_recipients) as recipient
|
|
195
|
+
where recipient !~ '^[1-9][0-9]{4,14}$'
|
|
196
|
+
) then
|
|
197
|
+
raise exception 'recipients must already be normalized international numbers' using errcode = '22023';
|
|
198
|
+
end if;
|
|
199
|
+
|
|
200
|
+
select id, content
|
|
201
|
+
into v_request_id, v_existing_content
|
|
202
|
+
from public.sms_requests
|
|
203
|
+
where owner_id = p_owner_id
|
|
204
|
+
and idempotency_key = btrim(p_idempotency_key)
|
|
205
|
+
for update;
|
|
206
|
+
|
|
207
|
+
if found then
|
|
208
|
+
select array_agg(to_number order by to_number)
|
|
209
|
+
into v_existing_recipients
|
|
210
|
+
from public.sms_recipients
|
|
211
|
+
where request_id = v_request_id;
|
|
212
|
+
|
|
213
|
+
if v_existing_content is distinct from p_content
|
|
214
|
+
or v_existing_recipients is distinct from v_recipients then
|
|
215
|
+
raise exception 'idempotency key was already used with different SMS data' using errcode = '22023';
|
|
216
|
+
end if;
|
|
217
|
+
|
|
218
|
+
return v_request_id;
|
|
219
|
+
end if;
|
|
220
|
+
|
|
221
|
+
v_request_id := gen_random_uuid();
|
|
222
|
+
|
|
223
|
+
insert into public.sms_requests (
|
|
224
|
+
id,
|
|
225
|
+
owner_id,
|
|
226
|
+
idempotency_key,
|
|
227
|
+
client_message_id,
|
|
228
|
+
content
|
|
229
|
+
) values (
|
|
230
|
+
v_request_id,
|
|
231
|
+
p_owner_id,
|
|
232
|
+
btrim(p_idempotency_key),
|
|
233
|
+
v_request_id::text,
|
|
234
|
+
p_content
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
insert into public.sms_recipients (request_id, to_number)
|
|
238
|
+
select v_request_id, recipient
|
|
239
|
+
from unnest(v_recipients) as recipient;
|
|
240
|
+
|
|
241
|
+
return v_request_id;
|
|
242
|
+
exception
|
|
243
|
+
when unique_violation then
|
|
244
|
+
select id into v_request_id
|
|
245
|
+
from public.sms_requests
|
|
246
|
+
where owner_id = p_owner_id
|
|
247
|
+
and idempotency_key = btrim(p_idempotency_key);
|
|
248
|
+
|
|
249
|
+
if v_request_id is null then
|
|
250
|
+
raise;
|
|
251
|
+
end if;
|
|
252
|
+
|
|
253
|
+
select content into v_existing_content
|
|
254
|
+
from public.sms_requests
|
|
255
|
+
where id = v_request_id;
|
|
256
|
+
|
|
257
|
+
select array_agg(to_number order by to_number)
|
|
258
|
+
into v_existing_recipients
|
|
259
|
+
from public.sms_recipients
|
|
260
|
+
where request_id = v_request_id;
|
|
261
|
+
|
|
262
|
+
if v_existing_content is distinct from p_content
|
|
263
|
+
or v_existing_recipients is distinct from v_recipients then
|
|
264
|
+
raise exception 'idempotency key was already used with different SMS data' using errcode = '22023';
|
|
265
|
+
end if;
|
|
266
|
+
|
|
267
|
+
return v_request_id;
|
|
268
|
+
end;
|
|
269
|
+
$$;
|
|
270
|
+
|
|
271
|
+
create or replace function public.claim_sms_request(p_request_id uuid)
|
|
272
|
+
returns boolean
|
|
273
|
+
language plpgsql
|
|
274
|
+
security definer
|
|
275
|
+
set search_path = public
|
|
276
|
+
as $$
|
|
277
|
+
declare
|
|
278
|
+
v_state public.sms_request_state;
|
|
279
|
+
v_attempt_no integer;
|
|
280
|
+
begin
|
|
281
|
+
select state
|
|
282
|
+
into v_state
|
|
283
|
+
from public.sms_requests
|
|
284
|
+
where id = p_request_id
|
|
285
|
+
for update;
|
|
286
|
+
|
|
287
|
+
if not found or v_state <> 'queued' then
|
|
288
|
+
return false;
|
|
289
|
+
end if;
|
|
290
|
+
|
|
291
|
+
select coalesce(max(attempt_no), 0) + 1
|
|
292
|
+
into v_attempt_no
|
|
293
|
+
from public.sms_attempts
|
|
294
|
+
where request_id = p_request_id;
|
|
295
|
+
|
|
296
|
+
update public.sms_requests
|
|
297
|
+
set state = 'sending',
|
|
298
|
+
sending_started_at = now(),
|
|
299
|
+
error_code = null,
|
|
300
|
+
error_http_status = null
|
|
301
|
+
where id = p_request_id;
|
|
302
|
+
|
|
303
|
+
insert into public.sms_attempts (request_id, attempt_no, state)
|
|
304
|
+
values (p_request_id, v_attempt_no, 'sending');
|
|
305
|
+
|
|
306
|
+
return true;
|
|
307
|
+
end;
|
|
308
|
+
$$;
|
|
309
|
+
|
|
310
|
+
alter table public.sms_requests enable row level security;
|
|
311
|
+
alter table public.sms_recipients enable row level security;
|
|
312
|
+
alter table public.sms_attempts enable row level security;
|
|
313
|
+
alter table public.sms_status_events enable row level security;
|
|
314
|
+
alter table public.sms_replies enable row level security;
|
|
315
|
+
|
|
316
|
+
create policy sms_requests_select_own
|
|
317
|
+
on public.sms_requests
|
|
318
|
+
for select
|
|
319
|
+
to authenticated
|
|
320
|
+
using (owner_id = auth.uid());
|
|
321
|
+
|
|
322
|
+
create policy sms_recipients_select_own
|
|
323
|
+
on public.sms_recipients
|
|
324
|
+
for select
|
|
325
|
+
to authenticated
|
|
326
|
+
using (
|
|
327
|
+
exists (
|
|
328
|
+
select 1
|
|
329
|
+
from public.sms_requests request
|
|
330
|
+
where request.id = sms_recipients.request_id
|
|
331
|
+
and request.owner_id = auth.uid()
|
|
332
|
+
)
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
create policy sms_attempts_select_own
|
|
336
|
+
on public.sms_attempts
|
|
337
|
+
for select
|
|
338
|
+
to authenticated
|
|
339
|
+
using (
|
|
340
|
+
exists (
|
|
341
|
+
select 1
|
|
342
|
+
from public.sms_requests request
|
|
343
|
+
where request.id = sms_attempts.request_id
|
|
344
|
+
and request.owner_id = auth.uid()
|
|
345
|
+
)
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
create policy sms_status_events_select_own
|
|
349
|
+
on public.sms_status_events
|
|
350
|
+
for select
|
|
351
|
+
to authenticated
|
|
352
|
+
using (
|
|
353
|
+
request_id is not null
|
|
354
|
+
and exists (
|
|
355
|
+
select 1
|
|
356
|
+
from public.sms_requests request
|
|
357
|
+
where request.id = sms_status_events.request_id
|
|
358
|
+
and request.owner_id = auth.uid()
|
|
359
|
+
)
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
create policy sms_replies_select_own
|
|
363
|
+
on public.sms_replies
|
|
364
|
+
for select
|
|
365
|
+
to authenticated
|
|
366
|
+
using (
|
|
367
|
+
request_id is not null
|
|
368
|
+
and exists (
|
|
369
|
+
select 1
|
|
370
|
+
from public.sms_requests request
|
|
371
|
+
where request.id = sms_replies.request_id
|
|
372
|
+
and request.owner_id = auth.uid()
|
|
373
|
+
)
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
revoke all on public.sms_requests from anon, authenticated;
|
|
377
|
+
revoke all on public.sms_recipients from anon, authenticated;
|
|
378
|
+
revoke all on public.sms_attempts from anon, authenticated;
|
|
379
|
+
revoke all on public.sms_status_events from anon, authenticated;
|
|
380
|
+
revoke all on public.sms_replies from anon, authenticated;
|
|
381
|
+
|
|
382
|
+
grant select on public.sms_requests to authenticated;
|
|
383
|
+
grant select on public.sms_recipients to authenticated;
|
|
384
|
+
grant select on public.sms_attempts to authenticated;
|
|
385
|
+
grant select on public.sms_status_events to authenticated;
|
|
386
|
+
grant select on public.sms_replies to authenticated;
|
|
387
|
+
|
|
388
|
+
grant all on public.sms_requests to service_role;
|
|
389
|
+
grant all on public.sms_recipients to service_role;
|
|
390
|
+
grant all on public.sms_attempts to service_role;
|
|
391
|
+
grant all on public.sms_status_events to service_role;
|
|
392
|
+
grant all on public.sms_replies to service_role;
|
|
393
|
+
|
|
394
|
+
revoke all on function public.create_sms_request(uuid, text, text, text[]) from public, anon, authenticated;
|
|
395
|
+
revoke all on function public.claim_sms_request(uuid) from public, anon, authenticated;
|
|
396
|
+
grant execute on function public.create_sms_request(uuid, text, text, text[]) to service_role;
|
|
397
|
+
grant execute on function public.claim_sms_request(uuid) to service_role;
|
|
398
|
+
|
|
399
|
+
commit;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createSmsService, SmsError } from "../index.js";
|
|
2
|
+
|
|
3
|
+
const [to, ...words] = process.argv.slice(2);
|
|
4
|
+
if (!to || words.length === 0) {
|
|
5
|
+
console.error('Usage: node --env-file=.env.local examples/send.mjs +46701234567 "Hello"');
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
} else {
|
|
8
|
+
try {
|
|
9
|
+
const sms = createSmsService({ apiKey: process.env.CLICKATELL_API_KEY });
|
|
10
|
+
const result = await sms.send({ to, content: words.join(" ") });
|
|
11
|
+
console.log(JSON.stringify(result));
|
|
12
|
+
if (!result.success) process.exitCode = 1;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (!(error instanceof SmsError)) throw error;
|
|
15
|
+
console.error(error.code, error.message, error.clientMessageId ?? "");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/http.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SmsReply, SmsService, SmsStatus } from "./index.js";
|
|
2
|
+
export type RequestHandler = (request: Request) => Promise<Response>;
|
|
3
|
+
export interface HandlerOptions {
|
|
4
|
+
/** Run the host application's authentication/rate-limit checks before processing. */
|
|
5
|
+
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
6
|
+
}
|
|
7
|
+
export function createSendSmsHandler(service: SmsService | (() => SmsService), options?: HandlerOptions): RequestHandler;
|
|
8
|
+
export function createStatusCallbackHandler(onStatus: (status: SmsStatus) => void | Promise<void>, options?: HandlerOptions): RequestHandler;
|
|
9
|
+
export function createReplyCallbackHandler(onReply: (reply: SmsReply) => void | Promise<void>, options?: HandlerOptions): RequestHandler;
|
package/http.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SmsError, parseReplyCallback, parseStatusCallback } from "./index.js";
|
|
2
|
+
|
|
3
|
+
const json = (body, status = 200, headers = {}) => Response.json(body, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
4
|
+
|
|
5
|
+
function errorResponse(error) {
|
|
6
|
+
if (!(error instanceof SmsError)) return json({ success: false, message: "An unexpected server error occurred." }, 500);
|
|
7
|
+
const statuses = { VALIDATION: 400, CONFIGURATION: 500, PROVIDER: 502, INVALID_RESPONSE: 502, NETWORK: 504, TIMEOUT: 504, ABORTED: 408 };
|
|
8
|
+
return json({
|
|
9
|
+
success: false,
|
|
10
|
+
message: error.code === "CONFIGURATION" ? "The SMS service is not configured correctly." : error.message,
|
|
11
|
+
code: error.code,
|
|
12
|
+
...(error.clientMessageId ? { clientMessageId: error.clientMessageId } : {}),
|
|
13
|
+
}, statuses[error.code] ?? 500);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readJson(request) {
|
|
17
|
+
try { return await request.json(); } catch {
|
|
18
|
+
throw new SmsError("VALIDATION", "Request body must contain valid JSON.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function authorized(request, options) {
|
|
23
|
+
return !options.authorize || await options.authorize(request);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSendSmsHandler(service, options = {}) {
|
|
27
|
+
return async (request) => {
|
|
28
|
+
if (request.method !== "POST") return json({ success: false, message: "Use POST." }, 405, { Allow: "POST" });
|
|
29
|
+
try {
|
|
30
|
+
if (!await authorized(request, options)) return json({ success: false, message: "Unauthorized." }, 401);
|
|
31
|
+
const input = await readJson(request);
|
|
32
|
+
const sms = typeof service === "function" ? service() : service;
|
|
33
|
+
const result = await sms.send(input, { signal: request.signal });
|
|
34
|
+
// Preserve the existing app's response parser while exposing structured fields.
|
|
35
|
+
const providerResponse = JSON.stringify({ responseCode: result.responseCode, messages: result.messages });
|
|
36
|
+
const message = result.success
|
|
37
|
+
? `SMS sent successfully to ${result.to.join(",")} (accepted by provider). Provider response: ${providerResponse}`
|
|
38
|
+
: result.partial
|
|
39
|
+
? "Only some recipients were accepted. Inspect messages before retrying rejected recipients."
|
|
40
|
+
: "The SMS provider rejected all recipients.";
|
|
41
|
+
return json({ ...result, message });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return errorResponse(error);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createCallbackHandler(parse, consume, message, options) {
|
|
49
|
+
if (typeof consume !== "function") throw new SmsError("CONFIGURATION", "A callback consumer is required.");
|
|
50
|
+
return async (request) => {
|
|
51
|
+
if (!["GET", "POST"].includes(request.method)) return json({ success: false, message: "Use GET or POST." }, 405, { Allow: "GET, POST" });
|
|
52
|
+
try {
|
|
53
|
+
if (!await authorized(request, options)) return json({ success: false, message: "Unauthorized." }, 401);
|
|
54
|
+
let body;
|
|
55
|
+
if (request.method === "GET") body = Object.fromEntries(new URL(request.url).searchParams);
|
|
56
|
+
else if (request.headers.get("content-type")?.split(";")[0].trim() === "application/x-www-form-urlencoded") {
|
|
57
|
+
body = Object.fromEntries(new URLSearchParams(await request.text()));
|
|
58
|
+
} else body = await readJson(request);
|
|
59
|
+
const payload = parse(body);
|
|
60
|
+
// Acknowledge only after the host application's consumer succeeds.
|
|
61
|
+
await consume(payload);
|
|
62
|
+
return json({ success: true, message });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return errorResponse(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createStatusCallbackHandler(onStatus, options = {}) {
|
|
70
|
+
return createCallbackHandler(parseStatusCallback, onStatus, "Status received", options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createReplyCallbackHandler(onReply, options = {}) {
|
|
74
|
+
return createCallbackHandler(parseReplyCallback, onReply, "Reply received", options);
|
|
75
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type SmsErrorCode = "CONFIGURATION" | "VALIDATION" | "PROVIDER" | "INVALID_RESPONSE" | "NETWORK" | "TIMEOUT" | "ABORTED";
|
|
2
|
+
export class SmsError extends Error {
|
|
3
|
+
readonly code: SmsErrorCode;
|
|
4
|
+
readonly httpStatus?: number;
|
|
5
|
+
readonly clientMessageId?: string;
|
|
6
|
+
constructor(code: SmsErrorCode, message: string, details?: { httpStatus?: number; clientMessageId?: string });
|
|
7
|
+
}
|
|
8
|
+
export interface SmsOptions {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
fetch?: typeof globalThis.fetch;
|
|
12
|
+
/** Optional absolute URLs; the host app owns callback configuration. */
|
|
13
|
+
statusCallbackUrl?: string;
|
|
14
|
+
replyCallbackUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SendSmsInput {
|
|
17
|
+
/** International numbers, with optional +; array or comma-separated string. */
|
|
18
|
+
to: string | readonly string[];
|
|
19
|
+
content: string;
|
|
20
|
+
/** Correlation identifier, not an idempotency guarantee. */
|
|
21
|
+
clientMessageId?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SmsMessageResult {
|
|
24
|
+
to: string;
|
|
25
|
+
accepted: boolean;
|
|
26
|
+
apiMessageId?: string;
|
|
27
|
+
errorCode?: string | number;
|
|
28
|
+
}
|
|
29
|
+
export interface SmsResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
partial: boolean;
|
|
32
|
+
clientMessageId: string;
|
|
33
|
+
to: string[];
|
|
34
|
+
responseCode?: number;
|
|
35
|
+
messages: SmsMessageResult[];
|
|
36
|
+
}
|
|
37
|
+
export interface SmsService {
|
|
38
|
+
/** One provider request. Never automatically retries. Acceptance is not delivery. */
|
|
39
|
+
send(input: SendSmsInput, options?: { signal?: AbortSignal }): Promise<SmsResult>;
|
|
40
|
+
}
|
|
41
|
+
export function createSmsService(options: SmsOptions): SmsService;
|
|
42
|
+
export function normalizeRecipients(to: unknown): string[];
|
|
43
|
+
export interface SmsStatus {
|
|
44
|
+
integrationName?: string;
|
|
45
|
+
messageId: string;
|
|
46
|
+
requestId?: string;
|
|
47
|
+
clientMessageId?: string;
|
|
48
|
+
to: string;
|
|
49
|
+
from?: string;
|
|
50
|
+
statusCode: number;
|
|
51
|
+
status: string;
|
|
52
|
+
statusDescription?: string;
|
|
53
|
+
timestamp: string;
|
|
54
|
+
}
|
|
55
|
+
export interface SmsReply {
|
|
56
|
+
integrationName?: string;
|
|
57
|
+
replyMessageId?: string;
|
|
58
|
+
messageId?: string;
|
|
59
|
+
fromNumber: string;
|
|
60
|
+
toNumber: string;
|
|
61
|
+
timestamp: number;
|
|
62
|
+
text: string;
|
|
63
|
+
charset?: string;
|
|
64
|
+
udh?: string;
|
|
65
|
+
network?: number;
|
|
66
|
+
keyword?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Parse the callback payloads used by this app; these functions do not authenticate requests. */
|
|
69
|
+
export function parseStatusCallback(input: unknown): SmsStatus;
|
|
70
|
+
export function parseReplyCallback(input: unknown): SmsReply;
|