@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
@@ -25,7 +25,7 @@ export interface AISession {
25
25
  updatedAt: Date;
26
26
  }
27
27
 
28
- const IMITATE_WAIT_TIME_MS = MOCK_MODE ? 1000 * 10 : 0;
28
+ const IMITATE_WAIT_TIME_MS = MOCK_MODE ? 1000 * 10 : 0;
29
29
 
30
30
  export interface ProcessFileResult {
31
31
  status: 'success' | 'error';
@@ -62,7 +62,7 @@ export class AISessionService {
62
62
  this.sessions.set(sessionId, session);
63
63
  return session;
64
64
  }
65
-
65
+
66
66
  const formData = new FormData();
67
67
  formData.append('command', 'init_session');
68
68
  formData.append('session', sessionId);
@@ -75,7 +75,7 @@ export class AISessionService {
75
75
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
76
76
  try {
77
77
  logger.info(`AI Session init attempt ${attempt}/${maxRetries} for workspace ${workspaceId}`);
78
-
78
+
79
79
  const response = await fetch(AI_SERVICE_URL, {
80
80
  method: 'POST',
81
81
  body: formData,
@@ -91,7 +91,7 @@ export class AISessionService {
91
91
 
92
92
  const result = await response.json();
93
93
  logger.debug(`AI Service result: ${JSON.stringify(result)}`);
94
-
94
+
95
95
  // If we get a response with a message, consider it successful
96
96
  if (!result.message) {
97
97
  throw new Error(`AI service error: No response message`);
@@ -109,11 +109,11 @@ export class AISessionService {
109
109
  this.sessions.set(sessionId, session);
110
110
  logger.info(`AI Session initialized successfully on attempt ${attempt}`);
111
111
  return session;
112
-
112
+
113
113
  } catch (error) {
114
114
  lastError = error instanceof Error ? error : new Error('Unknown error');
115
115
  logger.error(`AI Session init attempt ${attempt} failed: ${lastError.message}`);
116
-
116
+
117
117
  if (attempt < maxRetries) {
118
118
  const delay = Math.pow(2, attempt) * 1000; // Exponential backoff: 2s, 4s, 8s
119
119
  logger.info(`Retrying in ${delay}ms...`);
@@ -138,7 +138,7 @@ export class AISessionService {
138
138
  maxPages?: number
139
139
  ): Promise<ProcessFileResult> {
140
140
  await new Promise(resolve => setTimeout(resolve, IMITATE_WAIT_TIME_MS));
141
-
141
+
142
142
  // Mock mode - return fake processing result
143
143
  if (MOCK_MODE) {
144
144
  logger.info(`🎭 MOCK MODE: Processing ${fileType} file from URL for session ${sessionId}`);
@@ -173,7 +173,7 @@ export class AISessionService {
173
173
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
174
174
  try {
175
175
  logger.info(`📄 Processing ${fileType} file attempt ${attempt}/${maxRetries} for session ${sessionId}`);
176
-
176
+
177
177
  // Set timeout for large files (5 minutes)
178
178
  const controller = new AbortController();
179
179
  const timeoutId = setTimeout(() => controller.abort(), 300000); // 5 min timeout
@@ -200,11 +200,11 @@ export class AISessionService {
200
200
  }
201
201
 
202
202
  return result as ProcessFileResult;
203
-
203
+
204
204
  } catch (error) {
205
205
  lastError = error instanceof Error ? error : new Error('Unknown error');
206
206
  logger.error(`❌ File processing attempt ${attempt} failed:`, lastError.message);
207
-
207
+
208
208
  if (attempt < maxRetries) {
209
209
  const delay = Math.pow(2, attempt) * 1000; // Exponential backoff: 2s, 4s, 8s
210
210
  logger.info(`⏳ Retrying file processing in ${delay}ms...`);
@@ -265,7 +265,8 @@ This mock study guide demonstrates the structure and format that would be genera
265
265
  });
266
266
 
267
267
  if (!response.ok) {
268
- throw new Error(`AI service error: ${response.status} ${response.statusText}`);
268
+ const errorBody = await response.text().catch(() => '');
269
+ throw new Error(`AI service error: ${response.status} ${response.statusText} - ${errorBody}`);
269
270
  }
270
271
 
271
272
  const result = await response.json();
@@ -276,19 +277,18 @@ This mock study guide demonstrates the structure and format that would be genera
276
277
  }, { maxRetries: 3, timeoutMs: 300000, label: 'generateStudyGuide' });
277
278
  }
278
279
 
279
- // Generate flashcard questions
280
- async generateFlashcardQuestions(sessionId: string, user: string, numQuestions: number, difficulty: 'easy' | 'medium' | 'hard'): Promise<string> {
280
+ async generateFlashcardQuestions(sessionId: string, user: string, numQuestions: number, difficulty: 'easy' | 'medium' | 'hard', prompt?: string): Promise<string> {
281
281
  await new Promise(resolve => setTimeout(resolve, IMITATE_WAIT_TIME_MS));
282
282
  // Mock mode - return fake flashcard questions
283
283
  if (MOCK_MODE) {
284
284
  logger.info(`🎭 MOCK MODE: Generating ${numQuestions} ${difficulty} flashcard questions for session ${sessionId}`);
285
285
  return JSON.stringify(Array.from({ length: numQuestions }, (_, i) => ({
286
- id: `mock-flashcard-${i + 1}`,
287
- question: `Mock question ${i + 1}: What is the main concept covered in this material?`,
288
- answer: `Mock answer ${i + 1}: This is a sample answer that would be generated based on the uploaded content.`,
289
- difficulty: difficulty,
290
- category: `Mock Category ${(i % 3) + 1}`
291
- })));
286
+ id: `mock-flashcard-${i + 1}`,
287
+ question: `Mock question ${i + 1}: What is the main concept covered in this material?`,
288
+ answer: `Mock answer ${i + 1}: This is a sample answer that would be generated based on the uploaded content.`,
289
+ difficulty: difficulty,
290
+ category: `Mock Category ${(i % 3) + 1}`
291
+ })));
292
292
  }
293
293
 
294
294
  return withRetry(async () => {
@@ -298,6 +298,9 @@ This mock study guide demonstrates the structure and format that would be genera
298
298
  formData.append('user', user);
299
299
  formData.append('num_questions', numQuestions.toString());
300
300
  formData.append('difficulty', difficulty);
301
+ if (prompt) {
302
+ formData.append('prompt', prompt);
303
+ }
301
304
 
302
305
  const response = await fetch(AI_SERVICE_URL, {
303
306
  method: 'POST',
@@ -314,32 +317,47 @@ This mock study guide demonstrates the structure and format that would be genera
314
317
  }
315
318
 
316
319
  // Generate worksheet questions
317
- async generateWorksheetQuestions(sessionId: string, user: string, numQuestions: number, difficulty: 'EASY' | 'MEDIUM' | 'HARD'): Promise<string> {
320
+ async generateWorksheetQuestions(
321
+ sessionId: string,
322
+ user: string,
323
+ numQuestions: number,
324
+ difficulty: 'EASY' | 'MEDIUM' | 'HARD',
325
+ options?: {
326
+ mode?: 'practice' | 'quiz';
327
+ mcqRatio?: number;
328
+ questionTypes?: string[];
329
+ prompt?: string;
330
+ },
331
+ ): Promise<string> {
318
332
  await new Promise(resolve => setTimeout(resolve, IMITATE_WAIT_TIME_MS));
319
333
  // Mock mode - return fake worksheet questions
320
334
  if (MOCK_MODE) {
321
335
  logger.info(`🎭 MOCK MODE: Generating ${numQuestions} ${difficulty} worksheet questions for session ${sessionId}`);
336
+ const mode = options?.mode ?? 'practice';
337
+ const isQuiz = mode === 'quiz';
322
338
  return JSON.stringify({
323
- worksheet: {
324
- title: `Mock Worksheet - ${difficulty} Level`,
325
- questions: Array.from({ length: numQuestions }, (_, i) => ({
326
- id: `mock-worksheet-q${i + 1}`,
327
- question: `Mock worksheet question ${i + 1}: Based on the uploaded material, explain the key concept and provide examples.`,
328
- type: i % 2 === 0 ? 'short_answer' : 'essay',
329
- difficulty: difficulty,
330
- estimatedTime: difficulty === 'EASY' ? '2-3 minutes' : difficulty === 'MEDIUM' ? '5-7 minutes' : '10-15 minutes',
331
- points: difficulty === 'EASY' ? 5 : difficulty === 'MEDIUM' ? 10 : 15
332
- })),
333
- instructions: "This is a mock worksheet generated for testing purposes. Answer all questions based on the uploaded materials.",
334
- totalPoints: numQuestions * (difficulty === 'EASY' ? 5 : difficulty === 'MEDIUM' ? 10 : 15),
335
- estimatedTime: `${numQuestions * (difficulty === 'EASY' ? 3 : difficulty === 'MEDIUM' ? 6 : 12)} minutes`
336
- },
337
- metadata: {
338
- totalQuestions: numQuestions,
339
- difficulty: difficulty,
340
- generatedAt: new Date().toISOString(),
341
- isMock: true
342
- }
339
+ title: isQuiz ? `Mock Quiz - ${difficulty}` : `Mock Worksheet - ${difficulty} Level`,
340
+ description: 'Mock generated content',
341
+ difficulty,
342
+ estimatedTime: `${numQuestions * 2} min`,
343
+ problems: Array.from({ length: numQuestions }, (_, i) => {
344
+ if (isQuiz) {
345
+ return {
346
+ question: `Mock MCQ ${i + 1}: What is 2+2?`,
347
+ answer: '1',
348
+ type: 'MULTIPLE_CHOICE',
349
+ options: ['3', '4', '5', '6'],
350
+ mark_scheme: { points: [{ point: 1, requirements: 'Select correct option' }], totalPoints: 1 },
351
+ };
352
+ }
353
+ return {
354
+ question: `Mock question ${i + 1}: Explain a concept.`,
355
+ answer: 'Mock answer',
356
+ type: 'TEXT',
357
+ options: [],
358
+ mark_scheme: { points: [{ point: 1, requirements: 'Clear explanation' }], totalPoints: 1 },
359
+ };
360
+ }),
343
361
  });
344
362
  }
345
363
 
@@ -350,6 +368,16 @@ This mock study guide demonstrates the structure and format that would be genera
350
368
  formData.append('user', user);
351
369
  formData.append('num_questions', numQuestions.toString());
352
370
  formData.append('difficulty', difficulty);
371
+ formData.append('mode', options?.mode ?? 'practice');
372
+ if (options?.mcqRatio !== undefined) {
373
+ formData.append('mcq_ratio', String(options.mcqRatio));
374
+ }
375
+ if (options?.questionTypes?.length) {
376
+ formData.append('question_types', JSON.stringify(options.questionTypes));
377
+ }
378
+ if (options?.prompt) {
379
+ formData.append('worksheet_prompt', options.prompt);
380
+ }
353
381
 
354
382
  const response = await fetch(AI_SERVICE_URL, {
355
383
  method: 'POST',
@@ -391,15 +419,15 @@ This mock study guide demonstrates the structure and format that would be genera
391
419
 
392
420
  // Generate podcast structure
393
421
  async generatePodcastStructure(
394
- sessionId: string,
395
- user: string,
396
- title: string,
397
- description: string,
422
+ sessionId: string,
423
+ user: string,
424
+ title: string,
425
+ description: string,
398
426
  prompt: string,
399
427
  speakers: Array<{ id: string; role: string; name?: string }>
400
428
  ): Promise<any> {
401
429
  await new Promise(resolve => setTimeout(resolve, IMITATE_WAIT_TIME_MS));
402
-
430
+
403
431
  // Mock mode - return fake podcast structure
404
432
  if (MOCK_MODE) {
405
433
  logger.info(`🎭 MOCK MODE: Generating podcast structure for session ${sessionId}`);
@@ -482,7 +510,7 @@ This mock study guide demonstrates the structure and format that would be genera
482
510
  voiceId?: string
483
511
  ): Promise<any> {
484
512
  await new Promise(resolve => setTimeout(resolve, IMITATE_WAIT_TIME_MS));
485
-
513
+
486
514
  // Mock mode - return fake audio generation result
487
515
  if (MOCK_MODE) {
488
516
  logger.info(`🎭 MOCK MODE: Generating audio for segment ${segmentIndex} of podcast ${podcastId}`);
@@ -505,7 +533,7 @@ This mock study guide demonstrates the structure and format that would be genera
505
533
  formData.append('segment_index', segmentIndex.toString());
506
534
  formData.append('text', text);
507
535
  formData.append('speakers', JSON.stringify(speakers));
508
-
536
+
509
537
  if (voiceId) {
510
538
  formData.append('voice_id', voiceId);
511
539
  }
@@ -530,7 +558,7 @@ This mock study guide demonstrates the structure and format that would be genera
530
558
  });
531
559
  }
532
560
  }
533
-
561
+
534
562
 
535
563
 
536
564
  async generatePodcastImage(sessionId: string, user: string, summary: string): Promise<string> {
@@ -560,7 +588,7 @@ This mock study guide demonstrates the structure and format that would be genera
560
588
  }
561
589
  }
562
590
 
563
- async segmentStudyGuide (sessionId: string, user: string, studyGuide: string): Promise<{
591
+ async segmentStudyGuide(sessionId: string, user: string, studyGuide: string): Promise<{
564
592
  hint: string;
565
593
  content: string
566
594
  }[]> {
@@ -574,7 +602,7 @@ This mock study guide demonstrates the structure and format that would be genera
574
602
  // if not study_guide:
575
603
  // print("Study guide not provided.")
576
604
  // return {"error": "Study guide not provided."}, 400
577
-
605
+
578
606
  // messages = generate_segmentation(study_guide)
579
607
  // return {"segmentation": messages}, 200
580
608
 
@@ -662,7 +690,7 @@ This mock study guide demonstrates the structure and format that would be genera
662
690
  method: 'POST',
663
691
  body: new FormData(), // Empty form data
664
692
  });
665
-
693
+
666
694
  logger.info(`AI Service health check status: ${response.status}`);
667
695
  return response.ok;
668
696
  } catch (error) {
package/src/lib/email.ts CHANGED
@@ -1,43 +1,227 @@
1
- import { Resend } from 'resend';
1
+ import nodemailer from 'nodemailer';
2
2
  import { logger } from './logger.js';
3
+ import { env } from './env.js';
3
4
 
4
- // const resend = new Resend(process.env.RESEND_API_KEY);
5
+ // Commented out Resend flow as requested
6
+ // import { Resend } from 'resend';
7
+ // const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
5
8
 
6
- const FROM_EMAIL = process.env.EMAIL_FROM || 'Scribe <noreply@goscribe.app>';
7
- const APP_URL = process.env.AUTH_URL || 'http://localhost:3000';
9
+ const transporter = nodemailer.createTransport({
10
+ host: env.SMTP_HOST,
11
+ port: env.SMTP_PORT,
12
+ secure: env.SMTP_SECURE,
13
+ auth: {
14
+ user: env.SMTP_USER,
15
+ pass: env.SMTP_PASSWORD,
16
+ },
17
+ });
18
+
19
+ const FROM_EMAIL = env.EMAIL_FROM;
20
+ const APP_URL = env.FRONTEND_URL;
8
21
 
9
22
  export async function sendVerificationEmail(
10
23
  email: string,
11
24
  token: string,
12
25
  name?: string | null
13
26
  ): Promise<boolean> {
14
- const verifyUrl = APP_URL + '/verify-email?token=' + token;
15
- const greeting = name ? ', ' + name : '';
16
-
17
- const html = '<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">'
18
- + '<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Welcome to Scribe' + greeting + '!</h2>'
19
- + '<p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">'
20
- + 'Please verify your email address to get the most out of your account.</p>'
21
- + '<a href="' + verifyUrl + '" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Verify Email</a>'
22
- + '<p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">'
23
- + 'If you did not create an account on Scribe, you can safely ignore this email.'
24
- + ' This link expires in 24 hours.</p>'
25
- + '</div>';
27
+ const verifyUrl = `${APP_URL}/verify-email?token=${token}`;
28
+ const greeting = name ? `, ${name}` : '';
29
+
30
+ const html = `
31
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
32
+ <h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Welcome to Scribe${greeting}!</h2>
33
+ <p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
34
+ Please verify your email address to get the most out of your account.
35
+ </p>
36
+ <a href="${verifyUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Verify Email</a>
37
+ <p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
38
+ If you did not create an account on Scribe, you can safely ignore this email.
39
+ This link expires in 24 hours.
40
+ </p>
41
+ </div>
42
+ `;
43
+
44
+ try {
45
+ if (!env.SMTP_HOST) {
46
+ logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
47
+ logger.info(`Verification Email to ${email}: ${verifyUrl}`);
48
+ return true;
49
+ }
50
+
51
+ await transporter.sendMail({
52
+ from: FROM_EMAIL,
53
+ to: email,
54
+ subject: 'Verify your email - Scribe',
55
+ html,
56
+ });
57
+
58
+ logger.info(`Verification email sent to ${email}`);
59
+ return true;
60
+ } catch (err) {
61
+ logger.error('Email send error:', err);
62
+ return false;
63
+ }
64
+ }
65
+
66
+ export async function sendInvitationEmail(invitation: {
67
+ email: string;
68
+ token: string;
69
+ role: string;
70
+ workspaceTitle: string;
71
+ invitedByName: string;
72
+ }): Promise<boolean> {
73
+ const inviteUrl = `${APP_URL}/accept-invite?token=${invitation.token}`;
74
+
75
+ const html = `
76
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
77
+ <h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Workspace Invitation</h2>
78
+ <p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
79
+ <strong>${invitation.invitedByName}</strong> has invited you to join the <strong>${invitation.workspaceTitle}</strong> workspace as a <strong>${invitation.role}</strong>.
80
+ </p>
81
+ <p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
82
+ Click the button below to accept the invitation and start collaborating.
83
+ </p>
84
+ <a href="${inviteUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Accept Invitation</a>
85
+ <p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
86
+ This invitation was sent to ${invitation.email}. If you weren't expecting this invitation, you can safely ignore this email.
87
+ </p>
88
+ </div>
89
+ `;
90
+
91
+ try {
92
+ if (!env.SMTP_HOST) {
93
+ logger.warn('Email service not configured (SMTP_HOST missing). Logging invitation link instead:');
94
+ logger.info(`Invitation Link for ${invitation.email}: ${inviteUrl}`);
95
+ return true;
96
+ }
97
+
98
+ await transporter.sendMail({
99
+ from: FROM_EMAIL,
100
+ to: invitation.email,
101
+ subject: `Invitation to join ${invitation.workspaceTitle} on Scribe`,
102
+ html,
103
+ });
104
+
105
+ logger.info(`Invitation email sent to ${invitation.email}`);
106
+ return true;
107
+ } catch (err) {
108
+ logger.error('Email send error:', err);
109
+ return false;
110
+ }
111
+ }
112
+
113
+ export async function sendAccountDeletionScheduledEmail(email: string, token: string): Promise<boolean> {
114
+ const restoreUrl = `${APP_URL}/restore-account?token=${token}`;
115
+
116
+ const html = `
117
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
118
+ <h2 style="font-size:20px;font-weight:600;margin-bottom:8px;color:#dc2626;">Account Deletion Scheduled</h2>
119
+ <p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
120
+ Your account is scheduled for permanent deletion in 30 days.
121
+ </p>
122
+ <p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
123
+ If you change your mind, you can restore your account by clicking the button below. This link will remain active during the 30-day grace period.
124
+ </p>
125
+ <a href="${restoreUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Restore Account</a>
126
+ <p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
127
+ If you meant to delete your account, you can safely ignore this email.
128
+ </p>
129
+ </div>
130
+ `;
131
+
132
+ try {
133
+ if (!env.SMTP_HOST) {
134
+ logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
135
+ logger.info(`Account Deletion Scheduled Email to ${email}: ${restoreUrl}`);
136
+ return true;
137
+ }
138
+
139
+ await transporter.sendMail({
140
+ from: FROM_EMAIL,
141
+ to: email,
142
+ subject: 'Account Deletion Scheduled - Scribe',
143
+ html,
144
+ });
145
+
146
+ logger.info(`Account deletion scheduled email sent to ${email}`);
147
+ return true;
148
+ } catch (err) {
149
+ logger.error('Email send error:', err);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ export async function sendPasswordResetEmail(
155
+ email: string,
156
+ token: string,
157
+ name?: string | null
158
+ ): Promise<boolean> {
159
+ const resetUrl = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;
160
+ const greeting = name ? `, ${name}` : '';
161
+
162
+ const html = `
163
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
164
+ <h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Reset your password${greeting}</h2>
165
+ <p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
166
+ We received a request to reset your Scribe password. Click the button below to choose a new password.
167
+ </p>
168
+ <a href="${resetUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Reset password</a>
169
+ <p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
170
+ If you did not request this, you can ignore this email. This link expires in 1 hour.
171
+ </p>
172
+ </div>
173
+ `;
26
174
 
27
175
  try {
28
- // const { error } = await resend.emails.send({
29
- // from: FROM_EMAIL,
30
- // to: email,
31
- // subject: 'Verify your email - Scribe',
32
- // html,
33
- // });
34
-
35
- // if (error) {
36
- // logger.error('Failed to send verification email to ' + email + ': ' + error.message);
37
- // return false;
38
- // }
39
-
40
- logger.info('Verification email sent to ' + email);
176
+ if (!env.SMTP_HOST) {
177
+ logger.warn('Email service not configured (SMTP_HOST missing). Logging reset link instead:');
178
+ logger.info(`Password reset for ${email}: ${resetUrl}`);
179
+ return true;
180
+ }
181
+
182
+ await transporter.sendMail({
183
+ from: FROM_EMAIL,
184
+ to: email,
185
+ subject: 'Reset your password - Scribe',
186
+ html,
187
+ });
188
+
189
+ logger.info(`Password reset email sent to ${email}`);
190
+ return true;
191
+ } catch (err) {
192
+ logger.error('Email send error:', err);
193
+ return false;
194
+ }
195
+ }
196
+
197
+ export async function sendAccountRestoredEmail(email: string): Promise<boolean> {
198
+ const loginUrl = `${APP_URL}/login`;
199
+
200
+ const html = `
201
+ <div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
202
+ <h2 style="font-size:20px;font-weight:600;margin-bottom:8px;color:#16a34a;">Account Restored Successfully</h2>
203
+ <p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
204
+ Your account has been successfully restored. Your data is safe and your account deletion process has been cancelled.
205
+ </p>
206
+ <a href="${loginUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Log In</a>
207
+ </div>
208
+ `;
209
+
210
+ try {
211
+ if (!env.SMTP_HOST) {
212
+ logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
213
+ logger.info(`Account Restored Email to ${email}`);
214
+ return true;
215
+ }
216
+
217
+ await transporter.sendMail({
218
+ from: FROM_EMAIL,
219
+ to: email,
220
+ subject: 'Account Restored - Scribe',
221
+ html,
222
+ });
223
+
224
+ logger.info(`Account restored email sent to ${email}`);
41
225
  return true;
42
226
  } catch (err) {
43
227
  logger.error('Email send error:', err);
package/src/lib/env.ts CHANGED
@@ -10,37 +10,54 @@ const envSchema = z.object({
10
10
  // Database
11
11
  DATABASE_URL: z.string().url(),
12
12
  DIRECT_URL: z.string().url().optional(),
13
-
13
+
14
14
  // Server
15
15
  PORT: z.string().regex(/^\d+$/).default('3001').transform(Number),
16
16
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
17
-
17
+
18
18
  // Auth
19
19
  BETTER_AUTH_SECRET: z.string().min(32).optional(),
20
20
  BETTER_AUTH_URL: z.string().url().optional(),
21
-
21
+
22
22
  // Storage
23
23
  GOOGLE_CLOUD_PROJECT_ID: z.string().optional(),
24
24
  GOOGLE_CLOUD_BUCKET_NAME: z.string().optional(),
25
25
  GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
26
-
26
+
27
27
  // Pusher
28
28
  PUSHER_APP_ID: z.string().optional(),
29
29
  PUSHER_KEY: z.string().optional(),
30
30
  PUSHER_SECRET: z.string().optional(),
31
31
  PUSHER_CLUSTER: z.string().optional(),
32
-
32
+
33
33
  // Inference
34
34
  INFERENCE_API_URL: z.string().url().optional(),
35
-
35
+
36
36
  // CORS
37
37
  FRONTEND_URL: z.string().url().default('http://localhost:3000'),
38
+
39
+ // Email
40
+ SMTP_HOST: z.string(),
41
+ SMTP_PORT: z.string().regex(/^\d+$/).default('587').transform(Number),
42
+ SMTP_USER: z.string().optional(),
43
+ SMTP_PASSWORD: z.string().optional(),
44
+ SMTP_SECURE: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
45
+ EMAIL_FROM: z.string().default('Scribe <noreply@goscribe.app>'),
46
+ // Stripe
47
+ STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
48
+ STRIPE_SUCCESS_URL: z.string().url().default('http://localhost:3000/payment-success'),
49
+ STRIPE_CANCEL_URL: z.string().url().default('http://localhost:3000/payment-cancel'),
50
+ STRIPE_PRICE_SUB_BASIC: z.string().startsWith('price_').optional(),
51
+ STRIPE_PRICE_SUB_PRO: z.string().startsWith('price_').optional(),
52
+ STRIPE_PRICE_CREDITS_1000: z.string().startsWith('price_').optional(),
53
+ STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_').optional(),
38
54
  });
39
55
 
40
56
  /**
41
57
  * Parsed and validated environment variables
42
58
  */
43
59
  export const env = envSchema.parse(process.env);
60
+ // console.log('DEBUG: SMTP_HOST loaded:', env.SMTP_HOST ? 'Yes' : 'No');
44
61
 
45
62
  /**
46
63
  * Check if running in production
@@ -5,11 +5,11 @@ const openai = new OpenAI({
5
5
  baseURL: process.env.INFERENCE_BASE_URL,
6
6
  });
7
7
 
8
- async function inference(prompt: string) {
8
+ async function inference(messages: { role: 'system' | 'user' | 'assistant', content: string }[]) {
9
9
  try {
10
10
  const response = await openai.chat.completions.create({
11
11
  model: "command-a-03-2025",
12
- messages: [{ role: "user", content: prompt }],
12
+ messages: messages,
13
13
  });
14
14
  return response;
15
15
  } catch (error) {