@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.
@@ -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;