@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
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @doeza/sms-service
|
|
2
|
+
|
|
3
|
+
Standalone, server-side Clickatell SMS package. No Next.js, React, database, environment loader, runtime dependencies, or build step. Runs on Node.js 22+ and ships TypeScript declarations.
|
|
4
|
+
|
|
5
|
+
> **AI coding agents and production integrations:** read [`AGENTS.md`](./AGENTS.md) before implementing or modifying this package. It defines strict rules for duplicate-send prevention, secret handling, retries, callbacks, persistence, observability, testing, and framework integration.
|
|
6
|
+
|
|
7
|
+
## Install in another project
|
|
8
|
+
|
|
9
|
+
From this repository:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm pack:sms
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This creates `packages/sms-service/doeza-sms-service-1.0.0.tgz`. In your other project:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install /absolute/path/to/doeza-sms-service-1.0.0.tgz
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Alternatively copy the entire `packages/sms-service` directory into your project and install it with `npm install ./packages/sms-service`. No registry publication is required. Do not install the repository root as the SMS package.
|
|
22
|
+
|
|
23
|
+
## Send an SMS
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { createSmsService } from "@doeza/sms-service";
|
|
27
|
+
|
|
28
|
+
const sms = createSmsService({
|
|
29
|
+
apiKey: process.env.CLICKATELL_API_KEY,
|
|
30
|
+
timeoutMs: 10000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await sms.send({
|
|
34
|
+
to: "+46701234567",
|
|
35
|
+
content: "Your booking is confirmed.",
|
|
36
|
+
clientMessageId: "booking-123",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const message of result.messages) {
|
|
40
|
+
console.log(message.to, message.accepted, message.apiMessageId);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Configure a single service instance per credential set. Keep it in server code. Credentials are passed explicitly; the package does not read environment variables or log messages.
|
|
45
|
+
|
|
46
|
+
`to` accepts a string, comma-separated string, or string array. International numbers must contain 5–15 digits including the country code; a leading `+` and surrounding whitespace are removed. Duplicates are removed. Local numbers are rejected rather than assigned a country code. Content is preserved exactly and must not be blank.
|
|
47
|
+
|
|
48
|
+
`clientMessageId` defaults to a UUID. It correlates requests and callbacks; it is **not an idempotency guarantee**.
|
|
49
|
+
|
|
50
|
+
## Results and errors
|
|
51
|
+
|
|
52
|
+
`send()` returns `{ success, partial, clientMessageId, to, responseCode?, messages }`.
|
|
53
|
+
Each message has `to`, `accepted`, and optional `apiMessageId` / `errorCode`.
|
|
54
|
+
`success` means every recipient was accepted by Clickatell, not delivered to a handset.
|
|
55
|
+
`partial` means some recipients were accepted. Rejected recipients remain in the result.
|
|
56
|
+
|
|
57
|
+
Invalid input/configuration, HTTP failures, provider request errors, malformed responses, network errors, cancellation, and timeouts throw `SmsError`:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { SmsError } from "@doeza/sms-service";
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await sms.send({ to: ["46701234567"], content: "Hello" });
|
|
64
|
+
// Persist result here using your project's own database.
|
|
65
|
+
console.log(result.success, result.partial);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!(error instanceof SmsError)) throw error;
|
|
68
|
+
console.error(error.code, error.httpStatus, error.clientMessageId);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Error codes: `CONFIGURATION`, `VALIDATION`, `PROVIDER`, `INVALID_RESPONSE`, `NETWORK`, `TIMEOUT`, `ABORTED`.
|
|
73
|
+
The service does not expose raw provider bodies or native fetch errors, which can contain credentials.
|
|
74
|
+
The timeout covers fetching and reading the response. Pass `{ signal }` as the second argument to `send()` for cancellation.
|
|
75
|
+
|
|
76
|
+
There are no automatic retries. A timeout, disconnect, or incomplete provider response can happen after acceptance. Check provider status before resending. Handle storage failures separately from sending so a database retry does not send the SMS again.
|
|
77
|
+
|
|
78
|
+
The transport preserves this application's Clickatell Platform HTTP endpoint:
|
|
79
|
+
`https://platform.clickatell.com/messages/http/send`.
|
|
80
|
+
It sends one GET request with URL-encoded parameters and refuses redirects. Avoid logging outbound URLs because this API includes credentials and message content in query parameters.
|
|
81
|
+
The response fields follow [Clickatell's HTTP SDK examples](https://github.com/clickatell/clickatell-python#http-api).
|
|
82
|
+
|
|
83
|
+
## Next.js and other Web Request runtimes
|
|
84
|
+
|
|
85
|
+
The optional `/http` entry point uses standard `Request` / `Response` objects.
|
|
86
|
+
In a Next.js App Router route:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { createSmsService } from "@doeza/sms-service";
|
|
90
|
+
import { createSendSmsHandler } from "@doeza/sms-service/http";
|
|
91
|
+
|
|
92
|
+
export const runtime = "nodejs";
|
|
93
|
+
export const POST = createSendSmsHandler(() => createSmsService({
|
|
94
|
+
apiKey: process.env.CLICKATELL_API_KEY ?? "",
|
|
95
|
+
}));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The lazy factory keeps missing runtime credentials from breaking the build.
|
|
99
|
+
For server jobs or Express applications, call `sms.send()` directly inside your existing authenticated handler.
|
|
100
|
+
|
|
101
|
+
The host application owns authentication and rate limiting. Each HTTP handler accepts an optional `authorize(request)` callback. Returning false gives HTTP 401 before processing; omitting it adds no access control. Use the callback to connect your existing session or API-key checks before exposing a send route.
|
|
102
|
+
|
|
103
|
+
The send handler accepts POST JSON and returns the result plus a human-readable `message`. It retains the existing app's `success`, `message`, and `clientMessageId` contract. Partial and rejected recipient results use HTTP 200 with `success: false`; inspect `messages` rather than retrying the entire batch. Validation returns 400, configuration 500, provider failures 502, timeouts/network failures 504, and cancellation 408.
|
|
104
|
+
|
|
105
|
+
## Delivery status and incoming replies
|
|
106
|
+
|
|
107
|
+
The parsers and handlers accept the callback payloads already used by this app. They do not verify sender authenticity. Configure your Clickatell integration to match the payload format and callback URLs.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { createStatusCallbackHandler } from "@doeza/sms-service/http";
|
|
111
|
+
import { saveStatus } from "./your-database.js";
|
|
112
|
+
|
|
113
|
+
const handler = createStatusCallbackHandler(saveStatus);
|
|
114
|
+
export const GET = handler;
|
|
115
|
+
export const POST = handler;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`createReplyCallbackHandler(onReply)` works the same way. Consumers can be async; responses are acknowledged only after the consumer succeeds. Throwing returns an error so a provider can retry. Make database writes idempotent because callbacks can be repeated or arrive out of order. Both handlers accept GET query parameters, POST JSON, and POST URL-encoded forms. Connect webhook verification through `authorize` or your existing ingress.
|
|
119
|
+
|
|
120
|
+
For another framework, call `parseStatusCallback(payload)` or `parseReplyCallback(payload)` and persist their result yourself.
|
|
121
|
+
|
|
122
|
+
Required status fields: `messageId`, `to`, integer `statusCode`, `status`, and string `timestamp`.
|
|
123
|
+
Required reply fields: `fromNumber`, `toNumber`, integer `timestamp`, and `text`.
|
|
124
|
+
Numeric query values are normalized; missing timestamps and malformed numbers are rejected.
|
|
125
|
+
See the exported TypeScript types for optional fields.
|
|
126
|
+
|
|
127
|
+
Optional `statusCallbackUrl` and `replyCallbackUrl` service options preserve this app's `callback` and `replyCallback` send parameters. Use absolute HTTP(S) URLs. They do not replace configuring callbacks and two-way messaging in your provider account.
|
|
128
|
+
|
|
129
|
+
## Test and run the example
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
# No dependencies, credentials, or network required:
|
|
133
|
+
node --test packages/sms-service/test/*.test.js
|
|
134
|
+
|
|
135
|
+
# Sends a real message only when you provide a recipient and content:
|
|
136
|
+
node --env-file=.env.local packages/sms-service/examples/send.mjs +46701234567 "Hello"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Inject `fetch` in `createSmsService({ apiKey, fetch })` to use a mock or an instrumented transport. It must implement Fetch semantics, including abort signals. The bundled example also works from the copied package directory.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
- [`send.mjs`](./send.mjs) — minimal Node.js send example.
|
|
4
|
+
- [`nextjs-supabase/`](./nextjs-supabase/) — Next.js App Router + Supabase reference integration with authenticated sending, idempotency, persistence, delivery callbacks, incoming replies, RLS, and a complete SQL schema.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
|
|
2
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
|
|
3
|
+
SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY
|
|
4
|
+
|
|
5
|
+
CLICKATELL_API_KEY=YOUR_CLICKATELL_API_KEY
|
|
6
|
+
SMS_TIMEOUT_MS=10000
|
|
7
|
+
|
|
8
|
+
APP_URL=http://localhost:3000
|
|
9
|
+
ENABLE_SMS_CALLBACKS=true
|
|
10
|
+
SMS_WEBHOOK_TOKEN=replace-with-a-long-random-secret
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Next.js + Supabase integration
|
|
2
|
+
|
|
3
|
+
A production-oriented reference for using `@doeza/sms-service` from a new Next.js App Router project backed by Supabase.
|
|
4
|
+
|
|
5
|
+
The example deliberately keeps the SMS package server-only and leaves authentication, persistence, idempotency, policy, RLS, callback storage, and audit state in the host application.
|
|
6
|
+
|
|
7
|
+
## What this example demonstrates
|
|
8
|
+
|
|
9
|
+
- Next.js 16 App Router route handlers.
|
|
10
|
+
- Supabase SSR authentication using `@supabase/ssr`.
|
|
11
|
+
- A separate server-only service-role client for privileged SMS persistence.
|
|
12
|
+
- Required application idempotency keys.
|
|
13
|
+
- Atomic creation of an SMS request and its recipients.
|
|
14
|
+
- Atomic claim-before-send so concurrent requests cannot both transmit the same intent.
|
|
15
|
+
- One provider request per claimed intent.
|
|
16
|
+
- Per-recipient Clickatell acceptance/rejection persistence.
|
|
17
|
+
- An explicit `uncertain` state for timeout/network/provider/invalid-response outcomes.
|
|
18
|
+
- Delivery-status callback event storage without assuming callback arrival order equals delivery order.
|
|
19
|
+
- Incoming-reply deduplication.
|
|
20
|
+
- Row Level Security for user-readable operational data.
|
|
21
|
+
- No automatic send retries.
|
|
22
|
+
|
|
23
|
+
## 1. Create the Next.js project
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pnpm create next-app@latest sms-example --ts --app --src-dir
|
|
27
|
+
cd sms-example
|
|
28
|
+
pnpm add @supabase/supabase-js @supabase/ssr
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Install the SMS package from this repository:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# In sms-app
|
|
35
|
+
pnpm pack:sms
|
|
36
|
+
|
|
37
|
+
# In the new Next.js project
|
|
38
|
+
pnpm add /absolute/path/to/sms-app/packages/sms-service/doeza-sms-service-1.0.0.tgz
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The package requires Node.js 22+.
|
|
42
|
+
|
|
43
|
+
## 2. Copy the example files
|
|
44
|
+
|
|
45
|
+
Copy the contents of this example's `src/` directory into your new project's `src/` directory.
|
|
46
|
+
|
|
47
|
+
Recommended structure:
|
|
48
|
+
|
|
49
|
+
```txt
|
|
50
|
+
src/
|
|
51
|
+
app/api/sms/send/route.ts
|
|
52
|
+
app/api/sms/callbacks/status/route.ts
|
|
53
|
+
app/api/sms/callbacks/reply/route.ts
|
|
54
|
+
lib/sms/repository.ts
|
|
55
|
+
lib/sms/send-request.ts
|
|
56
|
+
lib/sms/service.ts
|
|
57
|
+
lib/sms/webhook-auth.ts
|
|
58
|
+
lib/supabase/admin.ts
|
|
59
|
+
lib/supabase/server.ts
|
|
60
|
+
supabase/
|
|
61
|
+
schema.sql
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 3. Apply the database schema
|
|
65
|
+
|
|
66
|
+
Run [`supabase/schema.sql`](./supabase/schema.sql) in the Supabase SQL editor or include it in your migrations.
|
|
67
|
+
|
|
68
|
+
The schema contains:
|
|
69
|
+
|
|
70
|
+
- `sms_requests`
|
|
71
|
+
- `sms_recipients`
|
|
72
|
+
- `sms_attempts`
|
|
73
|
+
- `sms_status_events`
|
|
74
|
+
- `sms_replies`
|
|
75
|
+
- enums and constraints
|
|
76
|
+
- updated-at triggers
|
|
77
|
+
- RLS policies
|
|
78
|
+
- `create_sms_request(...)`
|
|
79
|
+
- `claim_sms_request(...)`
|
|
80
|
+
- indexes and grants
|
|
81
|
+
|
|
82
|
+
`create_sms_request(...)` makes the application idempotency key authoritative. Reusing the same key with different content or recipients fails instead of silently changing an existing intent.
|
|
83
|
+
|
|
84
|
+
`claim_sms_request(...)` transitions only `queued -> sending` while holding a row lock. If two handlers race, only one can claim and call Clickatell.
|
|
85
|
+
|
|
86
|
+
## 4. Configure environment variables
|
|
87
|
+
|
|
88
|
+
Copy `.env.example` to `.env.local` and set real values.
|
|
89
|
+
|
|
90
|
+
`SUPABASE_SERVICE_ROLE_KEY` and `CLICKATELL_API_KEY` are server-only secrets. Never expose them through `NEXT_PUBLIC_*`.
|
|
91
|
+
|
|
92
|
+
For callbacks, use a long random `SMS_WEBHOOK_TOKEN`. This example embeds the token into the configured callback URL and verifies it before parsing callback payloads. Treat callback URLs as secret-bearing and do not log them.
|
|
93
|
+
|
|
94
|
+
If your ingress/provider setup supports a stronger documented verification mechanism, replace `verifySmsWebhookRequest()` with that mechanism. Do not invent a provider signature algorithm.
|
|
95
|
+
|
|
96
|
+
## 5. Send an SMS
|
|
97
|
+
|
|
98
|
+
The example route requires an authenticated Supabase user and an `Idempotency-Key` header.
|
|
99
|
+
|
|
100
|
+
Request:
|
|
101
|
+
|
|
102
|
+
```http
|
|
103
|
+
POST /api/sms/send
|
|
104
|
+
Idempotency-Key: booking-confirmation:5a6201a7
|
|
105
|
+
Content-Type: application/json
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
"to": "+46701234567",
|
|
109
|
+
"content": "Your booking is confirmed."
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Example browser/server call:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const response = await fetch("/api/sms/send", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: {
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
"Idempotency-Key": `booking-confirmation:${bookingId}`,
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
to: customer.phoneNumber,
|
|
124
|
+
content: "Your booking is confirmed.",
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const data = await response.json();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For a real product, prefer a domain-specific API such as `{ bookingId }` and resolve the phone number and message template server-side. A generic authenticated relay is useful for demonstrating the integration, but product authorization, recipient ownership, quotas, rate limits, consent, and template policy still belong in your application.
|
|
132
|
+
|
|
133
|
+
## 6. Idempotency and retries
|
|
134
|
+
|
|
135
|
+
There are two different identifiers:
|
|
136
|
+
|
|
137
|
+
- `Idempotency-Key`: host-application duplicate-intent protection.
|
|
138
|
+
- `clientMessageId`: correlation value passed to `@doeza/sms-service` and Clickatell.
|
|
139
|
+
|
|
140
|
+
The database request UUID is reused as `clientMessageId` so one value links database records, provider results, callbacks, and logs.
|
|
141
|
+
|
|
142
|
+
`clientMessageId` by itself is not an idempotency guarantee.
|
|
143
|
+
|
|
144
|
+
The send workflow is:
|
|
145
|
+
|
|
146
|
+
```txt
|
|
147
|
+
create/recover durable intent
|
|
148
|
+
|
|
|
149
|
+
v
|
|
150
|
+
atomically claim queued -> sending
|
|
151
|
+
|
|
|
152
|
+
v
|
|
153
|
+
call sms.send() exactly once
|
|
154
|
+
|
|
|
155
|
+
+--> accepted/partial/rejected -> persist result
|
|
156
|
+
|
|
|
157
|
+
+--> ambiguous transport/provider error -> uncertain
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Do not automatically retry requests in `sending` or `uncertain`. A process can lose the response after the provider accepted the SMS. Reconcile provider state before any resend.
|
|
161
|
+
|
|
162
|
+
A crash after the row is claimed but before result persistence intentionally leaves the request in `sending`. That is safer than automatically transmitting it again.
|
|
163
|
+
|
|
164
|
+
## 7. Acceptance is not delivery
|
|
165
|
+
|
|
166
|
+
`sms.send()` reports provider acceptance. Delivery is a separate lifecycle driven by later status callbacks.
|
|
167
|
+
|
|
168
|
+
This schema therefore stores:
|
|
169
|
+
|
|
170
|
+
- send-request state in `sms_requests`
|
|
171
|
+
- per-recipient acceptance in `sms_recipients`
|
|
172
|
+
- every normalized delivery callback in `sms_status_events`
|
|
173
|
+
|
|
174
|
+
The callback example does not convert provider status strings into a universal `delivered` boolean because that mapping is provider/business-policy specific and callbacks can arrive out of order. Build an explicit transition policy for the Clickatell statuses your account actually receives.
|
|
175
|
+
|
|
176
|
+
## 8. Incoming replies
|
|
177
|
+
|
|
178
|
+
`/api/sms/callbacks/reply` stores normalized reply payloads in `sms_replies`.
|
|
179
|
+
|
|
180
|
+
A SHA-256 `dedupe_key` makes repeated callbacks idempotent. Replies are linked back to a known request/recipient when the callback contains a provider `messageId` that matches a stored `provider_message_id`.
|
|
181
|
+
|
|
182
|
+
Treat incoming text as untrusted input. If replies trigger commands or business actions, run normal parsing, authorization-by-context, validation, and anti-abuse checks first.
|
|
183
|
+
|
|
184
|
+
## 9. RLS model
|
|
185
|
+
|
|
186
|
+
Authenticated users can read their own:
|
|
187
|
+
|
|
188
|
+
- SMS requests
|
|
189
|
+
- recipients
|
|
190
|
+
- attempts
|
|
191
|
+
- status events linked to their requests
|
|
192
|
+
- replies linked to their requests
|
|
193
|
+
|
|
194
|
+
Client-side inserts and updates are not granted. All SMS workflow writes use the service-role client on the server.
|
|
195
|
+
|
|
196
|
+
Unlinked inbound replies are intentionally not visible to normal users.
|
|
197
|
+
|
|
198
|
+
## 10. Operational rules
|
|
199
|
+
|
|
200
|
+
Do not log:
|
|
201
|
+
|
|
202
|
+
- Clickatell API keys
|
|
203
|
+
- Supabase service-role keys
|
|
204
|
+
- full provider request URLs
|
|
205
|
+
- callback URLs containing the webhook token
|
|
206
|
+
- full SMS bodies by default
|
|
207
|
+
- raw native fetch errors from the provider request
|
|
208
|
+
|
|
209
|
+
Useful structured fields include request ID/clientMessageId, account/user ID, recipient count, outcome category, `SmsError.code`, latency, and provider message IDs where appropriate.
|
|
210
|
+
|
|
211
|
+
Add your own rate limiter and quota policy before exposing the send route in production.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createReplyCallbackHandler } from "@doeza/sms-service/http";
|
|
2
|
+
import { saveReplyCallback } from "@/lib/sms/repository";
|
|
3
|
+
import { verifySmsWebhookRequest } from "@/lib/sms/webhook-auth";
|
|
4
|
+
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
const handler = createReplyCallbackHandler(saveReplyCallback, {
|
|
8
|
+
authorize: verifySmsWebhookRequest,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const GET = handler;
|
|
12
|
+
export const POST = handler;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createStatusCallbackHandler } from "@doeza/sms-service/http";
|
|
2
|
+
import { saveStatusCallback } from "@/lib/sms/repository";
|
|
3
|
+
import { verifySmsWebhookRequest } from "@/lib/sms/webhook-auth";
|
|
4
|
+
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
const handler = createStatusCallbackHandler(saveStatusCallback, {
|
|
8
|
+
authorize: verifySmsWebhookRequest,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const GET = handler;
|
|
12
|
+
export const POST = handler;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { SmsError, normalizeRecipients } from "@doeza/sms-service";
|
|
2
|
+
import {
|
|
3
|
+
createSmsRequest,
|
|
4
|
+
SmsRequestConflictError,
|
|
5
|
+
} from "@/lib/sms/repository";
|
|
6
|
+
import { processSmsRequest } from "@/lib/sms/send-request";
|
|
7
|
+
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
|
8
|
+
|
|
9
|
+
export const runtime = "nodejs";
|
|
10
|
+
|
|
11
|
+
type Body = {
|
|
12
|
+
to?: unknown;
|
|
13
|
+
content?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function smsErrorStatus(error: SmsError): number {
|
|
17
|
+
switch (error.code) {
|
|
18
|
+
case "VALIDATION":
|
|
19
|
+
return 400;
|
|
20
|
+
case "CONFIGURATION":
|
|
21
|
+
return 500;
|
|
22
|
+
case "ABORTED":
|
|
23
|
+
return 408;
|
|
24
|
+
case "PROVIDER":
|
|
25
|
+
case "INVALID_RESPONSE":
|
|
26
|
+
return 502;
|
|
27
|
+
case "NETWORK":
|
|
28
|
+
case "TIMEOUT":
|
|
29
|
+
return 504;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function POST(request: Request): Promise<Response> {
|
|
34
|
+
const supabase = await createSupabaseServerClient();
|
|
35
|
+
const {
|
|
36
|
+
data: { user },
|
|
37
|
+
error: authError,
|
|
38
|
+
} = await supabase.auth.getUser();
|
|
39
|
+
|
|
40
|
+
if (authError || !user) {
|
|
41
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const idempotencyKey = request.headers.get("Idempotency-Key")?.trim();
|
|
45
|
+
if (!idempotencyKey || idempotencyKey.length > 200) {
|
|
46
|
+
return Response.json(
|
|
47
|
+
{ error: "A 1-200 character Idempotency-Key header is required." },
|
|
48
|
+
{ status: 400 },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let body: Body;
|
|
53
|
+
try {
|
|
54
|
+
body = (await request.json()) as Body;
|
|
55
|
+
} catch {
|
|
56
|
+
return Response.json({ error: "Request body must be valid JSON." }, { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
60
|
+
return Response.json({ error: "content must be a non-empty string." }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let recipients: string[];
|
|
64
|
+
try {
|
|
65
|
+
recipients = normalizeRecipients(body.to);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof SmsError) {
|
|
68
|
+
return Response.json({ error: error.message, code: error.code }, { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let requestId: string;
|
|
74
|
+
try {
|
|
75
|
+
requestId = await createSmsRequest({
|
|
76
|
+
ownerId: user.id,
|
|
77
|
+
idempotencyKey,
|
|
78
|
+
content: body.content,
|
|
79
|
+
recipients,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof SmsRequestConflictError) {
|
|
83
|
+
return Response.json(
|
|
84
|
+
{ error: "The idempotency key is already bound to different SMS data." },
|
|
85
|
+
{ status: 409 },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return Response.json(
|
|
90
|
+
{ error: "Could not create the SMS request." },
|
|
91
|
+
{ status: 500 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const outcome = await processSmsRequest(requestId);
|
|
97
|
+
|
|
98
|
+
if (outcome.kind === "existing") {
|
|
99
|
+
return Response.json({
|
|
100
|
+
requestId,
|
|
101
|
+
state: outcome.request.state,
|
|
102
|
+
duplicate: true,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Response.json({
|
|
107
|
+
requestId,
|
|
108
|
+
state: outcome.request.state,
|
|
109
|
+
success: outcome.result.success,
|
|
110
|
+
partial: outcome.result.partial,
|
|
111
|
+
messages: outcome.result.messages,
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof SmsError) {
|
|
115
|
+
return Response.json(
|
|
116
|
+
{
|
|
117
|
+
requestId,
|
|
118
|
+
error: "SMS provider request failed.",
|
|
119
|
+
code: error.code,
|
|
120
|
+
},
|
|
121
|
+
{ status: smsErrorStatus(error) },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Response.json(
|
|
126
|
+
{
|
|
127
|
+
requestId,
|
|
128
|
+
error: "SMS workflow persistence failed. Do not automatically resend this request.",
|
|
129
|
+
},
|
|
130
|
+
{ status: 500 },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|