@goscribe/server 1.2.0 → 1.3.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.
Files changed (126) hide show
  1. package/check-difficulty.cjs +14 -0
  2. package/check-questions.cjs +14 -0
  3. package/db-summary.cjs +22 -0
  4. package/dist/context.d.ts +5 -1
  5. package/dist/lib/activity_human_description.d.ts +13 -0
  6. package/dist/lib/activity_human_description.js +221 -0
  7. package/dist/lib/activity_human_description.test.d.ts +1 -0
  8. package/dist/lib/activity_human_description.test.js +16 -0
  9. package/dist/lib/activity_log_service.d.ts +87 -0
  10. package/dist/lib/activity_log_service.js +276 -0
  11. package/dist/lib/activity_log_service.test.d.ts +1 -0
  12. package/dist/lib/activity_log_service.test.js +27 -0
  13. package/dist/lib/ai-session.d.ts +15 -2
  14. package/dist/lib/ai-session.js +147 -85
  15. package/dist/lib/constants.d.ts +13 -0
  16. package/dist/lib/constants.js +12 -0
  17. package/dist/lib/email.d.ts +11 -0
  18. package/dist/lib/email.js +193 -0
  19. package/dist/lib/env.d.ts +13 -0
  20. package/dist/lib/env.js +16 -0
  21. package/dist/lib/inference.d.ts +4 -1
  22. package/dist/lib/inference.js +3 -3
  23. package/dist/lib/logger.d.ts +4 -4
  24. package/dist/lib/logger.js +30 -8
  25. package/dist/lib/notification-service.d.ts +152 -0
  26. package/dist/lib/notification-service.js +473 -0
  27. package/dist/lib/notification-service.test.d.ts +1 -0
  28. package/dist/lib/notification-service.test.js +87 -0
  29. package/dist/lib/prisma.d.ts +2 -1
  30. package/dist/lib/prisma.js +5 -1
  31. package/dist/lib/pusher.d.ts +23 -0
  32. package/dist/lib/pusher.js +69 -5
  33. package/dist/lib/retry.d.ts +15 -0
  34. package/dist/lib/retry.js +37 -0
  35. package/dist/lib/storage.js +2 -2
  36. package/dist/lib/stripe.d.ts +9 -0
  37. package/dist/lib/stripe.js +36 -0
  38. package/dist/lib/subscription_service.d.ts +37 -0
  39. package/dist/lib/subscription_service.js +654 -0
  40. package/dist/lib/usage_service.d.ts +26 -0
  41. package/dist/lib/usage_service.js +59 -0
  42. package/dist/lib/worksheet-generation.d.ts +91 -0
  43. package/dist/lib/worksheet-generation.js +95 -0
  44. package/dist/lib/worksheet-generation.test.d.ts +1 -0
  45. package/dist/lib/worksheet-generation.test.js +20 -0
  46. package/dist/lib/workspace-access.d.ts +18 -0
  47. package/dist/lib/workspace-access.js +13 -0
  48. package/dist/routers/_app.d.ts +1349 -253
  49. package/dist/routers/_app.js +10 -0
  50. package/dist/routers/admin.d.ts +361 -0
  51. package/dist/routers/admin.js +633 -0
  52. package/dist/routers/annotations.d.ts +219 -0
  53. package/dist/routers/annotations.js +187 -0
  54. package/dist/routers/auth.d.ts +88 -7
  55. package/dist/routers/auth.js +339 -19
  56. package/dist/routers/chat.d.ts +6 -12
  57. package/dist/routers/copilot.d.ts +199 -0
  58. package/dist/routers/copilot.js +571 -0
  59. package/dist/routers/flashcards.d.ts +47 -81
  60. package/dist/routers/flashcards.js +143 -27
  61. package/dist/routers/members.d.ts +36 -7
  62. package/dist/routers/members.js +200 -19
  63. package/dist/routers/notifications.d.ts +99 -0
  64. package/dist/routers/notifications.js +127 -0
  65. package/dist/routers/payment.d.ts +89 -0
  66. package/dist/routers/payment.js +403 -0
  67. package/dist/routers/podcast.d.ts +8 -13
  68. package/dist/routers/podcast.js +54 -31
  69. package/dist/routers/studyguide.d.ts +1 -29
  70. package/dist/routers/studyguide.js +80 -71
  71. package/dist/routers/worksheets.d.ts +105 -38
  72. package/dist/routers/worksheets.js +258 -68
  73. package/dist/routers/workspace.d.ts +139 -60
  74. package/dist/routers/workspace.js +455 -315
  75. package/dist/scripts/purge-deleted-users.d.ts +1 -0
  76. package/dist/scripts/purge-deleted-users.js +149 -0
  77. package/dist/server.js +130 -10
  78. package/dist/services/flashcard-progress.service.d.ts +18 -66
  79. package/dist/services/flashcard-progress.service.js +51 -42
  80. package/dist/trpc.d.ts +20 -21
  81. package/dist/trpc.js +150 -1
  82. package/mcq-test.cjs +36 -0
  83. package/package.json +9 -2
  84. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  85. package/prisma/schema.prisma +471 -324
  86. package/src/context.ts +4 -1
  87. package/src/lib/activity_human_description.test.ts +28 -0
  88. package/src/lib/activity_human_description.ts +239 -0
  89. package/src/lib/activity_log_service.test.ts +37 -0
  90. package/src/lib/activity_log_service.ts +353 -0
  91. package/src/lib/ai-session.ts +79 -51
  92. package/src/lib/email.ts +213 -29
  93. package/src/lib/env.ts +23 -6
  94. package/src/lib/inference.ts +2 -2
  95. package/src/lib/notification-service.test.ts +106 -0
  96. package/src/lib/notification-service.ts +677 -0
  97. package/src/lib/prisma.ts +6 -1
  98. package/src/lib/pusher.ts +86 -2
  99. package/src/lib/stripe.ts +39 -0
  100. package/src/lib/subscription_service.ts +722 -0
  101. package/src/lib/usage_service.ts +74 -0
  102. package/src/lib/worksheet-generation.test.ts +31 -0
  103. package/src/lib/worksheet-generation.ts +139 -0
  104. package/src/routers/_app.ts +9 -0
  105. package/src/routers/admin.ts +710 -0
  106. package/src/routers/annotations.ts +41 -0
  107. package/src/routers/auth.ts +338 -28
  108. package/src/routers/copilot.ts +719 -0
  109. package/src/routers/flashcards.ts +201 -68
  110. package/src/routers/members.ts +280 -80
  111. package/src/routers/notifications.ts +142 -0
  112. package/src/routers/payment.ts +448 -0
  113. package/src/routers/podcast.ts +112 -83
  114. package/src/routers/studyguide.ts +12 -0
  115. package/src/routers/worksheets.ts +289 -66
  116. package/src/routers/workspace.ts +329 -122
  117. package/src/scripts/purge-deleted-users.ts +167 -0
  118. package/src/server.ts +137 -11
  119. package/src/services/flashcard-progress.service.ts +49 -37
  120. package/src/trpc.ts +184 -5
  121. package/test-generate.js +30 -0
  122. package/test-ratio.cjs +9 -0
  123. package/zod-test.cjs +22 -0
  124. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  125. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  126. package/prisma/seed.mjs +0 -135
@@ -0,0 +1,142 @@
1
+ import { z } from 'zod';
2
+ import { authedProcedure, router } from '../trpc.js';
3
+ import PusherService from '../lib/pusher.js';
4
+
5
+ const listInputSchema = z.object({
6
+ cursor: z.string().optional(),
7
+ limit: z.number().min(1).max(50).default(20),
8
+ unreadOnly: z.boolean().optional(),
9
+ types: z.array(z.string()).optional(),
10
+ });
11
+
12
+ export const notifications = router({
13
+ list: authedProcedure
14
+ .input(listInputSchema)
15
+ .query(async ({ ctx, input }) => {
16
+ const { cursor, limit, unreadOnly, types } = input;
17
+ const userId = ctx.userId;
18
+
19
+ const items = await ctx.db.notification.findMany({
20
+ where: {
21
+ userId,
22
+ ...(unreadOnly ? { read: false } : {}),
23
+ ...(types?.length ? { type: { in: types } } : {}),
24
+ },
25
+ include: {
26
+ actor: {
27
+ select: {
28
+ id: true,
29
+ name: true,
30
+ email: true,
31
+ },
32
+ },
33
+ workspace: {
34
+ select: {
35
+ id: true,
36
+ title: true,
37
+ },
38
+ },
39
+ },
40
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
41
+ take: limit + 1,
42
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
43
+ });
44
+
45
+ let nextCursor: string | undefined;
46
+ if (items.length > limit) {
47
+ const next = items.pop();
48
+ nextCursor = next?.id;
49
+ }
50
+
51
+ return {
52
+ items,
53
+ nextCursor,
54
+ };
55
+ }),
56
+
57
+ unreadCount: authedProcedure.query(async ({ ctx }) => {
58
+ const count = await ctx.db.notification.count({
59
+ where: {
60
+ userId: ctx.userId,
61
+ read: false,
62
+ },
63
+ });
64
+ return { count };
65
+ }),
66
+
67
+ markRead: authedProcedure
68
+ .input(z.object({ id: z.string() }))
69
+ .mutation(async ({ ctx, input }) => {
70
+ const now = new Date();
71
+ await ctx.db.notification.updateMany({
72
+ where: {
73
+ id: input.id,
74
+ userId: ctx.userId,
75
+ },
76
+ data: {
77
+ read: true,
78
+ readAt: now,
79
+ },
80
+ });
81
+
82
+ const unreadCount = await ctx.db.notification.count({
83
+ where: { userId: ctx.userId, read: false },
84
+ });
85
+ await PusherService.emitNotificationReadState(ctx.userId, { unreadCount });
86
+
87
+ return { success: true };
88
+ }),
89
+
90
+ markManyRead: authedProcedure
91
+ .input(z.object({ ids: z.array(z.string()).min(1) }))
92
+ .mutation(async ({ ctx, input }) => {
93
+ const now = new Date();
94
+ await ctx.db.notification.updateMany({
95
+ where: {
96
+ id: { in: input.ids },
97
+ userId: ctx.userId,
98
+ },
99
+ data: {
100
+ read: true,
101
+ readAt: now,
102
+ },
103
+ });
104
+
105
+ const unreadCount = await ctx.db.notification.count({
106
+ where: { userId: ctx.userId, read: false },
107
+ });
108
+ await PusherService.emitNotificationReadState(ctx.userId, { unreadCount });
109
+
110
+ return { success: true };
111
+ }),
112
+
113
+ markAllRead: authedProcedure
114
+ .mutation(async ({ ctx }) => {
115
+ const now = new Date();
116
+ await ctx.db.notification.updateMany({
117
+ where: {
118
+ userId: ctx.userId,
119
+ read: false,
120
+ },
121
+ data: {
122
+ read: true,
123
+ readAt: now,
124
+ },
125
+ });
126
+
127
+ await PusherService.emitNotificationReadState(ctx.userId, { unreadCount: 0 });
128
+ return { success: true };
129
+ }),
130
+
131
+ delete: authedProcedure
132
+ .input(z.object({ id: z.string() }))
133
+ .mutation(async ({ ctx, input }) => {
134
+ await ctx.db.notification.deleteMany({
135
+ where: {
136
+ id: input.id,
137
+ userId: ctx.userId,
138
+ },
139
+ });
140
+ return { success: true };
141
+ }),
142
+ });
@@ -0,0 +1,448 @@
1
+ import { z } from 'zod';
2
+ import { router, verifiedProcedure } from '../trpc.js';
3
+ import { TRPCError } from '@trpc/server';
4
+ import { stripe } from '../lib/stripe.js';
5
+ import { env } from '../lib/env.js';
6
+ import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
7
+ import {
8
+ notifyPaymentSucceeded,
9
+ notifySubscriptionActivated,
10
+ notifySubscriptionPaymentSucceeded,
11
+ } from '../lib/notification-service.js';
12
+ import { upsertSubscriptionFromStripe } from '../lib/subscription_service.js';
13
+ import { ArtifactType } from '../lib/prisma.js';
14
+
15
+ const ArtifactTypeUnion = z.enum(['STUDY_GUIDE', 'FLASHCARD_SET', 'WORKSHEET', 'MEETING_SUMMARY', 'PODCAST_EPISODE', 'STORAGE']);
16
+
17
+ /** Stripe Checkout URLs contain the session id (cs_…); we only store the URL in DB. */
18
+ const CHECKOUT_SESSION_ID_IN_URL = /cs_[a-zA-Z0-9]+/;
19
+
20
+ /**
21
+ * On idempotency conflict, we may have a stored checkout URL. Only reuse it if the
22
+ * Stripe session is still `open`. Otherwise release the row so a new session can be created
23
+ * (avoids redirecting users to a completed/expired checkout = "already completed").
24
+ */
25
+ async function reuseCheckoutUrlIfSessionStillOpen(
26
+ stripeClient: Stripe,
27
+ db: PrismaClient,
28
+ existing: { id: string; stripeSessionId: string | null },
29
+ lockKey: string,
30
+ ): Promise<string | null> {
31
+ const url = existing.stripeSessionId;
32
+ if (!url) return null;
33
+
34
+ const idMatch = url.match(CHECKOUT_SESSION_ID_IN_URL);
35
+ if (!idMatch) {
36
+ await db.idempotencyRecord.updateMany({
37
+ where: { id: existing.id, activeLockKey: lockKey },
38
+ data: { activeLockKey: null, status: 'expired' },
39
+ });
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ const session = await stripeClient.checkout.sessions.retrieve(idMatch[0]);
45
+ if (session.status === 'open') {
46
+ return url;
47
+ }
48
+ await db.idempotencyRecord.updateMany({
49
+ where: { id: existing.id, activeLockKey: lockKey },
50
+ data: {
51
+ activeLockKey: null,
52
+ status: session.status === 'complete' ? 'completed' : 'expired',
53
+ },
54
+ });
55
+ return null;
56
+ } catch {
57
+ await db.idempotencyRecord.updateMany({
58
+ where: { id: existing.id, activeLockKey: lockKey },
59
+ data: { activeLockKey: null, status: 'expired' },
60
+ });
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export const paymentRouter = router({
66
+ getPlans: verifiedProcedure
67
+ .query(async (opts) => {
68
+ const ctx = opts.ctx;
69
+ const userId = ctx.userId;
70
+
71
+ const plans = await ctx.db.plan.findMany({
72
+ where: { active: true },
73
+ include: { limit: true },
74
+ orderBy: { price: 'asc' }
75
+ });
76
+
77
+ const activeSubscriptions = await ctx.db.subscription.findMany({
78
+ where: {
79
+ userId: userId,
80
+ status: 'active'
81
+ }
82
+ });
83
+
84
+ return plans.map((plan: any) => {
85
+ return {
86
+ ...plan,
87
+ isActive: activeSubscriptions.some((sub: any) => sub.planId === plan.id)
88
+ };
89
+ });
90
+ }),
91
+
92
+ createCheckoutSession: verifiedProcedure
93
+ .input(z.object({
94
+ planId: z.string()
95
+ }))
96
+ .mutation(async (opts) => {
97
+ const ctx = opts.ctx;
98
+ const input = opts.input;
99
+ const userId = ctx.userId;
100
+
101
+ if (!stripe) {
102
+ throw new TRPCError({
103
+ code: 'INTERNAL_SERVER_ERROR',
104
+ message: 'Stripe not configured'
105
+ });
106
+ }
107
+
108
+ const user = await ctx.db.user.findUnique({ where: { id: userId } });
109
+ if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
110
+
111
+ const lockKey = `pending_${userId}_${input.planId}`;
112
+ let attempt = null;
113
+ let retryCount = 0;
114
+
115
+ while (retryCount < 3) {
116
+ try {
117
+ attempt = await ctx.db.idempotencyRecord.create({
118
+ data: {
119
+ userId: userId,
120
+ planId: input.planId,
121
+ activeLockKey: lockKey,
122
+ status: 'pending'
123
+ }
124
+ });
125
+ break;
126
+ } catch (err: any) {
127
+ if (err.code === 'P2002') {
128
+ const existing = await ctx.db.idempotencyRecord.findUnique({
129
+ where: { activeLockKey: lockKey }
130
+ });
131
+ if (!existing) { retryCount++; continue; }
132
+
133
+ const isStale = (Date.now() - existing.updatedAt.getTime()) > (24 * 60 * 60 * 1000);
134
+ if (isStale) {
135
+ const result = await ctx.db.idempotencyRecord.updateMany({
136
+ where: { id: existing.id, activeLockKey: lockKey },
137
+ data: { activeLockKey: null, status: 'expired' }
138
+ });
139
+ if (result.count > 0) { retryCount++; continue; }
140
+ }
141
+
142
+ if (existing.stripeSessionId && stripe) {
143
+ const reusable = await reuseCheckoutUrlIfSessionStillOpen(
144
+ stripe,
145
+ ctx.db,
146
+ existing,
147
+ lockKey,
148
+ );
149
+ if (reusable) return { url: reusable };
150
+ retryCount++;
151
+ continue;
152
+ }
153
+ await new Promise(resolve => setTimeout(resolve, 800));
154
+ retryCount++;
155
+ continue;
156
+ }
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ if (!attempt) throw new TRPCError({ code: 'CONFLICT', message: "Concurrent request" });
162
+
163
+ const plan = await ctx.db.plan.findUnique({ where: { id: input.planId } });
164
+ if (!plan) throw new TRPCError({ code: 'NOT_FOUND', message: "Plan not found" });
165
+
166
+ try {
167
+ const successUrl = env.STRIPE_SUCCESS_URL.includes('session_id=')
168
+ ? env.STRIPE_SUCCESS_URL
169
+ : `${env.STRIPE_SUCCESS_URL}${env.STRIPE_SUCCESS_URL.includes('?') ? '&' : '?'}session_id={CHECKOUT_SESSION_ID}`;
170
+
171
+ const session = await stripe.checkout.sessions.create({
172
+ customer: user.stripe_customer_id || undefined,
173
+ customer_email: user.stripe_customer_id ? undefined : (user.email || undefined),
174
+ line_items: [{ price: plan.stripePriceId, quantity: 1 }],
175
+ mode: plan.interval ? 'subscription' : 'payment',
176
+ subscription_data: plan.interval ? {
177
+ metadata: {
178
+ userId: userId,
179
+ planId: plan.id,
180
+ attemptId: attempt.id
181
+ }
182
+ } : undefined,
183
+ success_url: successUrl,
184
+ cancel_url: env.STRIPE_CANCEL_URL,
185
+ metadata: {
186
+ userId: userId,
187
+ planId: plan.id,
188
+ attemptId: attempt.id
189
+ }
190
+ }, {
191
+ idempotencyKey: attempt.id
192
+ });
193
+
194
+ await ctx.db.idempotencyRecord.update({
195
+ where: { id: attempt.id },
196
+ data: { stripeSessionId: session.url }
197
+ });
198
+
199
+ return { url: session.url };
200
+ } catch (error: any) {
201
+ await ctx.db.idempotencyRecord.update({
202
+ where: { id: attempt.id },
203
+ data: { status: 'failed', activeLockKey: null }
204
+ });
205
+ throw new TRPCError({
206
+ code: 'INTERNAL_SERVER_ERROR',
207
+ message: error.message
208
+ });
209
+ }
210
+ }),
211
+
212
+ confirmCheckoutSuccess: verifiedProcedure
213
+ .input(z.object({
214
+ sessionId: z.string().min(1),
215
+ }))
216
+ .mutation(async (opts) => {
217
+ const ctx = opts.ctx;
218
+ const input = opts.input;
219
+
220
+ if (!stripe) {
221
+ throw new TRPCError({
222
+ code: 'INTERNAL_SERVER_ERROR',
223
+ message: 'Stripe not configured'
224
+ });
225
+ }
226
+
227
+ const session = await stripe.checkout.sessions.retrieve(input.sessionId, {
228
+ expand: ['line_items', 'subscription'],
229
+ });
230
+
231
+ const metadata = session.metadata || {};
232
+ if (metadata.userId !== ctx.userId) {
233
+ throw new TRPCError({
234
+ code: 'FORBIDDEN',
235
+ message: 'This checkout session does not belong to the current user',
236
+ });
237
+ }
238
+
239
+ if (session.status !== 'complete') {
240
+ return { confirmed: false, reason: 'checkout_not_complete' };
241
+ }
242
+
243
+ const plan = metadata.planId
244
+ ? await ctx.db.plan.findUnique({ where: { id: metadata.planId } })
245
+ : null;
246
+
247
+ if (session.mode === 'payment' && session.payment_status === 'paid') {
248
+ await notifyPaymentSucceeded(ctx.db, {
249
+ userId: ctx.userId,
250
+ planId: metadata.planId,
251
+ planName: plan?.name || metadata.planType,
252
+ stripeSessionId: session.id,
253
+ amountPaid: session.amount_total ?? undefined,
254
+ });
255
+ return { confirmed: true, kind: 'payment' as const };
256
+ }
257
+
258
+ if (session.mode === 'subscription') {
259
+ const stripeSubscriptionId =
260
+ typeof session.subscription === 'string'
261
+ ? session.subscription
262
+ : session.subscription?.id;
263
+
264
+ if (stripeSubscriptionId) {
265
+ await upsertSubscriptionFromStripe(stripeSubscriptionId);
266
+ await notifySubscriptionActivated(ctx.db, {
267
+ userId: ctx.userId,
268
+ planId: metadata.planId,
269
+ planName: plan?.name || metadata.planType,
270
+ stripeSubscriptionId,
271
+ });
272
+
273
+ if (session.payment_status === 'paid') {
274
+ await notifySubscriptionPaymentSucceeded(ctx.db, {
275
+ userId: ctx.userId,
276
+ planId: metadata.planId,
277
+ planName: plan?.name || metadata.planType,
278
+ stripeInvoiceId: `checkout_${session.id}`,
279
+ amountPaid: session.amount_total ?? undefined,
280
+ });
281
+ }
282
+ }
283
+ return { confirmed: true, kind: 'subscription' as const };
284
+ }
285
+
286
+ return { confirmed: false, reason: 'unsupported_mode' };
287
+ }),
288
+
289
+ createResourcePurchaseSession: verifiedProcedure
290
+ .input(z.object({
291
+ resourceType: z.nativeEnum(ArtifactType),
292
+ quantity: z.number().min(1).default(1),
293
+ }))
294
+ .mutation(async ({ ctx, input }) => {
295
+ if (!stripe) {
296
+ throw new TRPCError({
297
+ code: 'INTERNAL_SERVER_ERROR',
298
+ message: 'Stripe is not configured on the server',
299
+ });
300
+ }
301
+
302
+ const userId = ctx.userId;
303
+ const user = await ctx.db.user.findUnique({
304
+ where: { id: userId },
305
+ });
306
+
307
+ if (!user) {
308
+ throw new TRPCError({
309
+ code: 'NOT_FOUND',
310
+ message: 'User not found',
311
+ });
312
+ }
313
+
314
+ const resourcePrice = await ctx.db.resourcePrice.findUnique({
315
+ where: { resourceType: input.resourceType },
316
+ });
317
+
318
+ if (!resourcePrice) {
319
+ throw new TRPCError({
320
+ code: 'PRECONDITION_FAILED',
321
+ message: 'Price not set',
322
+ });
323
+ }
324
+
325
+ const lockKey = `topup_${userId}_${input.resourceType}`;
326
+ let attempt = null;
327
+ let retryCount = 0;
328
+
329
+ while (retryCount < 3) {
330
+ try {
331
+ attempt = await ctx.db.idempotencyRecord.create({
332
+ data: {
333
+ userId: userId,
334
+ resourceType: input.resourceType as ArtifactType,
335
+ activeLockKey: lockKey,
336
+ status: 'pending'
337
+ }
338
+ });
339
+ break;
340
+ } catch (err: any) {
341
+ if (err.code === 'P2002') {
342
+ const existing = await ctx.db.idempotencyRecord.findUnique({
343
+ where: { activeLockKey: lockKey }
344
+ });
345
+ if (!existing) { retryCount++; continue; }
346
+
347
+ const isStale = (Date.now() - existing.updatedAt.getTime()) > (24 * 60 * 60 * 1000);
348
+ if (isStale) {
349
+ const result = await ctx.db.idempotencyRecord.updateMany({
350
+ where: { id: existing.id, activeLockKey: lockKey },
351
+ data: { activeLockKey: null, status: 'expired' }
352
+ });
353
+ if (result.count > 0) { retryCount++; continue; }
354
+ }
355
+
356
+ if (existing.stripeSessionId && stripe) {
357
+ const reusable = await reuseCheckoutUrlIfSessionStillOpen(
358
+ stripe,
359
+ ctx.db,
360
+ existing,
361
+ lockKey,
362
+ );
363
+ if (reusable) return { url: reusable };
364
+ retryCount++;
365
+ continue;
366
+ }
367
+ await new Promise(resolve => setTimeout(resolve, 800));
368
+ retryCount++;
369
+ continue;
370
+ }
371
+ throw err;
372
+ }
373
+ }
374
+
375
+ if (!attempt) throw new TRPCError({ code: 'CONFLICT', message: "Concurrent request" });
376
+
377
+ try {
378
+ const resourceName = input.resourceType
379
+ .split('_')
380
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
381
+ .join(' ');
382
+
383
+ const session = await stripe.checkout.sessions.create({
384
+ customer: user.stripe_customer_id || undefined,
385
+ line_items: [
386
+ {
387
+ price_data: {
388
+ currency: 'usd',
389
+ product_data: {
390
+ name: `Add-on: extra ${resourceName}s`,
391
+ description: `Purchase of ${input.quantity} additional ${resourceName}(s)`
392
+ },
393
+ unit_amount: resourcePrice.priceCents
394
+ },
395
+ quantity: input.quantity
396
+ }
397
+ ],
398
+ mode: 'payment',
399
+ success_url: `${env.STRIPE_SUCCESS_URL}?success=true`,
400
+ cancel_url: env.STRIPE_CANCEL_URL,
401
+ metadata: {
402
+ userId: userId,
403
+ resourceType: input.resourceType,
404
+ quantity: input.quantity.toString(),
405
+ isPurchase: 'true',
406
+ attemptId: attempt.id,
407
+ },
408
+ invoice_creation: {
409
+ enabled: true,
410
+ },
411
+ }, {
412
+ idempotencyKey: attempt.id
413
+ });
414
+
415
+ await ctx.db.idempotencyRecord.update({
416
+ where: { id: attempt.id },
417
+ data: { stripeSessionId: session.url }
418
+ });
419
+
420
+ return { url: session.url };
421
+ } catch (error: any) {
422
+ await ctx.db.idempotencyRecord.update({
423
+ where: { id: attempt.id },
424
+ data: { status: 'failed', activeLockKey: null }
425
+ });
426
+ throw new TRPCError({
427
+ code: 'INTERNAL_SERVER_ERROR',
428
+ message: error.message
429
+ });
430
+ }
431
+ }),
432
+
433
+ getUsageOverview: verifiedProcedure
434
+ .query(async (opts) => {
435
+ const ctx = opts.ctx;
436
+ const userId = ctx.userId;
437
+ const [usage, limits] = await Promise.all([
438
+ getUserUsage(userId),
439
+ getUserPlanLimits(userId)
440
+ ]);
441
+ return { usage: usage, limits: limits, hasActivePlan: (!!limits) };
442
+ }),
443
+
444
+ getResourcePrices: verifiedProcedure
445
+ .query(async (opts) => {
446
+ return opts.ctx.db.resourcePrice.findMany();
447
+ })
448
+ });