@supa-media/convex 1.2.1
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/LICENSE +21 -0
- package/README.md +517 -0
- package/package.json +36 -0
- package/src/auth/helpers.ts +99 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/setup.ts +497 -0
- package/src/index.ts +73 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/rateLimit.ts +92 -0
- package/src/lib/scheduling.ts +41 -0
- package/src/lib/validation.ts +63 -0
- package/src/notifications/index.ts +242 -0
- package/src/payments/index.ts +404 -0
- package/src/schema/authTables.ts +38 -0
- package/src/schema/chatTables.ts +58 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/notificationTables.ts +58 -0
- package/src/schema/paymentTables.ts +47 -0
- package/src/schema/tenantScoping.ts +243 -0
- package/src/schema/tenantTables.ts +84 -0
- package/src/webhooks/hmac.ts +125 -0
- package/src/webhooks/index.ts +41 -0
- package/src/webhooks/sharedSecret.ts +48 -0
- package/src/webhooks/stripe.ts +97 -0
- package/src/webhooks/twilio.ts +80 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduling Helpers
|
|
3
|
+
*
|
|
4
|
+
* Utilities for working with Convex crons and scheduled functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a cron schedule string for common intervals.
|
|
9
|
+
* Returns a cron expression string compatible with Convex's cronJobs().
|
|
10
|
+
*/
|
|
11
|
+
export const CronSchedules = {
|
|
12
|
+
/** Every minute */
|
|
13
|
+
everyMinute: "* * * * *",
|
|
14
|
+
/** Every 5 minutes */
|
|
15
|
+
every5Minutes: "*/5 * * * *",
|
|
16
|
+
/** Every 15 minutes */
|
|
17
|
+
every15Minutes: "*/15 * * * *",
|
|
18
|
+
/** Every 30 minutes */
|
|
19
|
+
every30Minutes: "*/30 * * * *",
|
|
20
|
+
/** Every hour */
|
|
21
|
+
everyHour: "0 * * * *",
|
|
22
|
+
/** Every day at midnight UTC */
|
|
23
|
+
daily: "0 0 * * *",
|
|
24
|
+
/** Every day at a specific hour (0-23 UTC) */
|
|
25
|
+
dailyAt: (hour: number) => `0 ${hour} * * *`,
|
|
26
|
+
/** Every week on Monday at midnight UTC */
|
|
27
|
+
weekly: "0 0 * * 1",
|
|
28
|
+
/** Every month on the 1st at midnight UTC */
|
|
29
|
+
monthly: "0 0 1 * *",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calculate a delay in milliseconds.
|
|
34
|
+
* Useful with ctx.scheduler.runAfter().
|
|
35
|
+
*/
|
|
36
|
+
export const Delay = {
|
|
37
|
+
seconds: (n: number) => n * 1000,
|
|
38
|
+
minutes: (n: number) => n * 60 * 1000,
|
|
39
|
+
hours: (n: number) => n * 60 * 60 * 1000,
|
|
40
|
+
days: (n: number) => n * 24 * 60 * 60 * 1000,
|
|
41
|
+
} as const;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common Validators
|
|
3
|
+
*
|
|
4
|
+
* Validation utilities for phone numbers and emails.
|
|
5
|
+
* These are runtime validators, not Convex schema validators.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* E.164 phone number regex.
|
|
10
|
+
* Matches: +1234567890 through +123456789012345
|
|
11
|
+
*/
|
|
12
|
+
const E164_REGEX = /^\+[1-9]\d{6,14}$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Basic email regex. Not exhaustive but catches most invalid formats.
|
|
16
|
+
*/
|
|
17
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate an E.164 phone number.
|
|
21
|
+
* Returns true if the phone number is in valid E.164 format.
|
|
22
|
+
*/
|
|
23
|
+
export function isValidPhone(phone: string): boolean {
|
|
24
|
+
return E164_REGEX.test(phone);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate an email address.
|
|
29
|
+
* Returns true if the email has a valid basic format.
|
|
30
|
+
*/
|
|
31
|
+
export function isValidEmail(email: string): boolean {
|
|
32
|
+
return EMAIL_REGEX.test(email);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a phone number to E.164 format.
|
|
37
|
+
* Strips common formatting characters (spaces, dashes, parens, dots).
|
|
38
|
+
* Does NOT add country codes — the input must already include one.
|
|
39
|
+
*
|
|
40
|
+
* @throws if the result is not valid E.164
|
|
41
|
+
*/
|
|
42
|
+
export function normalizePhone(phone: string): string {
|
|
43
|
+
const cleaned = phone.replace(/[\s\-().]/g, "");
|
|
44
|
+
if (!isValidPhone(cleaned)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Invalid phone number: "${phone}". Must be in E.164 format (e.g. +12025550123).`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return cleaned;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize an email address (lowercase, trim whitespace).
|
|
54
|
+
*
|
|
55
|
+
* @throws if the result is not a valid email
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeEmail(email: string): string {
|
|
58
|
+
const cleaned = email.trim().toLowerCase();
|
|
59
|
+
if (!isValidEmail(cleaned)) {
|
|
60
|
+
throw new Error(`Invalid email address: "${email}".`);
|
|
61
|
+
}
|
|
62
|
+
return cleaned;
|
|
63
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for sending push notifications via the Expo Push API,
|
|
5
|
+
* managing device tokens, and processing the notification queue.
|
|
6
|
+
*
|
|
7
|
+
* These are plain async functions — wrap them in Convex mutations/actions
|
|
8
|
+
* in your app's convex functions.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { sendNotification, registerPushToken } from "@supa-media/convex/notifications";
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
17
|
+
|
|
18
|
+
// -- Types --
|
|
19
|
+
|
|
20
|
+
export interface NotificationPayload {
|
|
21
|
+
userId: string;
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
image?: string;
|
|
25
|
+
deepLink?: string;
|
|
26
|
+
data?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExpoPushMessage {
|
|
30
|
+
to: string;
|
|
31
|
+
title: string;
|
|
32
|
+
body: string;
|
|
33
|
+
sound?: "default" | null;
|
|
34
|
+
data?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExpoPushTicket {
|
|
38
|
+
status: "ok" | "error";
|
|
39
|
+
id?: string;
|
|
40
|
+
message?: string;
|
|
41
|
+
details?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Minimal DB context for notification functions. */
|
|
45
|
+
interface NotificationCtx {
|
|
46
|
+
db: {
|
|
47
|
+
query: (table: string) => any;
|
|
48
|
+
insert: (table: string, doc: any) => Promise<any>;
|
|
49
|
+
patch: (id: any, fields: any) => Promise<void>;
|
|
50
|
+
delete: (id: any) => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -- Push Token Management --
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a device push token for the current user.
|
|
58
|
+
* Upserts — if the token already exists, it's a no-op.
|
|
59
|
+
*/
|
|
60
|
+
export async function registerPushToken(
|
|
61
|
+
ctx: NotificationCtx,
|
|
62
|
+
userId: string,
|
|
63
|
+
token: string,
|
|
64
|
+
platform: "ios" | "android" | "web",
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
// Check if token already registered
|
|
67
|
+
const existing = await ctx.db
|
|
68
|
+
.query("pushTokens")
|
|
69
|
+
.withIndex("by_token", (q: any) => q.eq("token", token))
|
|
70
|
+
.first();
|
|
71
|
+
|
|
72
|
+
if (existing) {
|
|
73
|
+
// Update userId if token was reassigned to a different user
|
|
74
|
+
if (existing.userId !== userId) {
|
|
75
|
+
await ctx.db.patch(existing._id, { userId });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await ctx.db.insert("pushTokens", {
|
|
81
|
+
userId,
|
|
82
|
+
token,
|
|
83
|
+
platform,
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove expired or invalid push tokens for a user.
|
|
90
|
+
* Call this after receiving "DeviceNotRegistered" errors from Expo.
|
|
91
|
+
*/
|
|
92
|
+
export async function cleanupExpiredTokens(
|
|
93
|
+
ctx: NotificationCtx,
|
|
94
|
+
invalidTokens: string[],
|
|
95
|
+
): Promise<number> {
|
|
96
|
+
let removed = 0;
|
|
97
|
+
for (const token of invalidTokens) {
|
|
98
|
+
const existing = await ctx.db
|
|
99
|
+
.query("pushTokens")
|
|
100
|
+
.withIndex("by_token", (q: any) => q.eq("token", token))
|
|
101
|
+
.first();
|
|
102
|
+
if (existing) {
|
|
103
|
+
await ctx.db.delete(existing._id);
|
|
104
|
+
removed++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return removed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -- Notification Queue --
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Enqueue a notification for sending.
|
|
114
|
+
* The notification is stored in the queue with "pending" status.
|
|
115
|
+
*/
|
|
116
|
+
export async function enqueueNotification(
|
|
117
|
+
ctx: NotificationCtx,
|
|
118
|
+
payload: NotificationPayload,
|
|
119
|
+
): Promise<string> {
|
|
120
|
+
return await ctx.db.insert("notificationQueue", {
|
|
121
|
+
userId: payload.userId,
|
|
122
|
+
title: payload.title,
|
|
123
|
+
body: payload.body,
|
|
124
|
+
image: payload.image,
|
|
125
|
+
deepLink: payload.deepLink,
|
|
126
|
+
data: payload.data,
|
|
127
|
+
status: "pending",
|
|
128
|
+
createdAt: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// -- Expo Push API --
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send a single push notification via the Expo Push API.
|
|
136
|
+
* This is an action-level function (requires network access).
|
|
137
|
+
*/
|
|
138
|
+
export async function sendPushNotification(
|
|
139
|
+
messages: ExpoPushMessage[],
|
|
140
|
+
): Promise<ExpoPushTicket[]> {
|
|
141
|
+
if (messages.length === 0) return [];
|
|
142
|
+
|
|
143
|
+
const response = await fetch(EXPO_PUSH_URL, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
Accept: "application/json",
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify(messages),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const errorText = await response.text();
|
|
154
|
+
throw new Error(`Expo Push API error (${response.status}): ${errorText}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = await response.json();
|
|
158
|
+
return result.data as ExpoPushTicket[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Send a notification to a specific user by looking up their push tokens.
|
|
163
|
+
* Returns the push tokens that received the notification.
|
|
164
|
+
*/
|
|
165
|
+
export async function sendNotificationToUser(
|
|
166
|
+
ctx: NotificationCtx,
|
|
167
|
+
payload: NotificationPayload,
|
|
168
|
+
): Promise<{ sent: number; tokens: string[] }> {
|
|
169
|
+
const tokens = await ctx.db
|
|
170
|
+
.query("pushTokens")
|
|
171
|
+
.withIndex("by_userId", (q: any) => q.eq("userId", payload.userId))
|
|
172
|
+
.collect();
|
|
173
|
+
|
|
174
|
+
if (tokens.length === 0) {
|
|
175
|
+
return { sent: 0, tokens: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const messages: ExpoPushMessage[] = tokens.map((t: any) => ({
|
|
179
|
+
to: t.token,
|
|
180
|
+
title: payload.title,
|
|
181
|
+
body: payload.body,
|
|
182
|
+
sound: "default" as const,
|
|
183
|
+
data: {
|
|
184
|
+
...payload.data,
|
|
185
|
+
deepLink: payload.deepLink,
|
|
186
|
+
},
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
await sendPushNotification(messages);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
sent: tokens.length,
|
|
193
|
+
tokens: tokens.map((t: any) => t.token),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Process pending notifications from the queue.
|
|
199
|
+
* Designed to be called from a cron job or scheduled function.
|
|
200
|
+
*
|
|
201
|
+
* @param batchSize - Max notifications to process per run (default 100)
|
|
202
|
+
* @returns Count of processed notifications
|
|
203
|
+
*/
|
|
204
|
+
export async function processNotificationQueue(
|
|
205
|
+
ctx: NotificationCtx,
|
|
206
|
+
batchSize: number = 100,
|
|
207
|
+
): Promise<{ processed: number; failed: number }> {
|
|
208
|
+
const pending = await ctx.db
|
|
209
|
+
.query("notificationQueue")
|
|
210
|
+
.withIndex("by_status", (q: any) => q.eq("status", "pending"))
|
|
211
|
+
.take(batchSize);
|
|
212
|
+
|
|
213
|
+
let processed = 0;
|
|
214
|
+
let failed = 0;
|
|
215
|
+
|
|
216
|
+
for (const notification of pending) {
|
|
217
|
+
try {
|
|
218
|
+
await sendNotificationToUser(ctx, {
|
|
219
|
+
userId: notification.userId,
|
|
220
|
+
title: notification.title,
|
|
221
|
+
body: notification.body,
|
|
222
|
+
image: notification.image,
|
|
223
|
+
deepLink: notification.deepLink,
|
|
224
|
+
data: notification.data,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await ctx.db.patch(notification._id, {
|
|
228
|
+
status: "sent",
|
|
229
|
+
sentAt: Date.now(),
|
|
230
|
+
});
|
|
231
|
+
processed++;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
await ctx.db.patch(notification._id, {
|
|
234
|
+
status: "failed",
|
|
235
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
236
|
+
});
|
|
237
|
+
failed++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { processed, failed };
|
|
242
|
+
}
|