@goscribe/server 1.3.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 (79) hide show
  1. package/dist/context.d.ts +5 -1
  2. package/dist/lib/activity_human_description.d.ts +13 -0
  3. package/dist/lib/activity_human_description.js +221 -0
  4. package/dist/lib/activity_human_description.test.d.ts +1 -0
  5. package/dist/lib/activity_human_description.test.js +16 -0
  6. package/dist/lib/activity_log_service.d.ts +87 -0
  7. package/dist/lib/activity_log_service.js +276 -0
  8. package/dist/lib/activity_log_service.test.d.ts +1 -0
  9. package/dist/lib/activity_log_service.test.js +27 -0
  10. package/dist/lib/ai-session.d.ts +15 -2
  11. package/dist/lib/ai-session.js +147 -85
  12. package/dist/lib/constants.d.ts +13 -0
  13. package/dist/lib/constants.js +12 -0
  14. package/dist/lib/email.d.ts +11 -0
  15. package/dist/lib/email.js +193 -0
  16. package/dist/lib/env.d.ts +13 -0
  17. package/dist/lib/env.js +16 -0
  18. package/dist/lib/inference.d.ts +4 -1
  19. package/dist/lib/inference.js +3 -3
  20. package/dist/lib/logger.d.ts +4 -4
  21. package/dist/lib/logger.js +30 -8
  22. package/dist/lib/notification-service.d.ts +152 -0
  23. package/dist/lib/notification-service.js +473 -0
  24. package/dist/lib/notification-service.test.d.ts +1 -0
  25. package/dist/lib/notification-service.test.js +87 -0
  26. package/dist/lib/prisma.d.ts +2 -1
  27. package/dist/lib/prisma.js +5 -1
  28. package/dist/lib/pusher.d.ts +23 -0
  29. package/dist/lib/pusher.js +69 -5
  30. package/dist/lib/retry.d.ts +15 -0
  31. package/dist/lib/retry.js +37 -0
  32. package/dist/lib/storage.js +2 -2
  33. package/dist/lib/stripe.d.ts +9 -0
  34. package/dist/lib/stripe.js +36 -0
  35. package/dist/lib/subscription_service.d.ts +37 -0
  36. package/dist/lib/subscription_service.js +654 -0
  37. package/dist/lib/usage_service.d.ts +26 -0
  38. package/dist/lib/usage_service.js +59 -0
  39. package/dist/lib/worksheet-generation.d.ts +91 -0
  40. package/dist/lib/worksheet-generation.js +95 -0
  41. package/dist/lib/worksheet-generation.test.d.ts +1 -0
  42. package/dist/lib/worksheet-generation.test.js +20 -0
  43. package/dist/lib/workspace-access.d.ts +18 -0
  44. package/dist/lib/workspace-access.js +13 -0
  45. package/dist/routers/_app.d.ts +1349 -253
  46. package/dist/routers/_app.js +10 -0
  47. package/dist/routers/admin.d.ts +361 -0
  48. package/dist/routers/admin.js +633 -0
  49. package/dist/routers/annotations.d.ts +219 -0
  50. package/dist/routers/annotations.js +187 -0
  51. package/dist/routers/auth.d.ts +88 -7
  52. package/dist/routers/auth.js +339 -19
  53. package/dist/routers/chat.d.ts +6 -12
  54. package/dist/routers/copilot.d.ts +199 -0
  55. package/dist/routers/copilot.js +571 -0
  56. package/dist/routers/flashcards.d.ts +47 -81
  57. package/dist/routers/flashcards.js +143 -27
  58. package/dist/routers/members.d.ts +36 -7
  59. package/dist/routers/members.js +200 -19
  60. package/dist/routers/notifications.d.ts +99 -0
  61. package/dist/routers/notifications.js +127 -0
  62. package/dist/routers/payment.d.ts +89 -0
  63. package/dist/routers/payment.js +403 -0
  64. package/dist/routers/podcast.d.ts +8 -13
  65. package/dist/routers/podcast.js +54 -31
  66. package/dist/routers/studyguide.d.ts +1 -29
  67. package/dist/routers/studyguide.js +80 -71
  68. package/dist/routers/worksheets.d.ts +105 -38
  69. package/dist/routers/worksheets.js +258 -68
  70. package/dist/routers/workspace.d.ts +139 -60
  71. package/dist/routers/workspace.js +455 -315
  72. package/dist/scripts/purge-deleted-users.d.ts +1 -0
  73. package/dist/scripts/purge-deleted-users.js +149 -0
  74. package/dist/server.js +130 -10
  75. package/dist/services/flashcard-progress.service.d.ts +18 -66
  76. package/dist/services/flashcard-progress.service.js +51 -42
  77. package/dist/trpc.d.ts +20 -21
  78. package/dist/trpc.js +150 -1
  79. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,149 @@
1
+ import { PrismaClient } from '@prisma/client';
2
+ import { supabaseClient } from '../lib/storage.js';
3
+ import { logger } from '../lib/logger.js';
4
+ import { notifyAdminsAccountPermanentlyDeleted } from '../lib/notification-service.js';
5
+ import { ActivityLogCategory, ActivityLogStatus, } from '@prisma/client';
6
+ import { recordExplicitActivity } from '../lib/activity_log_service.js';
7
+ const db = new PrismaClient();
8
+ async function purgeDeletedUsers() {
9
+ try {
10
+ const startedAt = Date.now();
11
+ let status = ActivityLogStatus.SUCCESS;
12
+ let errorCode = null;
13
+ let purgedUsers = 0;
14
+ logger.info('Starting scheduled purge for deleted users...');
15
+ // Find users whose deletedAt is older than 30 days
16
+ const thirtyDaysAgo = new Date();
17
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
18
+ const usersToPurge = await db.user.findMany({
19
+ where: {
20
+ deletedAt: {
21
+ lte: thirtyDaysAgo,
22
+ },
23
+ },
24
+ select: {
25
+ id: true,
26
+ name: true,
27
+ email: true,
28
+ profilePicture: {
29
+ select: {
30
+ objectKey: true,
31
+ }
32
+ }
33
+ },
34
+ });
35
+ if (usersToPurge.length === 0) {
36
+ logger.info('No users found past the 30-day grace period.');
37
+ await recordExplicitActivity({
38
+ db,
39
+ actorUserId: null,
40
+ action: 'cron.purgeDeletedUsers',
41
+ category: ActivityLogCategory.SYSTEM,
42
+ status: ActivityLogStatus.SUCCESS,
43
+ errorCode: null,
44
+ durationMs: Date.now() - startedAt,
45
+ forceWrite: true,
46
+ metadata: {
47
+ usersFound: 0,
48
+ usersPurged: 0,
49
+ graceDays: 30,
50
+ },
51
+ });
52
+ return;
53
+ }
54
+ logger.info(`Found ${usersToPurge.length} user(s) to purge. Processing...`);
55
+ for (const user of usersToPurge) {
56
+ logger.info(`Purging user: ${user.id} (${user.email})`);
57
+ try {
58
+ // 1. Delete associated files from Supabase Storage
59
+ // (Prisma cascade will delete the FileAsset DB record, but we need to delete the actual file first)
60
+ // Let's get all file assets owned by the user
61
+ const userFiles = await db.fileAsset.findMany({
62
+ where: { userId: user.id },
63
+ select: { objectKey: true, bucket: true },
64
+ });
65
+ for (const file of userFiles) {
66
+ if (file.objectKey && file.bucket) {
67
+ try {
68
+ const { error } = await supabaseClient.storage
69
+ .from(file.bucket)
70
+ .remove([file.objectKey]);
71
+ if (error) {
72
+ logger.error(`Failed to delete file from Supabase: ${file.objectKey}`, error);
73
+ }
74
+ else {
75
+ logger.info(`Deleted file from Supabase: ${file.objectKey}`);
76
+ }
77
+ }
78
+ catch (storageError) {
79
+ logger.error(`Exception during Supabase deletion for ${file.objectKey}:`, storageError);
80
+ }
81
+ }
82
+ }
83
+ await notifyAdminsAccountPermanentlyDeleted(db, {
84
+ id: user.id,
85
+ name: user.name,
86
+ email: user.email,
87
+ }).catch(() => { });
88
+ // 2. Finally, delete the User from the database
89
+ // (Prisma onDelete: Cascade will handle almost everything else depending on schema)
90
+ await db.user.delete({
91
+ where: { id: user.id },
92
+ });
93
+ purgedUsers += 1;
94
+ logger.info(`Successfully purged user: ${user.id}`);
95
+ }
96
+ catch (userError) {
97
+ status = ActivityLogStatus.FAILURE;
98
+ errorCode =
99
+ userError instanceof Error ? userError.message : String(userError);
100
+ logger.error(`Failed to purge user ${user.id}:`, userError);
101
+ }
102
+ }
103
+ if (status === ActivityLogStatus.SUCCESS) {
104
+ logger.info('Purge process completed successfully.');
105
+ }
106
+ else {
107
+ logger.warn('Purge process completed with failures.');
108
+ }
109
+ await recordExplicitActivity({
110
+ db,
111
+ actorUserId: null,
112
+ action: 'cron.purgeDeletedUsers',
113
+ category: ActivityLogCategory.SYSTEM,
114
+ status,
115
+ errorCode,
116
+ durationMs: Date.now() - startedAt,
117
+ forceWrite: true,
118
+ metadata: {
119
+ usersFound: usersToPurge.length,
120
+ usersPurged: purgedUsers,
121
+ graceDays: 30,
122
+ },
123
+ });
124
+ }
125
+ catch (error) {
126
+ const startedAt = Date.now();
127
+ await recordExplicitActivity({
128
+ db,
129
+ actorUserId: null,
130
+ action: 'cron.purgeDeletedUsers',
131
+ category: ActivityLogCategory.SYSTEM,
132
+ status: ActivityLogStatus.FAILURE,
133
+ errorCode: error instanceof Error ? error.message : String(error),
134
+ durationMs: Date.now() - startedAt,
135
+ forceWrite: true,
136
+ }).catch(() => { });
137
+ logger.error('Critical error during user purge process:', error);
138
+ }
139
+ finally {
140
+ await db.$disconnect();
141
+ }
142
+ }
143
+ // Run the script
144
+ purgeDeletedUsers().then(() => {
145
+ process.exit(0);
146
+ }).catch((error) => {
147
+ console.error("Unhandled error:", error);
148
+ process.exit(1);
149
+ });
package/dist/server.js CHANGED
@@ -7,20 +7,24 @@ import compression from 'compression';
7
7
  import * as trpcExpress from '@trpc/server/adapters/express';
8
8
  import { appRouter } from './routers/_app.js';
9
9
  import { createContext } from './context.js';
10
+ import { prisma } from './lib/prisma.js';
10
11
  import { logger } from './lib/logger.js';
11
12
  import { supabaseClient } from './lib/storage.js';
13
+ import { ActivityLogCategory, ActivityLogStatus, } from '@prisma/client';
14
+ import { recordExplicitActivity } from './lib/activity_log_service.js';
12
15
  const PORT = process.env.PORT ? Number(process.env.PORT) : 3001;
13
16
  async function main() {
14
17
  const app = express();
15
18
  // Middlewares
16
- app.use(helmet());
17
19
  app.use(cors({
18
- origin: process.env.FRONTEND_URL || "http://localhost:3000",
20
+ origin: ['https://www.scribe.study', 'https://scribe.study', 'http://localhost:3000', 'http://localhost:3002'],
19
21
  credentials: true, // allow cookies
20
22
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
21
23
  allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'Set-Cookie'],
22
24
  exposedHeaders: ['Set-Cookie'],
25
+ preflightContinue: false, // Important: stop further handling of OPTIONS
23
26
  }));
27
+ app.use(helmet());
24
28
  // Custom morgan middleware with logger integration
25
29
  app.use(morgan('combined', {
26
30
  stream: {
@@ -30,6 +34,112 @@ async function main() {
30
34
  }
31
35
  }));
32
36
  app.use(compression());
37
+ // Stripe Webhook (Must be before express.json() for raw body)
38
+ app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
39
+ const sig = req.headers['stripe-signature'];
40
+ const { stripe } = await import('./lib/stripe.js');
41
+ const { env } = await import('./lib/env.js');
42
+ const subscriptionService = await import('./lib/subscription_service.js');
43
+ let event;
44
+ try {
45
+ if (!sig || !env.STRIPE_WEBHOOK_SECRET) {
46
+ throw new Error('Missing stripe-signature or STRIPE_WEBHOOK_SECRET');
47
+ }
48
+ event = stripe?.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
49
+ }
50
+ catch (err) {
51
+ logger.error(`Webhook signature verification failed: ${err.message}`, 'STRIPE');
52
+ await recordExplicitActivity({
53
+ db: prisma,
54
+ actorUserId: null,
55
+ action: 'stripe.webhook.signatureVerificationFailed',
56
+ category: ActivityLogCategory.SYSTEM,
57
+ status: ActivityLogStatus.FAILURE,
58
+ errorCode: err?.message ?? 'unknown',
59
+ durationMs: 0,
60
+ forceWrite: true,
61
+ }).catch(() => { });
62
+ return res.status(400).send(`Webhook Error: ${err.message}`);
63
+ }
64
+ // Handle the event
65
+ try {
66
+ const startedAt = Date.now();
67
+ let status = ActivityLogStatus.SUCCESS;
68
+ let errorCode = null;
69
+ // Best-effort: some webhook objects include metadata.userId
70
+ let actorUserId = undefined;
71
+ try {
72
+ const obj = event?.data?.object;
73
+ const md = obj?.metadata;
74
+ if (md && typeof md.userId === 'string')
75
+ actorUserId = md.userId;
76
+ }
77
+ catch {
78
+ actorUserId = undefined;
79
+ }
80
+ switch (event?.type) {
81
+ case 'checkout.session.completed':
82
+ await subscriptionService.handleCheckoutCompleted(event);
83
+ break;
84
+ case 'customer.subscription.created':
85
+ await subscriptionService.handleSubscriptionCreated(event);
86
+ break;
87
+ case 'customer.subscription.updated':
88
+ await subscriptionService.handleSubscriptionUpdated(event);
89
+ break;
90
+ case 'customer.subscription.deleted':
91
+ await subscriptionService.handleSubscriptionDeleted(event);
92
+ break;
93
+ case 'invoice.paid':
94
+ case 'invoice.payment_succeeded':
95
+ await subscriptionService.handleInvoicePaid(event);
96
+ break;
97
+ case 'invoice.payment_failed':
98
+ await subscriptionService.handlePaymentFailed(event);
99
+ break;
100
+ case 'payment_intent.payment_failed':
101
+ await subscriptionService.handlePaymentIntentFailed(event);
102
+ break;
103
+ default:
104
+ logger.debug(`Unhandled stripe event type: ${event?.type}`, 'STRIPE');
105
+ }
106
+ await recordExplicitActivity({
107
+ db: prisma,
108
+ actorUserId: actorUserId ?? undefined,
109
+ action: `stripe.webhook.${event?.type}`,
110
+ category: ActivityLogCategory.SYSTEM,
111
+ status,
112
+ errorCode,
113
+ durationMs: Date.now() - startedAt,
114
+ forceWrite: true,
115
+ metadata: {
116
+ stripeEventId: event?.id,
117
+ stripeEventType: event?.type,
118
+ },
119
+ }).catch(() => { });
120
+ res.json({ received: true });
121
+ }
122
+ catch (err) {
123
+ const startedAt = Date.now();
124
+ const message = err?.message ?? 'unknown';
125
+ await recordExplicitActivity({
126
+ db: prisma,
127
+ actorUserId: null,
128
+ action: `stripe.webhook.${event?.type}`,
129
+ category: ActivityLogCategory.SYSTEM,
130
+ status: ActivityLogStatus.FAILURE,
131
+ errorCode: message,
132
+ durationMs: Date.now() - startedAt,
133
+ forceWrite: true,
134
+ metadata: {
135
+ stripeEventId: event?.id,
136
+ stripeEventType: event?.type,
137
+ },
138
+ }).catch(() => { });
139
+ logger.error(`Error processing webhook event ${event?.type}: ${err.message}`, 'STRIPE');
140
+ res.status(500).send('Internal Server Error');
141
+ }
142
+ });
33
143
  app.use(express.json({ limit: '50mb' }));
34
144
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
35
145
  // Health (plain Express)
@@ -37,15 +147,25 @@ async function main() {
37
147
  res.json({ ok: true, service: 'trpc-express', ts: Date.now() });
38
148
  });
39
149
  app.get('/profile-picture/:objectKey', async (req, res) => {
40
- const { objectKey } = req.params;
41
- const signedUrl = await supabaseClient.storage
42
- .from('media')
43
- .createSignedUrl(objectKey, 60 * 60 * 24 * 30);
44
- if (signedUrl.error) {
45
- return res.status(500).json({ error: 'Failed to generate signed URL' });
150
+ try {
151
+ const { objectKey } = req.params;
152
+ const { data, error } = await supabaseClient.storage
153
+ .from('media')
154
+ .download(objectKey);
155
+ if (error || !data) {
156
+ logger.error(`Failed to download profile picture: ${error?.message}`, 'STORAGE');
157
+ return res.status(404).send('Not found');
158
+ }
159
+ const buffer = Buffer.from(await data.arrayBuffer());
160
+ res.setHeader('Content-Type', 'image/jpeg');
161
+ res.setHeader('Cache-Control', 'public, max-age=31536000');
162
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
163
+ res.send(buffer);
164
+ }
165
+ catch (err) {
166
+ logger.error('Error serving profile picture', 'STORAGE', undefined, err);
167
+ res.status(500).send('Internal Server Error');
46
168
  }
47
- // res.json({ url: signedUrl.data.signedUrl });
48
- res.redirect(signedUrl.data.signedUrl);
49
169
  });
50
170
  // tRPC mounted under /trpc
51
171
  app.use('/trpc', trpcExpress.createExpressMiddleware({
@@ -1,4 +1,4 @@
1
- import type { PrismaClient } from '@prisma/client';
1
+ import { Prisma, type PrismaClient } from '@prisma/client';
2
2
  /**
3
3
  * SM-2 Spaced Repetition Algorithm
4
4
  * https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
@@ -39,33 +39,7 @@ export declare class FlashcardProgressService {
39
39
  isCorrect: boolean;
40
40
  confidence?: 'easy' | 'medium' | 'hard';
41
41
  timeSpentMs?: number;
42
- }): Promise<{
43
- flashcard: {
44
- id: string;
45
- createdAt: Date;
46
- artifactId: string;
47
- order: number;
48
- front: string;
49
- back: string;
50
- tags: string[];
51
- };
52
- } & {
53
- id: string;
54
- createdAt: Date;
55
- updatedAt: Date;
56
- userId: string;
57
- flashcardId: string;
58
- timesStudied: number;
59
- timesCorrect: number;
60
- timesIncorrect: number;
61
- timesIncorrectConsecutive: number;
62
- easeFactor: number;
63
- interval: number;
64
- repetitions: number;
65
- masteryLevel: number;
66
- lastStudiedAt: Date | null;
67
- nextReviewAt: Date | null;
68
- }>;
42
+ }, retryCount?: number): Promise<any>;
69
43
  /**
70
44
  * Get user's progress on all flashcards in a set
71
45
  */
@@ -99,17 +73,18 @@ export declare class FlashcardProgressService {
99
73
  id: string;
100
74
  createdAt: Date;
101
75
  updatedAt: Date;
102
- title: string;
103
- description: string | null;
104
76
  workspaceId: string;
105
77
  type: import("@prisma/client").$Enums.ArtifactType;
78
+ title: string;
106
79
  isArchived: boolean;
107
- generating: boolean;
108
- generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
109
80
  difficulty: import("@prisma/client").$Enums.Difficulty | null;
110
81
  estimatedTime: string | null;
111
- imageObjectKey: string | null;
112
82
  createdById: string | null;
83
+ description: string | null;
84
+ generating: boolean;
85
+ generatingMetadata: Prisma.JsonValue | null;
86
+ worksheetConfig: Prisma.JsonValue | null;
87
+ imageObjectKey: string | null;
113
88
  };
114
89
  } & {
115
90
  id: string;
@@ -119,22 +94,24 @@ export declare class FlashcardProgressService {
119
94
  front: string;
120
95
  back: string;
121
96
  tags: string[];
97
+ acceptedAnswers: string[];
122
98
  }) | {
123
99
  artifact: {
124
100
  id: string;
125
101
  createdAt: Date;
126
102
  updatedAt: Date;
127
- title: string;
128
- description: string | null;
129
103
  workspaceId: string;
130
104
  type: import("@prisma/client").$Enums.ArtifactType;
105
+ title: string;
131
106
  isArchived: boolean;
132
- generating: boolean;
133
- generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
134
107
  difficulty: import("@prisma/client").$Enums.Difficulty | null;
135
108
  estimatedTime: string | null;
136
- imageObjectKey: string | null;
137
109
  createdById: string | null;
110
+ description: string | null;
111
+ generating: boolean;
112
+ generatingMetadata: Prisma.JsonValue | null;
113
+ worksheetConfig: Prisma.JsonValue | null;
114
+ imageObjectKey: string | null;
138
115
  };
139
116
  id: string;
140
117
  createdAt: Date;
@@ -143,6 +120,7 @@ export declare class FlashcardProgressService {
143
120
  front: string;
144
121
  back: string;
145
122
  tags: string[];
123
+ acceptedAnswers: string[];
146
124
  })[]>;
147
125
  /**
148
126
  * Get user statistics for a flashcard set
@@ -161,7 +139,7 @@ export declare class FlashcardProgressService {
161
139
  /**
162
140
  * Reset progress for a flashcard
163
141
  */
164
- resetProgress(userId: string, flashcardId: string): Promise<import("@prisma/client").Prisma.BatchPayload>;
142
+ resetProgress(userId: string, flashcardId: string): Promise<Prisma.BatchPayload>;
165
143
  /**
166
144
  * Bulk record study session
167
145
  */
@@ -173,33 +151,7 @@ export declare class FlashcardProgressService {
173
151
  confidence?: 'easy' | 'medium' | 'hard';
174
152
  timeSpentMs?: number;
175
153
  }>;
176
- }): Promise<({
177
- flashcard: {
178
- id: string;
179
- createdAt: Date;
180
- artifactId: string;
181
- order: number;
182
- front: string;
183
- back: string;
184
- tags: string[];
185
- };
186
- } & {
187
- id: string;
188
- createdAt: Date;
189
- updatedAt: Date;
190
- userId: string;
191
- flashcardId: string;
192
- timesStudied: number;
193
- timesCorrect: number;
194
- timesIncorrect: number;
195
- timesIncorrectConsecutive: number;
196
- easeFactor: number;
197
- interval: number;
198
- repetitions: number;
199
- masteryLevel: number;
200
- lastStudiedAt: Date | null;
201
- nextReviewAt: Date | null;
202
- })[]>;
154
+ }): Promise<any[]>;
203
155
  }
204
156
  /**
205
157
  * Factory function
@@ -1,3 +1,4 @@
1
+ import { Prisma } from '@prisma/client';
1
2
  import { NotFoundError } from '../lib/errors.js';
2
3
  export class FlashcardProgressService {
3
4
  constructor(db) {
@@ -105,7 +106,7 @@ export class FlashcardProgressService {
105
106
  /**
106
107
  * Record flashcard study attempt
107
108
  */
108
- async recordStudyAttempt(data) {
109
+ async recordStudyAttempt(data, retryCount = 0) {
109
110
  const { userId, flashcardId, isCorrect, timeSpentMs } = data;
110
111
  // Verify flashcard exists and user has access
111
112
  const flashcard = await this.db.flashcard.findFirst({
@@ -155,44 +156,55 @@ export class FlashcardProgressService {
155
156
  (Math.min(sm2Result.repetitions, 10) / 10) * 30 - // 30% weight on repetitions
156
157
  consecutivePenalty // Penalty for consecutive failures
157
158
  )));
158
- // Upsert progress
159
- return this.db.flashcardProgress.upsert({
160
- where: {
161
- userId_flashcardId: {
159
+ try {
160
+ // Upsert progress
161
+ return await this.db.flashcardProgress.upsert({
162
+ where: {
163
+ userId_flashcardId: {
164
+ userId,
165
+ flashcardId,
166
+ },
167
+ },
168
+ update: {
169
+ timesStudied: { increment: 1 },
170
+ timesCorrect: isCorrect ? { increment: 1 } : undefined,
171
+ timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
172
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
173
+ easeFactor: sm2Result.easeFactor,
174
+ interval: sm2Result.interval,
175
+ repetitions: sm2Result.repetitions,
176
+ masteryLevel,
177
+ lastStudiedAt: new Date(),
178
+ nextReviewAt: sm2Result.nextReviewAt,
179
+ },
180
+ create: {
162
181
  userId,
163
182
  flashcardId,
183
+ timesStudied: 1,
184
+ timesCorrect: isCorrect ? 1 : 0,
185
+ timesIncorrect: isCorrect ? 0 : 1,
186
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
187
+ easeFactor: sm2Result.easeFactor,
188
+ interval: sm2Result.interval,
189
+ repetitions: sm2Result.repetitions,
190
+ masteryLevel,
191
+ lastStudiedAt: new Date(),
192
+ nextReviewAt: sm2Result.nextReviewAt,
164
193
  },
165
- },
166
- update: {
167
- timesStudied: { increment: 1 },
168
- timesCorrect: isCorrect ? { increment: 1 } : undefined,
169
- timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
170
- timesIncorrectConsecutive: newConsecutiveIncorrect,
171
- easeFactor: sm2Result.easeFactor,
172
- interval: sm2Result.interval,
173
- repetitions: sm2Result.repetitions,
174
- masteryLevel,
175
- lastStudiedAt: new Date(),
176
- nextReviewAt: sm2Result.nextReviewAt,
177
- },
178
- create: {
179
- userId,
180
- flashcardId,
181
- timesStudied: 1,
182
- timesCorrect: isCorrect ? 1 : 0,
183
- timesIncorrect: isCorrect ? 0 : 1,
184
- timesIncorrectConsecutive: newConsecutiveIncorrect,
185
- easeFactor: sm2Result.easeFactor,
186
- interval: sm2Result.interval,
187
- repetitions: sm2Result.repetitions,
188
- masteryLevel,
189
- lastStudiedAt: new Date(),
190
- nextReviewAt: sm2Result.nextReviewAt,
191
- },
192
- include: {
193
- flashcard: true,
194
- },
195
- });
194
+ include: {
195
+ flashcard: true,
196
+ },
197
+ });
198
+ }
199
+ catch (error) {
200
+ // Handle rare race condition where parallel submissions try creating same row.
201
+ if (error instanceof Prisma.PrismaClientKnownRequestError &&
202
+ error.code === 'P2002' &&
203
+ retryCount < 1) {
204
+ return this.recordStudyAttempt(data, retryCount + 1);
205
+ }
206
+ throw error;
207
+ }
196
208
  }
197
209
  /**
198
210
  * Get user's progress on all flashcards in a set
@@ -253,7 +265,7 @@ export class FlashcardProgressService {
253
265
  },
254
266
  },
255
267
  });
256
- console.log('allFlashcards', allFlashcards.length);
268
+ // allFlashcards count logged for debugging if needed
257
269
  const TAKE_NUMBER = (allFlashcards.length > 10) ? 10 : allFlashcards.length;
258
270
  // Get progress records for flashcards that are due or have low mastery
259
271
  const progressRecords = await this.db.flashcardProgress.findMany({
@@ -289,9 +301,7 @@ export class FlashcardProgressService {
289
301
  },
290
302
  take: TAKE_NUMBER,
291
303
  });
292
- console.log('TAKE_NUMBER', TAKE_NUMBER);
293
- console.log('TAKE_NUMBER - progressRecords.length', TAKE_NUMBER - progressRecords.length);
294
- console.log('progressRecords', progressRecords.map((progress) => progress.flashcard.id));
304
+ // Due card selection: TAKE_NUMBER=${TAKE_NUMBER}, existing=${progressRecords.length}
295
305
  // Get flashcard IDs that already have progress records
296
306
  const flashcardIdsWithProgress = new Set(progressRecords.map((progress) => progress.flashcard.id));
297
307
  // Find flashcards without progress records (non-studied) to pad the results
@@ -318,8 +328,7 @@ export class FlashcardProgressService {
318
328
  flashcard: flashcardWithoutProgress,
319
329
  };
320
330
  });
321
- console.log('progressRecordsPadding', progressRecordsPadding.length);
322
- console.log('progressRecords', progressRecords.length);
331
+ // Combined: ${progressRecords.length} due + ${progressRecordsPadding.length} padding cards
323
332
  const selectedCards = [...progressRecords, ...progressRecordsPadding];
324
333
  // Build result array: include progress records and non-studied flashcards
325
334
  const results = [];
package/dist/trpc.d.ts CHANGED
@@ -1,11 +1,6 @@
1
+ import type { Context } from "./context.js";
1
2
  export declare const router: import("@trpc/server").TRPCRouterBuilder<{
2
- ctx: {
3
- db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
4
- session: any;
5
- req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
6
- res: import("express").Response<any, Record<string, any>>;
7
- cookies: Record<string, string | undefined>;
8
- };
3
+ ctx: Context;
9
4
  meta: object;
10
5
  errorShape: {
11
6
  data: {
@@ -20,34 +15,38 @@ export declare const router: import("@trpc/server").TRPCRouterBuilder<{
20
15
  };
21
16
  transformer: true;
22
17
  }>;
23
- export declare const middleware: <$ContextOverrides>(fn: import("@trpc/server").TRPCMiddlewareFunction<{
24
- db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
18
+ export declare const middleware: <$ContextOverrides>(fn: import("@trpc/server").TRPCMiddlewareFunction<Context, object, object, $ContextOverrides, unknown>) => import("@trpc/server").TRPCMiddlewareBuilder<Context, object, $ContextOverrides, unknown>;
19
+ export declare const publicProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, object, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
20
+ /** Exported procedures with middleware */
21
+ export declare const authedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
22
+ userId: any;
25
23
  session: any;
26
24
  req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
27
25
  res: import("express").Response<any, Record<string, any>>;
28
- cookies: Record<string, string | undefined>;
29
- }, object, object, $ContextOverrides, unknown>) => import("@trpc/server").TRPCMiddlewareBuilder<{
30
26
  db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
27
+ cookies: Record<string, string | undefined>;
28
+ }, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
29
+ export declare const verifiedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
30
+ userId: any;
31
31
  session: any;
32
32
  req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
33
33
  res: import("express").Response<any, Record<string, any>>;
34
- cookies: Record<string, string | undefined>;
35
- }, object, $ContextOverrides, unknown>;
36
- export declare const publicProcedure: import("@trpc/server").TRPCProcedureBuilder<{
37
34
  db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
35
+ cookies: Record<string, string | undefined>;
36
+ }, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
37
+ export declare const adminProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
38
+ userId: any;
38
39
  session: any;
39
40
  req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
40
41
  res: import("express").Response<any, Record<string, any>>;
41
- cookies: Record<string, string | undefined>;
42
- }, object, object, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
43
- /** Exported procedures with middleware */
44
- export declare const authedProcedure: import("@trpc/server").TRPCProcedureBuilder<{
45
42
  db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
43
+ cookies: Record<string, string | undefined>;
44
+ }, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
45
+ export declare const limitedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
46
+ userId: any;
46
47
  session: any;
47
48
  req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
48
49
  res: import("express").Response<any, Record<string, any>>;
50
+ db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
49
51
  cookies: Record<string, string | undefined>;
50
- }, object, {
51
- session: any;
52
- userId: any;
53
52
  }, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;