@goscribe/server 1.2.0 → 1.3.0
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/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/mcq-test.cjs +36 -0
- package/package.json +9 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +471 -324
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +79 -51
- package/src/lib/email.ts +213 -29
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +2 -2
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +86 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/routers/_app.ts +9 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +41 -0
- package/src/routers/auth.ts +338 -28
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +201 -68
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +112 -83
- package/src/routers/studyguide.ts +12 -0
- package/src/routers/worksheets.ts +289 -66
- package/src/routers/workspace.ts +329 -122
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +137 -11
- package/src/services/flashcard-progress.service.ts +49 -37
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
package/src/lib/ai-session.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface AISession {
|
|
|
25
25
|
updatedAt: Date;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const IMITATE_WAIT_TIME_MS = MOCK_MODE ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
|
+
import { env } from './env.js';
|
|
3
4
|
|
|
4
|
-
//
|
|
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
|
|
7
|
-
|
|
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
|
|
15
|
-
const greeting = name ?
|
|
16
|
-
|
|
17
|
-
const html =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
package/src/lib/inference.ts
CHANGED
|
@@ -5,11 +5,11 @@ const openai = new OpenAI({
|
|
|
5
5
|
baseURL: process.env.INFERENCE_BASE_URL,
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
-
async function inference(
|
|
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:
|
|
12
|
+
messages: messages,
|
|
13
13
|
});
|
|
14
14
|
return response;
|
|
15
15
|
} catch (error) {
|