@goscribe/server 1.1.7 → 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.
Files changed (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
@@ -0,0 +1,230 @@
1
+ import nodemailer from 'nodemailer';
2
+ import { logger } from './logger.js';
3
+ import { env } from './env.js';
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;
8
+
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;
21
+
22
+ export async function sendVerificationEmail(
23
+ email: string,
24
+ token: string,
25
+ name?: string | null
26
+ ): Promise<boolean> {
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
+ `;
174
+
175
+ try {
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}`);
225
+ return true;
226
+ } catch (err) {
227
+ logger.error('Email send error:', err);
228
+ return false;
229
+ }
230
+ }
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,15 +5,15 @@ 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) {
16
- console.error('Inference error:', error);
16
+ // Inference error logged at call site
17
17
  throw error;
18
18
  }
19
19
  }
package/src/lib/logger.ts CHANGED
@@ -163,7 +163,8 @@ class Logger {
163
163
  return `\x1b[90m${time}\x1b[0m`; // Gray color
164
164
  }
165
165
 
166
- private formatContext(context: string): string {
166
+ private formatContext(context: string | unknown): string {
167
+ if (typeof context !== 'string') return '';
167
168
  const icon = CONTEXT_ICONS[context.toUpperCase()] || '📦';
168
169
  return `\x1b[94m${icon}${context}\x1b[0m `; // Blue color
169
170
  }
@@ -248,20 +249,36 @@ class Logger {
248
249
  }
249
250
  }
250
251
 
251
- error(message: string, context?: string, metadata?: Record<string, any>, error?: Error): void {
252
- this.log(LogLevel.ERROR, message, context, metadata, error);
252
+ error(message: string, contextOrError?: string | Error | unknown, metadata?: Record<string, any>, error?: Error): void {
253
+ if (contextOrError instanceof Error) {
254
+ this.log(LogLevel.ERROR, message, undefined, metadata, contextOrError);
255
+ } else {
256
+ this.log(LogLevel.ERROR, message, contextOrError as string | undefined, metadata, error);
257
+ }
253
258
  }
254
259
 
255
- warn(message: string, context?: string, metadata?: Record<string, any>): void {
256
- this.log(LogLevel.WARN, message, context, metadata);
260
+ warn(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
261
+ if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
262
+ this.log(LogLevel.WARN, message, undefined, contextOrMeta as Record<string, any>);
263
+ } else {
264
+ this.log(LogLevel.WARN, message, contextOrMeta as string | undefined, metadata);
265
+ }
257
266
  }
258
267
 
259
- info(message: string, context?: string, metadata?: Record<string, any>): void {
260
- this.log(LogLevel.INFO, message, context, metadata);
268
+ info(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
269
+ if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
270
+ this.log(LogLevel.INFO, message, undefined, contextOrMeta as Record<string, any>);
271
+ } else {
272
+ this.log(LogLevel.INFO, message, contextOrMeta as string | undefined, metadata);
273
+ }
261
274
  }
262
275
 
263
- debug(message: string, context?: string, metadata?: Record<string, any>): void {
264
- this.log(LogLevel.DEBUG, message, context, metadata);
276
+ debug(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
277
+ if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
278
+ this.log(LogLevel.DEBUG, message, undefined, contextOrMeta as Record<string, any>);
279
+ } else {
280
+ this.log(LogLevel.DEBUG, message, contextOrMeta as string | undefined, metadata);
281
+ }
265
282
  }
266
283
 
267
284
  trace(message: string, context?: string, metadata?: Record<string, any>): void {
@@ -0,0 +1,106 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import PusherService from './pusher.js';
4
+ import { NotificationType, createNotification } from './notification-service.js';
5
+
6
+ type FakeNotification = {
7
+ id: string;
8
+ userId: string;
9
+ type: string;
10
+ title: string;
11
+ body: string;
12
+ content?: string;
13
+ sourceId?: string;
14
+ createdAt: Date;
15
+ read: boolean;
16
+ };
17
+
18
+ function createFakeDb() {
19
+ const store: FakeNotification[] = [];
20
+
21
+ const db = {
22
+ notification: {
23
+ async findFirst(args: any) {
24
+ const where = args?.where ?? {};
25
+ return (
26
+ store.find((item) => {
27
+ if (where.userId && item.userId !== where.userId) return false;
28
+ if (where.type && item.type !== where.type) return false;
29
+ if (where.sourceId && item.sourceId !== where.sourceId) return false;
30
+ return true;
31
+ }) ?? null
32
+ );
33
+ },
34
+ async create(args: any) {
35
+ const data = args.data;
36
+ const item: FakeNotification = {
37
+ id: `n_${store.length + 1}`,
38
+ userId: data.userId,
39
+ type: data.type,
40
+ title: data.title,
41
+ body: data.body,
42
+ content: data.content,
43
+ sourceId: data.sourceId,
44
+ createdAt: new Date(),
45
+ read: false,
46
+ };
47
+ store.push(item);
48
+ return item;
49
+ },
50
+ async count(args: any) {
51
+ const where = args?.where ?? {};
52
+ return store.filter((item) => {
53
+ if (where.userId && item.userId !== where.userId) return false;
54
+ if (where.read !== undefined && item.read !== where.read) return false;
55
+ return true;
56
+ }).length;
57
+ },
58
+ },
59
+ };
60
+
61
+ return { db, store };
62
+ }
63
+
64
+ test('createNotification deduplicates by sourceId', async () => {
65
+ const { db, store } = createFakeDb();
66
+ const originalEmit = PusherService.emitNotificationNew;
67
+ PusherService.emitNotificationNew = async () => {};
68
+
69
+ await createNotification(db, {
70
+ userId: 'u1',
71
+ type: NotificationType.PAYMENT_SUCCEEDED,
72
+ title: 'Payment successful',
73
+ body: 'Paid',
74
+ sourceId: 'source-1',
75
+ });
76
+ await createNotification(db, {
77
+ userId: 'u1',
78
+ type: NotificationType.PAYMENT_SUCCEEDED,
79
+ title: 'Payment successful',
80
+ body: 'Paid',
81
+ sourceId: 'source-1',
82
+ });
83
+
84
+ assert.equal(store.length, 1);
85
+ PusherService.emitNotificationNew = originalEmit;
86
+ });
87
+
88
+ test('createNotification emits unread count payload', async () => {
89
+ const { db } = createFakeDb();
90
+ const originalEmit = PusherService.emitNotificationNew;
91
+ let capturedUnread = -1;
92
+
93
+ PusherService.emitNotificationNew = async (_userId, payload) => {
94
+ capturedUnread = payload.unreadCount;
95
+ };
96
+
97
+ await createNotification(db, {
98
+ userId: 'u1',
99
+ type: NotificationType.GENERAL,
100
+ title: 'Hello',
101
+ body: 'World',
102
+ });
103
+
104
+ assert.equal(capturedUnread, 1);
105
+ PusherService.emitNotificationNew = originalEmit;
106
+ });