@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,250 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type {
|
|
5
|
+
SmsError,
|
|
6
|
+
SmsReply,
|
|
7
|
+
SmsResult,
|
|
8
|
+
SmsStatus,
|
|
9
|
+
} from "@doeza/sms-service";
|
|
10
|
+
import { getSupabaseAdmin } from "@/lib/supabase/admin";
|
|
11
|
+
|
|
12
|
+
export class SmsRequestConflictError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SmsRequestConflictError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type StoredSmsRequest = {
|
|
20
|
+
id: string;
|
|
21
|
+
owner_id: string;
|
|
22
|
+
client_message_id: string;
|
|
23
|
+
content: string;
|
|
24
|
+
state: string;
|
|
25
|
+
sms_recipients: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
to_number: string;
|
|
28
|
+
state: string;
|
|
29
|
+
provider_message_id: string | null;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function assertNoError(error: { message: string } | null, context: string): void {
|
|
34
|
+
if (error) throw new Error(`${context}: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sha256(parts: Array<string | number | undefined>): string {
|
|
38
|
+
return createHash("sha256")
|
|
39
|
+
.update(parts.map((part) => String(part ?? "")).join("\u001f"))
|
|
40
|
+
.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createSmsRequest(input: {
|
|
44
|
+
ownerId: string;
|
|
45
|
+
idempotencyKey: string;
|
|
46
|
+
content: string;
|
|
47
|
+
recipients: string[];
|
|
48
|
+
}): Promise<string> {
|
|
49
|
+
const admin = getSupabaseAdmin();
|
|
50
|
+
const { data, error } = await admin.rpc("create_sms_request", {
|
|
51
|
+
p_owner_id: input.ownerId,
|
|
52
|
+
p_idempotency_key: input.idempotencyKey,
|
|
53
|
+
p_content: input.content,
|
|
54
|
+
p_recipients: input.recipients,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (error?.code === "22023") {
|
|
58
|
+
throw new SmsRequestConflictError(error.message);
|
|
59
|
+
}
|
|
60
|
+
assertNoError(error, "Could not create SMS request");
|
|
61
|
+
|
|
62
|
+
if (typeof data !== "string") throw new Error("SMS request RPC returned an invalid id.");
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function claimSmsRequest(requestId: string): Promise<boolean> {
|
|
67
|
+
const admin = getSupabaseAdmin();
|
|
68
|
+
const { data, error } = await admin.rpc("claim_sms_request", {
|
|
69
|
+
p_request_id: requestId,
|
|
70
|
+
});
|
|
71
|
+
assertNoError(error, "Could not claim SMS request");
|
|
72
|
+
return data === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getSmsRequest(requestId: string): Promise<StoredSmsRequest> {
|
|
76
|
+
const admin = getSupabaseAdmin();
|
|
77
|
+
const { data, error } = await admin
|
|
78
|
+
.from("sms_requests")
|
|
79
|
+
.select(
|
|
80
|
+
"id,owner_id,client_message_id,content,state,sms_recipients(id,to_number,state,provider_message_id)",
|
|
81
|
+
)
|
|
82
|
+
.eq("id", requestId)
|
|
83
|
+
.single();
|
|
84
|
+
|
|
85
|
+
assertNoError(error, "Could not load SMS request");
|
|
86
|
+
if (!data) throw new Error("SMS request was not found.");
|
|
87
|
+
return data as StoredSmsRequest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function recordSmsResult(
|
|
91
|
+
requestId: string,
|
|
92
|
+
result: SmsResult,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const admin = getSupabaseAdmin();
|
|
95
|
+
|
|
96
|
+
for (const message of result.messages) {
|
|
97
|
+
const { error } = await admin
|
|
98
|
+
.from("sms_recipients")
|
|
99
|
+
.update({
|
|
100
|
+
state: message.accepted ? "accepted" : "rejected",
|
|
101
|
+
provider_message_id: message.apiMessageId ?? null,
|
|
102
|
+
provider_error_code:
|
|
103
|
+
message.errorCode === undefined ? null : String(message.errorCode),
|
|
104
|
+
})
|
|
105
|
+
.eq("request_id", requestId)
|
|
106
|
+
.eq("to_number", message.to);
|
|
107
|
+
assertNoError(error, "Could not persist recipient result");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const state = result.success ? "accepted" : result.partial ? "partial" : "rejected";
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
|
|
113
|
+
const { error: attemptError } = await admin
|
|
114
|
+
.from("sms_attempts")
|
|
115
|
+
.update({ state, finished_at: now })
|
|
116
|
+
.eq("request_id", requestId)
|
|
117
|
+
.eq("state", "sending");
|
|
118
|
+
assertNoError(attemptError, "Could not complete SMS attempt");
|
|
119
|
+
|
|
120
|
+
const { error: requestError } = await admin
|
|
121
|
+
.from("sms_requests")
|
|
122
|
+
.update({
|
|
123
|
+
state,
|
|
124
|
+
response_code: result.responseCode ?? null,
|
|
125
|
+
provider_accepted_at: result.messages.some((message) => message.accepted)
|
|
126
|
+
? now
|
|
127
|
+
: null,
|
|
128
|
+
completed_at: now,
|
|
129
|
+
error_code: null,
|
|
130
|
+
error_http_status: null,
|
|
131
|
+
})
|
|
132
|
+
.eq("id", requestId);
|
|
133
|
+
assertNoError(requestError, "Could not complete SMS request");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function recordSmsError(
|
|
137
|
+
requestId: string,
|
|
138
|
+
error: SmsError,
|
|
139
|
+
state: "uncertain" | "failed",
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const admin = getSupabaseAdmin();
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
|
|
144
|
+
const { error: attemptError } = await admin
|
|
145
|
+
.from("sms_attempts")
|
|
146
|
+
.update({
|
|
147
|
+
state,
|
|
148
|
+
error_code: error.code,
|
|
149
|
+
error_http_status: error.httpStatus ?? null,
|
|
150
|
+
finished_at: now,
|
|
151
|
+
})
|
|
152
|
+
.eq("request_id", requestId)
|
|
153
|
+
.eq("state", "sending");
|
|
154
|
+
assertNoError(attemptError, "Could not persist SMS attempt failure");
|
|
155
|
+
|
|
156
|
+
const { error: requestError } = await admin
|
|
157
|
+
.from("sms_requests")
|
|
158
|
+
.update({
|
|
159
|
+
state,
|
|
160
|
+
error_code: error.code,
|
|
161
|
+
error_http_status: error.httpStatus ?? null,
|
|
162
|
+
completed_at: state === "failed" ? now : null,
|
|
163
|
+
})
|
|
164
|
+
.eq("id", requestId);
|
|
165
|
+
assertNoError(requestError, "Could not persist SMS request failure");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function saveStatusCallback(status: SmsStatus): Promise<void> {
|
|
169
|
+
const admin = getSupabaseAdmin();
|
|
170
|
+
const { data: recipient, error: lookupError } = await admin
|
|
171
|
+
.from("sms_recipients")
|
|
172
|
+
.select("id,request_id")
|
|
173
|
+
.eq("provider_message_id", status.messageId)
|
|
174
|
+
.maybeSingle();
|
|
175
|
+
assertNoError(lookupError, "Could not resolve delivery-status recipient");
|
|
176
|
+
|
|
177
|
+
const dedupeKey = sha256([
|
|
178
|
+
status.messageId,
|
|
179
|
+
status.statusCode,
|
|
180
|
+
status.status,
|
|
181
|
+
status.timestamp,
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const { error } = await admin.from("sms_status_events").upsert(
|
|
185
|
+
{
|
|
186
|
+
dedupe_key: dedupeKey,
|
|
187
|
+
request_id: recipient?.request_id ?? null,
|
|
188
|
+
recipient_id: recipient?.id ?? null,
|
|
189
|
+
provider_message_id: status.messageId,
|
|
190
|
+
provider_request_id: status.requestId ?? null,
|
|
191
|
+
client_message_id: status.clientMessageId ?? null,
|
|
192
|
+
integration_name: status.integrationName ?? null,
|
|
193
|
+
to_number: status.to,
|
|
194
|
+
from_number: status.from ?? null,
|
|
195
|
+
status_code: status.statusCode,
|
|
196
|
+
status: status.status,
|
|
197
|
+
status_description: status.statusDescription ?? null,
|
|
198
|
+
provider_timestamp: status.timestamp,
|
|
199
|
+
normalized_payload: status,
|
|
200
|
+
},
|
|
201
|
+
{ onConflict: "dedupe_key", ignoreDuplicates: true },
|
|
202
|
+
);
|
|
203
|
+
assertNoError(error, "Could not persist delivery-status callback");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function saveReplyCallback(reply: SmsReply): Promise<void> {
|
|
207
|
+
const admin = getSupabaseAdmin();
|
|
208
|
+
let recipient: { id: string; request_id: string } | null = null;
|
|
209
|
+
|
|
210
|
+
if (reply.messageId) {
|
|
211
|
+
const { data, error } = await admin
|
|
212
|
+
.from("sms_recipients")
|
|
213
|
+
.select("id,request_id")
|
|
214
|
+
.eq("provider_message_id", reply.messageId)
|
|
215
|
+
.maybeSingle();
|
|
216
|
+
assertNoError(error, "Could not resolve reply recipient");
|
|
217
|
+
recipient = data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const dedupeKey = sha256([
|
|
221
|
+
reply.replyMessageId,
|
|
222
|
+
reply.messageId,
|
|
223
|
+
reply.fromNumber,
|
|
224
|
+
reply.toNumber,
|
|
225
|
+
reply.timestamp,
|
|
226
|
+
reply.text,
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const { error } = await admin.from("sms_replies").upsert(
|
|
230
|
+
{
|
|
231
|
+
dedupe_key: dedupeKey,
|
|
232
|
+
request_id: recipient?.request_id ?? null,
|
|
233
|
+
recipient_id: recipient?.id ?? null,
|
|
234
|
+
reply_message_id: reply.replyMessageId ?? null,
|
|
235
|
+
provider_message_id: reply.messageId ?? null,
|
|
236
|
+
integration_name: reply.integrationName ?? null,
|
|
237
|
+
from_number: reply.fromNumber,
|
|
238
|
+
to_number: reply.toNumber,
|
|
239
|
+
provider_timestamp: reply.timestamp,
|
|
240
|
+
text: reply.text,
|
|
241
|
+
charset: reply.charset ?? null,
|
|
242
|
+
udh: reply.udh ?? null,
|
|
243
|
+
network: reply.network ?? null,
|
|
244
|
+
keyword: reply.keyword ?? null,
|
|
245
|
+
normalized_payload: reply,
|
|
246
|
+
},
|
|
247
|
+
{ onConflict: "dedupe_key", ignoreDuplicates: true },
|
|
248
|
+
);
|
|
249
|
+
assertNoError(error, "Could not persist SMS reply callback");
|
|
250
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { SmsError, type SmsResult } from "@doeza/sms-service";
|
|
4
|
+
import {
|
|
5
|
+
claimSmsRequest,
|
|
6
|
+
getSmsRequest,
|
|
7
|
+
recordSmsError,
|
|
8
|
+
recordSmsResult,
|
|
9
|
+
type StoredSmsRequest,
|
|
10
|
+
} from "@/lib/sms/repository";
|
|
11
|
+
import { getSmsService } from "@/lib/sms/service";
|
|
12
|
+
|
|
13
|
+
export type ProcessSmsRequestResult =
|
|
14
|
+
| { kind: "sent"; request: StoredSmsRequest; result: SmsResult }
|
|
15
|
+
| { kind: "existing"; request: StoredSmsRequest };
|
|
16
|
+
|
|
17
|
+
export async function processSmsRequest(
|
|
18
|
+
requestId: string,
|
|
19
|
+
): Promise<ProcessSmsRequestResult> {
|
|
20
|
+
const claimed = await claimSmsRequest(requestId);
|
|
21
|
+
|
|
22
|
+
if (!claimed) {
|
|
23
|
+
return { kind: "existing", request: await getSmsRequest(requestId) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const request = await getSmsRequest(requestId);
|
|
27
|
+
const recipients = request.sms_recipients.map((recipient) => recipient.to_number);
|
|
28
|
+
|
|
29
|
+
let result: SmsResult;
|
|
30
|
+
try {
|
|
31
|
+
result = await getSmsService().send({
|
|
32
|
+
to: recipients,
|
|
33
|
+
content: request.content,
|
|
34
|
+
clientMessageId: request.client_message_id,
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!(error instanceof SmsError)) {
|
|
38
|
+
// An unknown exception around provider I/O is not safe to resend automatically.
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const definitelyLocal = error.code === "CONFIGURATION" || error.code === "VALIDATION";
|
|
43
|
+
await recordSmsError(requestId, error, definitelyLocal ? "failed" : "uncertain");
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If persistence fails here, leave the request in `sending` rather than automatically
|
|
48
|
+
// transmitting it again. Reconcile the provider result before any resend.
|
|
49
|
+
await recordSmsResult(requestId, result);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kind: "sent",
|
|
53
|
+
request: await getSmsRequest(requestId),
|
|
54
|
+
result,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { createSmsService, SmsError, type SmsService } from "@doeza/sms-service";
|
|
4
|
+
|
|
5
|
+
let service: SmsService | undefined;
|
|
6
|
+
|
|
7
|
+
function callbackUrl(pathname: string): string | undefined {
|
|
8
|
+
if (process.env.ENABLE_SMS_CALLBACKS !== "true") return undefined;
|
|
9
|
+
|
|
10
|
+
const appUrl = process.env.APP_URL;
|
|
11
|
+
const token = process.env.SMS_WEBHOOK_TOKEN;
|
|
12
|
+
|
|
13
|
+
if (!appUrl || !token) {
|
|
14
|
+
throw new SmsError(
|
|
15
|
+
"CONFIGURATION",
|
|
16
|
+
"APP_URL and SMS_WEBHOOK_TOKEN are required when SMS callbacks are enabled.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(pathname, appUrl);
|
|
22
|
+
url.searchParams.set("token", token);
|
|
23
|
+
return url.href;
|
|
24
|
+
} catch {
|
|
25
|
+
throw new SmsError("CONFIGURATION", "APP_URL must be an absolute HTTP(S) URL.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getSmsService(): SmsService {
|
|
30
|
+
if (service) return service;
|
|
31
|
+
|
|
32
|
+
service = createSmsService({
|
|
33
|
+
apiKey: process.env.CLICKATELL_API_KEY ?? "",
|
|
34
|
+
timeoutMs:
|
|
35
|
+
process.env.SMS_TIMEOUT_MS === undefined
|
|
36
|
+
? 10_000
|
|
37
|
+
: Number(process.env.SMS_TIMEOUT_MS),
|
|
38
|
+
statusCallbackUrl: callbackUrl("/api/sms/callbacks/status"),
|
|
39
|
+
replyCallbackUrl: callbackUrl("/api/sms/callbacks/reply"),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return service;
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export function verifySmsWebhookRequest(request: Request): boolean {
|
|
6
|
+
const expected = process.env.SMS_WEBHOOK_TOKEN;
|
|
7
|
+
if (!expected) return false;
|
|
8
|
+
|
|
9
|
+
const actual = new URL(request.url).searchParams.get("token");
|
|
10
|
+
if (!actual) return false;
|
|
11
|
+
|
|
12
|
+
const expectedBytes = Buffer.from(expected);
|
|
13
|
+
const actualBytes = Buffer.from(actual);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
expectedBytes.length === actualBytes.length &&
|
|
17
|
+
timingSafeEqual(expectedBytes, actualBytes)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
4
|
+
|
|
5
|
+
let admin: SupabaseClient | undefined;
|
|
6
|
+
|
|
7
|
+
export function getSupabaseAdmin(): SupabaseClient {
|
|
8
|
+
if (admin) return admin;
|
|
9
|
+
|
|
10
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
11
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
12
|
+
|
|
13
|
+
if (!url || !serviceRoleKey) {
|
|
14
|
+
throw new Error("Supabase server configuration is missing.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
admin = createClient(url, serviceRoleKey, {
|
|
18
|
+
auth: {
|
|
19
|
+
autoRefreshToken: false,
|
|
20
|
+
persistSession: false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return admin;
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
|
|
4
|
+
export async function createSupabaseServerClient() {
|
|
5
|
+
const cookieStore = await cookies();
|
|
6
|
+
|
|
7
|
+
return createServerClient(
|
|
8
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
|
|
9
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "",
|
|
10
|
+
{
|
|
11
|
+
cookies: {
|
|
12
|
+
getAll() {
|
|
13
|
+
return cookieStore.getAll();
|
|
14
|
+
},
|
|
15
|
+
setAll(cookiesToSet) {
|
|
16
|
+
try {
|
|
17
|
+
for (const { name, value, options } of cookiesToSet) {
|
|
18
|
+
cookieStore.set(name, value, options);
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Some Server Component contexts cannot write cookies.
|
|
22
|
+
// Route handlers and server actions can.
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
}
|