@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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment Utilities
|
|
3
|
+
*
|
|
4
|
+
* Stripe integration helpers for Convex — webhook handling,
|
|
5
|
+
* checkout session creation, and subscription management.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { handleStripeWebhook, getSubscriptionStatus } from "@supa-media/convex/payments";
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { timingSafeEqual } from "../webhooks/hmac";
|
|
13
|
+
|
|
14
|
+
// -- Types --
|
|
15
|
+
|
|
16
|
+
export interface CheckoutSessionParams {
|
|
17
|
+
userId: string;
|
|
18
|
+
priceId: string;
|
|
19
|
+
successUrl: string;
|
|
20
|
+
cancelUrl: string;
|
|
21
|
+
metadata?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SubscriptionStatus {
|
|
25
|
+
isActive: boolean;
|
|
26
|
+
status: string | null;
|
|
27
|
+
priceId: string | null;
|
|
28
|
+
currentPeriodEnd: number | null;
|
|
29
|
+
cancelAtPeriodEnd: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal DB context for payment functions. */
|
|
33
|
+
interface PaymentCtx {
|
|
34
|
+
db: {
|
|
35
|
+
query: (table: string) => any;
|
|
36
|
+
insert: (table: string, doc: any) => Promise<any>;
|
|
37
|
+
patch: (id: any, fields: any) => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// -- Customer Management --
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get or create a Stripe customer record for a Convex user.
|
|
45
|
+
* Returns the stripeCustomerId.
|
|
46
|
+
*/
|
|
47
|
+
export async function getOrCreateCustomer(
|
|
48
|
+
ctx: PaymentCtx,
|
|
49
|
+
userId: string,
|
|
50
|
+
): Promise<{ stripeCustomerId: string; isNew: boolean }> {
|
|
51
|
+
const existing = await ctx.db
|
|
52
|
+
.query("customers")
|
|
53
|
+
.withIndex("by_userId", (q: any) => q.eq("userId", userId))
|
|
54
|
+
.first();
|
|
55
|
+
|
|
56
|
+
if (existing) {
|
|
57
|
+
return { stripeCustomerId: existing.stripeCustomerId, isNew: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create Stripe customer via API
|
|
61
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
62
|
+
if (!stripeSecretKey) {
|
|
63
|
+
throw new Error("STRIPE_SECRET_KEY is not configured");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const user = await ctx.db.get(userId as any);
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
if (user?.email) params.set("email", user.email);
|
|
69
|
+
if (user?.name) params.set("name", user.name);
|
|
70
|
+
if (user?.phone) params.set("phone", user.phone);
|
|
71
|
+
params.set("metadata[convexUserId]", userId);
|
|
72
|
+
|
|
73
|
+
const response = await fetch("https://api.stripe.com/v1/customers", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: `Bearer ${stripeSecretKey}`,
|
|
77
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
78
|
+
},
|
|
79
|
+
body: params,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const errorText = await response.text();
|
|
84
|
+
throw new Error(`Stripe customer creation failed: ${errorText}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const customer = await response.json();
|
|
88
|
+
|
|
89
|
+
await ctx.db.insert("customers", {
|
|
90
|
+
userId,
|
|
91
|
+
stripeCustomerId: customer.id,
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { stripeCustomerId: customer.id, isNew: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -- Checkout --
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a Stripe Checkout Session.
|
|
102
|
+
* Returns the checkout URL for redirecting the user.
|
|
103
|
+
*/
|
|
104
|
+
export async function createCheckoutSession(
|
|
105
|
+
ctx: PaymentCtx,
|
|
106
|
+
params: CheckoutSessionParams,
|
|
107
|
+
): Promise<{ url: string; sessionId: string }> {
|
|
108
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
109
|
+
if (!stripeSecretKey) {
|
|
110
|
+
throw new Error("STRIPE_SECRET_KEY is not configured");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { stripeCustomerId } = await getOrCreateCustomer(ctx, params.userId);
|
|
114
|
+
|
|
115
|
+
const body = new URLSearchParams();
|
|
116
|
+
body.set("customer", stripeCustomerId);
|
|
117
|
+
body.set("mode", "subscription");
|
|
118
|
+
body.set("line_items[0][price]", params.priceId);
|
|
119
|
+
body.set("line_items[0][quantity]", "1");
|
|
120
|
+
body.set("success_url", params.successUrl);
|
|
121
|
+
body.set("cancel_url", params.cancelUrl);
|
|
122
|
+
|
|
123
|
+
if (params.metadata) {
|
|
124
|
+
for (const [key, value] of Object.entries(params.metadata)) {
|
|
125
|
+
body.set(`metadata[${key}]`, value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
body.set("metadata[convexUserId]", params.userId);
|
|
129
|
+
|
|
130
|
+
const response = await fetch(
|
|
131
|
+
"https://api.stripe.com/v1/checkout/sessions",
|
|
132
|
+
{
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
Authorization: `Bearer ${stripeSecretKey}`,
|
|
136
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
137
|
+
},
|
|
138
|
+
body,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const errorText = await response.text();
|
|
144
|
+
throw new Error(`Stripe checkout session creation failed: ${errorText}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const session = await response.json();
|
|
148
|
+
return { url: session.url, sessionId: session.id };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -- Subscription Status --
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the subscription status for a user.
|
|
155
|
+
*/
|
|
156
|
+
export async function getSubscriptionStatus(
|
|
157
|
+
ctx: PaymentCtx,
|
|
158
|
+
userId: string,
|
|
159
|
+
): Promise<SubscriptionStatus> {
|
|
160
|
+
const subscription = await ctx.db
|
|
161
|
+
.query("subscriptions")
|
|
162
|
+
.withIndex("by_userId", (q: any) => q.eq("userId", userId))
|
|
163
|
+
.first();
|
|
164
|
+
|
|
165
|
+
if (!subscription) {
|
|
166
|
+
return {
|
|
167
|
+
isActive: false,
|
|
168
|
+
status: null,
|
|
169
|
+
priceId: null,
|
|
170
|
+
currentPeriodEnd: null,
|
|
171
|
+
cancelAtPeriodEnd: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const activeStatuses = ["active", "trialing"];
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
isActive: activeStatuses.includes(subscription.status),
|
|
179
|
+
status: subscription.status,
|
|
180
|
+
priceId: subscription.priceId ?? null,
|
|
181
|
+
currentPeriodEnd: subscription.currentPeriodEnd ?? null,
|
|
182
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd ?? false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -- Webhook Handling --
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stripe webhook event types that this handler processes.
|
|
190
|
+
*/
|
|
191
|
+
type StripeWebhookEventType =
|
|
192
|
+
| "customer.subscription.created"
|
|
193
|
+
| "customer.subscription.updated"
|
|
194
|
+
| "customer.subscription.deleted"
|
|
195
|
+
| "checkout.session.completed";
|
|
196
|
+
|
|
197
|
+
interface StripeWebhookEvent {
|
|
198
|
+
type: string;
|
|
199
|
+
data: {
|
|
200
|
+
object: Record<string, any>;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Handle a Stripe webhook event.
|
|
206
|
+
*
|
|
207
|
+
* Designed to be called from a Convex HTTP endpoint. The caller is responsible
|
|
208
|
+
* for verifying the webhook signature before calling this function.
|
|
209
|
+
*
|
|
210
|
+
* Processes:
|
|
211
|
+
* - customer.subscription.created/updated/deleted — syncs subscription state
|
|
212
|
+
* - checkout.session.completed — links customer to user if needed
|
|
213
|
+
*
|
|
214
|
+
* Usage:
|
|
215
|
+
* ```ts
|
|
216
|
+
* // convex/http.ts
|
|
217
|
+
* import { handleStripeWebhook } from "@supa-media/convex/payments";
|
|
218
|
+
*
|
|
219
|
+
* const http = httpRouter();
|
|
220
|
+
* http.route({
|
|
221
|
+
* path: "/stripe/webhook",
|
|
222
|
+
* method: "POST",
|
|
223
|
+
* handler: httpAction(async (ctx, request) => {
|
|
224
|
+
* const body = await request.text();
|
|
225
|
+
* // Verify signature here...
|
|
226
|
+
* const event = JSON.parse(body);
|
|
227
|
+
* await handleStripeWebhook(ctx, event);
|
|
228
|
+
* return new Response("ok", { status: 200 });
|
|
229
|
+
* }),
|
|
230
|
+
* });
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
export async function handleStripeWebhook(
|
|
234
|
+
ctx: PaymentCtx,
|
|
235
|
+
event: StripeWebhookEvent,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const { type, data } = event;
|
|
238
|
+
const obj = data.object;
|
|
239
|
+
|
|
240
|
+
switch (type as StripeWebhookEventType) {
|
|
241
|
+
case "customer.subscription.created":
|
|
242
|
+
case "customer.subscription.updated": {
|
|
243
|
+
const stripeSubscriptionId = obj.id;
|
|
244
|
+
const stripeCustomerId = obj.customer;
|
|
245
|
+
const status = obj.status;
|
|
246
|
+
|
|
247
|
+
// Find the user by Stripe customer ID
|
|
248
|
+
const customer = await ctx.db
|
|
249
|
+
.query("customers")
|
|
250
|
+
.withIndex("by_stripeCustomerId", (q: any) =>
|
|
251
|
+
q.eq("stripeCustomerId", stripeCustomerId),
|
|
252
|
+
)
|
|
253
|
+
.first();
|
|
254
|
+
|
|
255
|
+
if (!customer) {
|
|
256
|
+
console.warn(
|
|
257
|
+
`Stripe webhook: no customer found for ${stripeCustomerId}`,
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check for existing subscription record
|
|
263
|
+
const existingSub = await ctx.db
|
|
264
|
+
.query("subscriptions")
|
|
265
|
+
.withIndex("by_stripeSubscriptionId", (q: any) =>
|
|
266
|
+
q.eq("stripeSubscriptionId", stripeSubscriptionId),
|
|
267
|
+
)
|
|
268
|
+
.first();
|
|
269
|
+
|
|
270
|
+
const subData = {
|
|
271
|
+
userId: customer.userId,
|
|
272
|
+
stripeSubscriptionId,
|
|
273
|
+
status,
|
|
274
|
+
priceId: obj.items?.data?.[0]?.price?.id,
|
|
275
|
+
productId: obj.items?.data?.[0]?.price?.product,
|
|
276
|
+
currentPeriodStart: obj.current_period_start
|
|
277
|
+
? obj.current_period_start * 1000
|
|
278
|
+
: undefined,
|
|
279
|
+
currentPeriodEnd: obj.current_period_end
|
|
280
|
+
? obj.current_period_end * 1000
|
|
281
|
+
: undefined,
|
|
282
|
+
cancelAtPeriodEnd: obj.cancel_at_period_end ?? false,
|
|
283
|
+
updatedAt: Date.now(),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (existingSub) {
|
|
287
|
+
await ctx.db.patch(existingSub._id, subData);
|
|
288
|
+
} else {
|
|
289
|
+
await ctx.db.insert("subscriptions", {
|
|
290
|
+
...subData,
|
|
291
|
+
createdAt: Date.now(),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case "customer.subscription.deleted": {
|
|
298
|
+
const stripeSubscriptionId = obj.id;
|
|
299
|
+
|
|
300
|
+
const existingSub = await ctx.db
|
|
301
|
+
.query("subscriptions")
|
|
302
|
+
.withIndex("by_stripeSubscriptionId", (q: any) =>
|
|
303
|
+
q.eq("stripeSubscriptionId", stripeSubscriptionId),
|
|
304
|
+
)
|
|
305
|
+
.first();
|
|
306
|
+
|
|
307
|
+
if (existingSub) {
|
|
308
|
+
await ctx.db.patch(existingSub._id, {
|
|
309
|
+
status: "canceled",
|
|
310
|
+
updatedAt: Date.now(),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case "checkout.session.completed": {
|
|
317
|
+
// Link customer to user if convexUserId is in metadata
|
|
318
|
+
const convexUserId = obj.metadata?.convexUserId;
|
|
319
|
+
const stripeCustomerId = obj.customer;
|
|
320
|
+
|
|
321
|
+
if (convexUserId && stripeCustomerId) {
|
|
322
|
+
const existing = await ctx.db
|
|
323
|
+
.query("customers")
|
|
324
|
+
.withIndex("by_stripeCustomerId", (q: any) =>
|
|
325
|
+
q.eq("stripeCustomerId", stripeCustomerId),
|
|
326
|
+
)
|
|
327
|
+
.first();
|
|
328
|
+
|
|
329
|
+
if (!existing) {
|
|
330
|
+
await ctx.db.insert("customers", {
|
|
331
|
+
userId: convexUserId,
|
|
332
|
+
stripeCustomerId,
|
|
333
|
+
createdAt: Date.now(),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Verify a Stripe webhook signature.
|
|
344
|
+
* Uses the raw body and the Stripe-Signature header.
|
|
345
|
+
*
|
|
346
|
+
* Returns the parsed event if valid, throws if invalid.
|
|
347
|
+
*/
|
|
348
|
+
export async function verifyStripeSignature(
|
|
349
|
+
rawBody: string,
|
|
350
|
+
signature: string,
|
|
351
|
+
endpointSecret?: string,
|
|
352
|
+
): Promise<StripeWebhookEvent> {
|
|
353
|
+
const secret = endpointSecret ?? process.env.STRIPE_WEBHOOK_SECRET;
|
|
354
|
+
if (!secret) {
|
|
355
|
+
throw new Error("STRIPE_WEBHOOK_SECRET is not configured");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Parse the signature header
|
|
359
|
+
const elements = signature.split(",");
|
|
360
|
+
const timestamp = elements
|
|
361
|
+
.find((e) => e.startsWith("t="))
|
|
362
|
+
?.slice(2);
|
|
363
|
+
const v1Signature = elements
|
|
364
|
+
.find((e) => e.startsWith("v1="))
|
|
365
|
+
?.slice(3);
|
|
366
|
+
|
|
367
|
+
if (!timestamp || !v1Signature) {
|
|
368
|
+
throw new Error("Invalid Stripe signature format");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check timestamp tolerance (5 minutes)
|
|
372
|
+
const tolerance = 5 * 60;
|
|
373
|
+
const now = Math.floor(Date.now() / 1000);
|
|
374
|
+
if (Math.abs(now - parseInt(timestamp)) > tolerance) {
|
|
375
|
+
throw new Error("Stripe webhook timestamp too old");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Compute expected signature
|
|
379
|
+
const signedPayload = `${timestamp}.${rawBody}`;
|
|
380
|
+
const encoder = new TextEncoder();
|
|
381
|
+
const key = await crypto.subtle.importKey(
|
|
382
|
+
"raw",
|
|
383
|
+
encoder.encode(secret),
|
|
384
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
385
|
+
false,
|
|
386
|
+
["sign"],
|
|
387
|
+
);
|
|
388
|
+
const signatureBytes = await crypto.subtle.sign(
|
|
389
|
+
"HMAC",
|
|
390
|
+
key,
|
|
391
|
+
encoder.encode(signedPayload),
|
|
392
|
+
);
|
|
393
|
+
const expectedSignature = Array.from(new Uint8Array(signatureBytes))
|
|
394
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
395
|
+
.join("");
|
|
396
|
+
|
|
397
|
+
// Constant-time comparison to prevent timing attacks (see
|
|
398
|
+
// ../webhooks/hmac.ts's timingSafeEqual for the rationale).
|
|
399
|
+
if (!timingSafeEqual(expectedSignature, v1Signature)) {
|
|
400
|
+
throw new Error("Invalid Stripe webhook signature");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return JSON.parse(rawBody) as StripeWebhookEvent;
|
|
404
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Auth Tables
|
|
3
|
+
*
|
|
4
|
+
* Provides `supaAuthTables` — spreads the @convex-dev/auth tables and adds
|
|
5
|
+
* a `users` table with sensible defaults for phone/email OTP apps.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* // convex/schema.ts
|
|
10
|
+
* import { defineSchema } from "convex/server";
|
|
11
|
+
* import { supaAuthTables } from "@supa-media/convex/schema";
|
|
12
|
+
*
|
|
13
|
+
* export default defineSchema({
|
|
14
|
+
* ...supaAuthTables,
|
|
15
|
+
* // your other tables...
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { authTables } from "@convex-dev/auth/server";
|
|
21
|
+
import { defineTable } from "convex/server";
|
|
22
|
+
import { v } from "convex/values";
|
|
23
|
+
|
|
24
|
+
export const supaAuthTables = {
|
|
25
|
+
...authTables,
|
|
26
|
+
users: defineTable({
|
|
27
|
+
name: v.optional(v.string()),
|
|
28
|
+
email: v.optional(v.string()),
|
|
29
|
+
phone: v.optional(v.string()),
|
|
30
|
+
image: v.optional(v.string()),
|
|
31
|
+
emailVerificationTime: v.optional(v.number()),
|
|
32
|
+
phoneVerificationTime: v.optional(v.number()),
|
|
33
|
+
isActive: v.optional(v.boolean()),
|
|
34
|
+
createdAt: v.optional(v.number()),
|
|
35
|
+
})
|
|
36
|
+
.index("by_email", ["email"])
|
|
37
|
+
.index("by_phone", ["phone"]),
|
|
38
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Tables
|
|
3
|
+
*
|
|
4
|
+
* Schema tables for real-time chat — channels, membership, and messages.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineSchema } from "convex/server";
|
|
9
|
+
* import { supaAuthTables, supaChatTables } from "@supa-media/convex/schema";
|
|
10
|
+
*
|
|
11
|
+
* export default defineSchema({
|
|
12
|
+
* ...supaAuthTables,
|
|
13
|
+
* ...supaChatTables,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTable } from "convex/server";
|
|
19
|
+
import { v } from "convex/values";
|
|
20
|
+
|
|
21
|
+
export const supaChatTables = {
|
|
22
|
+
/** Chat channels (groups, DMs, etc.). */
|
|
23
|
+
channels: defineTable({
|
|
24
|
+
name: v.optional(v.string()),
|
|
25
|
+
type: v.optional(v.string()),
|
|
26
|
+
metadata: v.optional(v.any()),
|
|
27
|
+
createdBy: v.optional(v.id("users")),
|
|
28
|
+
createdAt: v.number(),
|
|
29
|
+
}).index("by_type", ["type"]),
|
|
30
|
+
|
|
31
|
+
/** Channel membership — tracks which users belong to which channels. */
|
|
32
|
+
channelMembers: defineTable({
|
|
33
|
+
channelId: v.id("channels"),
|
|
34
|
+
userId: v.id("users"),
|
|
35
|
+
role: v.optional(v.string()),
|
|
36
|
+
lastReadAt: v.optional(v.number()),
|
|
37
|
+
joinedAt: v.optional(v.number()),
|
|
38
|
+
})
|
|
39
|
+
.index("by_channelId", ["channelId"])
|
|
40
|
+
.index("by_userId", ["userId"])
|
|
41
|
+
.index("by_channelId_userId", ["channelId", "userId"]),
|
|
42
|
+
|
|
43
|
+
/** Chat messages. */
|
|
44
|
+
messages: defineTable({
|
|
45
|
+
channelId: v.id("channels"),
|
|
46
|
+
userId: v.id("users"),
|
|
47
|
+
body: v.string(),
|
|
48
|
+
attachments: v.optional(v.array(v.any())),
|
|
49
|
+
replyTo: v.optional(v.id("messages")),
|
|
50
|
+
isEdited: v.optional(v.boolean()),
|
|
51
|
+
isDeleted: v.optional(v.boolean()),
|
|
52
|
+
createdAt: v.number(),
|
|
53
|
+
editedAt: v.optional(v.number()),
|
|
54
|
+
})
|
|
55
|
+
.index("by_channelId", ["channelId"])
|
|
56
|
+
.index("by_channelId_createdAt", ["channelId", "createdAt"])
|
|
57
|
+
.index("by_userId", ["userId"]),
|
|
58
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { supaAuthTables } from "./authTables";
|
|
2
|
+
export { supaTenantTables } from "./tenantTables";
|
|
3
|
+
export type { TenantTableConfig } from "./tenantTables";
|
|
4
|
+
export { supaTenantScope } from "./tenantScoping";
|
|
5
|
+
export type { TenantScopeConfig, SupaTenantScope } from "./tenantScoping";
|
|
6
|
+
export { supaNotificationTables } from "./notificationTables";
|
|
7
|
+
export { supaChatTables } from "./chatTables";
|
|
8
|
+
export { supaPaymentTables } from "./paymentTables";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Tables
|
|
3
|
+
*
|
|
4
|
+
* Schema tables for push notification token storage and outbound notification queue.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineSchema } from "convex/server";
|
|
9
|
+
* import { supaAuthTables, supaNotificationTables } from "@supa-media/convex/schema";
|
|
10
|
+
*
|
|
11
|
+
* export default defineSchema({
|
|
12
|
+
* ...supaAuthTables,
|
|
13
|
+
* ...supaNotificationTables,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTable } from "convex/server";
|
|
19
|
+
import { v } from "convex/values";
|
|
20
|
+
|
|
21
|
+
export const supaNotificationTables = {
|
|
22
|
+
/**
|
|
23
|
+
* Device push tokens per user.
|
|
24
|
+
* Each device registration creates a row. Users may have multiple devices.
|
|
25
|
+
*/
|
|
26
|
+
pushTokens: defineTable({
|
|
27
|
+
userId: v.id("users"),
|
|
28
|
+
token: v.string(),
|
|
29
|
+
platform: v.union(v.literal("ios"), v.literal("android"), v.literal("web")),
|
|
30
|
+
createdAt: v.number(),
|
|
31
|
+
})
|
|
32
|
+
.index("by_userId", ["userId"])
|
|
33
|
+
.index("by_token", ["token"]),
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Outbound notification queue.
|
|
37
|
+
* Notifications are enqueued here and processed by a cron or scheduled function.
|
|
38
|
+
*/
|
|
39
|
+
notificationQueue: defineTable({
|
|
40
|
+
userId: v.id("users"),
|
|
41
|
+
title: v.string(),
|
|
42
|
+
body: v.string(),
|
|
43
|
+
image: v.optional(v.string()),
|
|
44
|
+
deepLink: v.optional(v.string()),
|
|
45
|
+
data: v.optional(v.any()),
|
|
46
|
+
status: v.union(
|
|
47
|
+
v.literal("pending"),
|
|
48
|
+
v.literal("sent"),
|
|
49
|
+
v.literal("failed"),
|
|
50
|
+
),
|
|
51
|
+
error: v.optional(v.string()),
|
|
52
|
+
createdAt: v.number(),
|
|
53
|
+
sentAt: v.optional(v.number()),
|
|
54
|
+
})
|
|
55
|
+
.index("by_status", ["status"])
|
|
56
|
+
.index("by_userId", ["userId"])
|
|
57
|
+
.index("by_status_createdAt", ["status", "createdAt"]),
|
|
58
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment Tables
|
|
3
|
+
*
|
|
4
|
+
* Schema tables for Stripe customer mapping and subscription state.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineSchema } from "convex/server";
|
|
9
|
+
* import { supaAuthTables, supaPaymentTables } from "@supa-media/convex/schema";
|
|
10
|
+
*
|
|
11
|
+
* export default defineSchema({
|
|
12
|
+
* ...supaAuthTables,
|
|
13
|
+
* ...supaPaymentTables,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTable } from "convex/server";
|
|
19
|
+
import { v } from "convex/values";
|
|
20
|
+
|
|
21
|
+
export const supaPaymentTables = {
|
|
22
|
+
/** Stripe customer mapping — links Convex users to Stripe customers. */
|
|
23
|
+
customers: defineTable({
|
|
24
|
+
userId: v.id("users"),
|
|
25
|
+
stripeCustomerId: v.string(),
|
|
26
|
+
createdAt: v.optional(v.number()),
|
|
27
|
+
})
|
|
28
|
+
.index("by_userId", ["userId"])
|
|
29
|
+
.index("by_stripeCustomerId", ["stripeCustomerId"]),
|
|
30
|
+
|
|
31
|
+
/** Subscription state — tracks active Stripe subscriptions. */
|
|
32
|
+
subscriptions: defineTable({
|
|
33
|
+
userId: v.id("users"),
|
|
34
|
+
stripeSubscriptionId: v.string(),
|
|
35
|
+
status: v.string(),
|
|
36
|
+
priceId: v.optional(v.string()),
|
|
37
|
+
productId: v.optional(v.string()),
|
|
38
|
+
currentPeriodStart: v.optional(v.number()),
|
|
39
|
+
currentPeriodEnd: v.optional(v.number()),
|
|
40
|
+
cancelAtPeriodEnd: v.optional(v.boolean()),
|
|
41
|
+
createdAt: v.optional(v.number()),
|
|
42
|
+
updatedAt: v.optional(v.number()),
|
|
43
|
+
})
|
|
44
|
+
.index("by_userId", ["userId"])
|
|
45
|
+
.index("by_stripeSubscriptionId", ["stripeSubscriptionId"])
|
|
46
|
+
.index("by_status", ["status"]),
|
|
47
|
+
};
|