@goscribe/server 1.0.11 → 1.1.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 (83) hide show
  1. package/ANALYSIS_PROGRESS_SPEC.md +463 -0
  2. package/PROGRESS_QUICK_REFERENCE.md +239 -0
  3. package/dist/lib/ai-session.d.ts +20 -9
  4. package/dist/lib/ai-session.js +316 -80
  5. package/dist/lib/auth.d.ts +35 -2
  6. package/dist/lib/auth.js +88 -15
  7. package/dist/lib/env.d.ts +32 -0
  8. package/dist/lib/env.js +46 -0
  9. package/dist/lib/errors.d.ts +33 -0
  10. package/dist/lib/errors.js +78 -0
  11. package/dist/lib/inference.d.ts +4 -1
  12. package/dist/lib/inference.js +9 -11
  13. package/dist/lib/logger.d.ts +62 -0
  14. package/dist/lib/logger.js +342 -0
  15. package/dist/lib/podcast-prompts.d.ts +43 -0
  16. package/dist/lib/podcast-prompts.js +135 -0
  17. package/dist/lib/pusher.d.ts +1 -0
  18. package/dist/lib/pusher.js +14 -2
  19. package/dist/lib/storage.d.ts +3 -3
  20. package/dist/lib/storage.js +51 -47
  21. package/dist/lib/validation.d.ts +51 -0
  22. package/dist/lib/validation.js +64 -0
  23. package/dist/routers/_app.d.ts +697 -111
  24. package/dist/routers/_app.js +5 -0
  25. package/dist/routers/auth.d.ts +11 -1
  26. package/dist/routers/chat.d.ts +11 -1
  27. package/dist/routers/flashcards.d.ts +205 -6
  28. package/dist/routers/flashcards.js +144 -66
  29. package/dist/routers/members.d.ts +165 -0
  30. package/dist/routers/members.js +531 -0
  31. package/dist/routers/podcast.d.ts +78 -63
  32. package/dist/routers/podcast.js +330 -393
  33. package/dist/routers/studyguide.d.ts +11 -1
  34. package/dist/routers/worksheets.d.ts +124 -13
  35. package/dist/routers/worksheets.js +123 -50
  36. package/dist/routers/workspace.d.ts +213 -26
  37. package/dist/routers/workspace.js +303 -181
  38. package/dist/server.js +12 -4
  39. package/dist/services/flashcard-progress.service.d.ts +183 -0
  40. package/dist/services/flashcard-progress.service.js +383 -0
  41. package/dist/services/flashcard.service.d.ts +183 -0
  42. package/dist/services/flashcard.service.js +224 -0
  43. package/dist/services/podcast-segment-reorder.d.ts +0 -0
  44. package/dist/services/podcast-segment-reorder.js +107 -0
  45. package/dist/services/podcast.service.d.ts +0 -0
  46. package/dist/services/podcast.service.js +326 -0
  47. package/dist/services/worksheet.service.d.ts +0 -0
  48. package/dist/services/worksheet.service.js +295 -0
  49. package/dist/trpc.d.ts +13 -2
  50. package/dist/trpc.js +55 -6
  51. package/dist/types/index.d.ts +126 -0
  52. package/dist/types/index.js +1 -0
  53. package/package.json +3 -2
  54. package/prisma/schema.prisma +142 -4
  55. package/src/lib/ai-session.ts +356 -85
  56. package/src/lib/auth.ts +113 -19
  57. package/src/lib/env.ts +59 -0
  58. package/src/lib/errors.ts +92 -0
  59. package/src/lib/inference.ts +11 -11
  60. package/src/lib/logger.ts +405 -0
  61. package/src/lib/pusher.ts +15 -3
  62. package/src/lib/storage.ts +56 -51
  63. package/src/lib/validation.ts +75 -0
  64. package/src/routers/_app.ts +5 -0
  65. package/src/routers/chat.ts +2 -23
  66. package/src/routers/flashcards.ts +108 -24
  67. package/src/routers/members.ts +586 -0
  68. package/src/routers/podcast.ts +385 -420
  69. package/src/routers/worksheets.ts +118 -36
  70. package/src/routers/workspace.ts +356 -195
  71. package/src/server.ts +13 -4
  72. package/src/services/flashcard-progress.service.ts +541 -0
  73. package/src/trpc.ts +59 -6
  74. package/src/types/index.ts +165 -0
  75. package/AUTH_FRONTEND_SPEC.md +0 -21
  76. package/CHAT_FRONTEND_SPEC.md +0 -474
  77. package/DATABASE_SETUP.md +0 -165
  78. package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
  79. package/PODCAST_FRONTEND_SPEC.md +0 -595
  80. package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
  81. package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
  82. package/WORKSPACE_FRONTEND_SPEC.md +0 -47
  83. package/test-ai-integration.js +0 -134
@@ -0,0 +1,405 @@
1
+ import { createWriteStream, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export enum LogLevel {
5
+ ERROR = 0,
6
+ WARN = 1,
7
+ INFO = 2,
8
+ DEBUG = 3,
9
+ TRACE = 4,
10
+ }
11
+
12
+ // Icons and colors for different log levels
13
+ const LOG_STYLES = {
14
+ [LogLevel.ERROR]: {
15
+ icon: '❌',
16
+ color: '\x1b[31m', // Red
17
+ bgColor: '\x1b[41m', // Red background
18
+ bold: '\x1b[1m',
19
+ },
20
+ [LogLevel.WARN]: {
21
+ icon: '⚠️ ',
22
+ color: '\x1b[33m', // Yellow
23
+ bgColor: '\x1b[43m', // Yellow background
24
+ bold: '\x1b[1m',
25
+ },
26
+ [LogLevel.INFO]: {
27
+ icon: 'ℹ️ ',
28
+ color: '\x1b[36m', // Cyan
29
+ bgColor: '\x1b[46m', // Cyan background
30
+ bold: '\x1b[1m',
31
+ },
32
+ [LogLevel.DEBUG]: {
33
+ icon: '🐛',
34
+ color: '\x1b[35m', // Magenta
35
+ bgColor: '\x1b[45m', // Magenta background
36
+ bold: '\x1b[1m',
37
+ },
38
+ [LogLevel.TRACE]: {
39
+ icon: '🔍',
40
+ color: '\x1b[37m', // White
41
+ bgColor: '\x1b[47m', // White background
42
+ bold: '\x1b[1m',
43
+ },
44
+ };
45
+
46
+ // Context icons for common services
47
+ const CONTEXT_ICONS: Record<string, string> = {
48
+ 'SERVER': '🖥️ ',
49
+ 'HTTP': '🌐',
50
+ 'API': '🔌',
51
+ 'AUTH': '🔐',
52
+ 'DATABASE': '🗄️ ',
53
+ 'DB': '🗄️ ',
54
+ 'TRPC': '⚡',
55
+ 'WORKSPACE': '📁',
56
+ 'WORKSHEET': '📝',
57
+ 'FLASHCARD': '🃏',
58
+ 'STUDYGUIDE': '📚',
59
+ 'PODCAST': '🎙️ ',
60
+ 'MEETING': '🤝',
61
+ 'CHAT': '💬',
62
+ 'FILE': '📄',
63
+ 'STORAGE': '💾',
64
+ 'CACHE': '⚡',
65
+ 'MIDDLEWARE': '🔧',
66
+ 'PERFORMANCE': '⚡',
67
+ 'SECURITY': '🛡️ ',
68
+ 'VALIDATION': '✅',
69
+ 'ERROR': '❌',
70
+ 'SUCCESS': '✅',
71
+ 'LOGGER': '📋',
72
+ };
73
+
74
+ export interface LogEntry {
75
+ timestamp: string;
76
+ level: string;
77
+ message: string;
78
+ context?: string;
79
+ metadata?: Record<string, any>;
80
+ error?: {
81
+ name: string;
82
+ message: string;
83
+ stack?: string;
84
+ };
85
+ }
86
+
87
+ export interface LoggerConfig {
88
+ level: LogLevel;
89
+ enableConsole: boolean;
90
+ enableFile: boolean;
91
+ logDir?: string;
92
+ maxFileSize?: number;
93
+ maxFiles?: number;
94
+ format?: 'json' | 'pretty';
95
+ }
96
+
97
+ class Logger {
98
+ private config: LoggerConfig;
99
+ private logStream?: NodeJS.WritableStream;
100
+
101
+ constructor(config: Partial<LoggerConfig> = {}) {
102
+ this.config = {
103
+ level: LogLevel.INFO,
104
+ enableConsole: true,
105
+ enableFile: false,
106
+ logDir: './logs',
107
+ maxFileSize: 10 * 1024 * 1024, // 10MB
108
+ maxFiles: 5,
109
+ format: 'pretty',
110
+ ...config,
111
+ };
112
+
113
+ if (this.config.enableFile) {
114
+ this.setupFileLogging();
115
+ }
116
+ }
117
+
118
+ private setupFileLogging(): void {
119
+ if (!this.config.logDir) return;
120
+
121
+ // Ensure log directory exists
122
+ if (!existsSync(this.config.logDir)) {
123
+ mkdirSync(this.config.logDir, { recursive: true });
124
+ }
125
+
126
+ const logFile = join(this.config.logDir, `app-${new Date().toISOString().split('T')[0]}.log`);
127
+ this.logStream = createWriteStream(logFile, { flags: 'a' });
128
+ }
129
+
130
+ private shouldLog(level: LogLevel): boolean {
131
+ return level <= this.config.level;
132
+ }
133
+
134
+ private formatLogEntry(entry: LogEntry): string {
135
+ if (this.config.format === 'json') {
136
+ return JSON.stringify(entry) + '\n';
137
+ }
138
+
139
+ // Pretty format with enhanced styling
140
+ const timestamp = this.formatTimestamp(entry.timestamp);
141
+ const level = this.formatLevel(entry.level);
142
+ const context = entry.context ? this.formatContext(entry.context) : '';
143
+ const metadata = entry.metadata ? this.formatMetadata(entry.metadata) : '';
144
+ const error = entry.error ? this.formatError(entry.error) : '';
145
+
146
+ return `${timestamp} ${level} ${context}${entry.message}${metadata}${error}`;
147
+ }
148
+
149
+ private formatLevel(level: string): string {
150
+ const levelNum = LogLevel[level as keyof typeof LogLevel];
151
+ const style = LOG_STYLES[levelNum];
152
+ return `${style.color}${style.bold}${level.padEnd(5)}\x1b[0m`;
153
+ }
154
+
155
+ private formatTimestamp(timestamp: string): string {
156
+ const date = new Date(timestamp);
157
+ const time = date.toLocaleTimeString('en-US', {
158
+ hour12: false,
159
+ hour: '2-digit',
160
+ minute: '2-digit',
161
+ second: '2-digit',
162
+ });
163
+ return `\x1b[90m${time}\x1b[0m`; // Gray color
164
+ }
165
+
166
+ private formatContext(context: string): string {
167
+ const icon = CONTEXT_ICONS[context.toUpperCase()] || '📦';
168
+ return `\x1b[94m${icon}${context}\x1b[0m `; // Blue color
169
+ }
170
+
171
+ private formatMetadata(metadata: Record<string, any>): string {
172
+ const entries = Object.entries(metadata)
173
+ .map(([key, value]) => {
174
+ const formattedValue = this.formatValue(value);
175
+ return `\x1b[93m${key}\x1b[0m=\x1b[96m${formattedValue}\x1b[0m`;
176
+ })
177
+ .join(' \x1b[90m|\x1b[0m ');
178
+
179
+ return entries ? `\n \x1b[90m└─\x1b[0m \x1b[90m{\x1b[0m ${entries} \x1b[90m}\x1b[0m` : '';
180
+ }
181
+
182
+ private formatValue(value: any): string {
183
+ if (value === null) return '\x1b[90mnull\x1b[0m';
184
+ if (value === undefined) return '\x1b[90mundefined\x1b[0m';
185
+ if (typeof value === 'boolean') return value ? '\x1b[92mtrue\x1b[0m' : '\x1b[91mfalse\x1b[0m';
186
+ if (typeof value === 'number') return `\x1b[95m${value}\x1b[0m`;
187
+ if (typeof value === 'string') return `\x1b[96m"${value}"\x1b[0m`;
188
+ if (typeof value === 'object') {
189
+ if (Array.isArray(value)) {
190
+ const items = value.map(item => this.formatValue(item)).join('\x1b[90m, \x1b[0m');
191
+ return `\x1b[90m[\x1b[0m${items}\x1b[90m]\x1b[0m`;
192
+ }
193
+ const objEntries = Object.entries(value)
194
+ .map(([k, v]) => `\x1b[93m${k}\x1b[0m:\x1b[96m${this.formatValue(v)}\x1b[0m`)
195
+ .join('\x1b[90m, \x1b[0m');
196
+ return `\x1b[90m{\x1b[0m${objEntries}\x1b[90m}\x1b[0m`;
197
+ }
198
+ return `\x1b[96m${String(value)}\x1b[0m`;
199
+ }
200
+
201
+ private formatError(error: { name: string; message: string; stack?: string }): string {
202
+ let errorStr = `\n \x1b[90m└─\x1b[0m \x1b[31m❌ \x1b[93m${error.name}\x1b[0m: \x1b[91m${error.message}\x1b[0m`;
203
+
204
+ if (error.stack) {
205
+ const stackLines = error.stack.split('\n').slice(1, 4); // Show first 3 stack lines
206
+ errorStr += `\n \x1b[90m└─ Stack:\x1b[0m`;
207
+ stackLines.forEach((line, index) => {
208
+ const isLast = index === stackLines.length - 1;
209
+ const connector = isLast ? '└─' : '├─';
210
+ errorStr += `\n \x1b[90m${connector} \x1b[0m\x1b[90m${line.trim()}\x1b[0m`;
211
+ });
212
+ }
213
+
214
+ return errorStr;
215
+ }
216
+
217
+ private log(level: LogLevel, message: string, context?: string, metadata?: Record<string, any>, error?: Error): void {
218
+ if (!this.shouldLog(level)) return;
219
+
220
+ const entry: LogEntry = {
221
+ timestamp: new Date().toISOString(),
222
+ level: LogLevel[level],
223
+ message,
224
+ context,
225
+ metadata,
226
+ error: error ? {
227
+ name: error.name,
228
+ message: error.message,
229
+ stack: error.stack,
230
+ } : undefined,
231
+ };
232
+
233
+ const formattedLog = this.formatLogEntry(entry);
234
+
235
+ if (this.config.enableConsole) {
236
+ // Enhanced console output with icons and colors
237
+ const style = LOG_STYLES[level];
238
+ const reset = '\x1b[0m';
239
+
240
+ // Create a beautiful log line with proper spacing and colors
241
+ const logLine = `${style.color}${style.icon} ${formattedLog}${reset}`;
242
+
243
+ console.log(logLine);
244
+ }
245
+
246
+ if (this.config.enableFile && this.logStream) {
247
+ this.logStream.write(formattedLog + '\n');
248
+ }
249
+ }
250
+
251
+ error(message: string, context?: string, metadata?: Record<string, any>, error?: Error): void {
252
+ this.log(LogLevel.ERROR, message, context, metadata, error);
253
+ }
254
+
255
+ warn(message: string, context?: string, metadata?: Record<string, any>): void {
256
+ this.log(LogLevel.WARN, message, context, metadata);
257
+ }
258
+
259
+ info(message: string, context?: string, metadata?: Record<string, any>): void {
260
+ this.log(LogLevel.INFO, message, context, metadata);
261
+ }
262
+
263
+ debug(message: string, context?: string, metadata?: Record<string, any>): void {
264
+ this.log(LogLevel.DEBUG, message, context, metadata);
265
+ }
266
+
267
+ trace(message: string, context?: string, metadata?: Record<string, any>): void {
268
+ this.log(LogLevel.TRACE, message, context, metadata);
269
+ }
270
+
271
+ // Convenience methods for common use cases
272
+ http(method: string, url: string, statusCode: number, responseTime?: number, context?: string): void {
273
+ const statusIcon = this.getHttpStatusIcon(statusCode);
274
+ const responseTimeStr = responseTime ? `${responseTime}ms` : undefined;
275
+
276
+ const metadata = {
277
+ method,
278
+ url,
279
+ statusCode,
280
+ responseTime: responseTimeStr,
281
+ };
282
+
283
+ this.info(`${statusIcon} ${method} ${url} - ${statusCode}`, context || 'HTTP', metadata);
284
+ }
285
+
286
+ private getHttpStatusIcon(statusCode: number): string {
287
+ if (statusCode >= 200 && statusCode < 300) return '✅';
288
+ if (statusCode >= 300 && statusCode < 400) return '↩️ ';
289
+ if (statusCode >= 400 && statusCode < 500) return '⚠️ ';
290
+ if (statusCode >= 500) return '❌';
291
+ return '❓';
292
+ }
293
+
294
+ database(operation: string, table: string, duration?: number, context?: string): void {
295
+ const operationIcon = this.getDatabaseOperationIcon(operation);
296
+ const durationStr = duration ? `${duration}ms` : undefined;
297
+
298
+ const metadata = {
299
+ operation,
300
+ table,
301
+ duration: durationStr,
302
+ };
303
+
304
+ this.debug(`${operationIcon} ${operation} on ${table}`, context || 'DATABASE', metadata);
305
+ }
306
+
307
+ private getDatabaseOperationIcon(operation: string): string {
308
+ const op = operation.toUpperCase();
309
+ if (op.includes('SELECT')) return '🔍';
310
+ if (op.includes('INSERT')) return '➕';
311
+ if (op.includes('UPDATE')) return '✏️ ';
312
+ if (op.includes('DELETE')) return '🗑️ ';
313
+ if (op.includes('CREATE')) return '🏗️ ';
314
+ if (op.includes('DROP')) return '💥';
315
+ return '🗄️ ';
316
+ }
317
+
318
+ auth(action: string, userId?: string, context?: string): void {
319
+ const metadata = {
320
+ action,
321
+ userId,
322
+ };
323
+ this.info(`Auth ${action}`, context, metadata);
324
+ }
325
+
326
+ trpc(procedure: string, input?: any, output?: any, duration?: number, context?: string): void {
327
+ const metadata = {
328
+ procedure,
329
+ input: input ? JSON.stringify(input) : undefined,
330
+ output: output ? JSON.stringify(output) : undefined,
331
+ duration: duration ? `${duration}ms` : undefined,
332
+ };
333
+ this.debug(`tRPC ${procedure}`, context, metadata);
334
+ }
335
+
336
+ // Method to update configuration at runtime
337
+ updateConfig(newConfig: Partial<LoggerConfig>): void {
338
+ this.config = { ...this.config, ...newConfig };
339
+
340
+ if (newConfig.enableFile && !this.logStream) {
341
+ this.setupFileLogging();
342
+ } else if (!newConfig.enableFile && this.logStream) {
343
+ this.logStream.end();
344
+ this.logStream = undefined;
345
+ }
346
+ }
347
+
348
+ // Progress indicator for long-running operations
349
+ progress(message: string, current: number, total: number, context?: string): void {
350
+ const percentage = Math.round((current / total) * 100);
351
+ const progressBar = this.createProgressBar(percentage);
352
+
353
+ this.info(`${progressBar} ${message} (${current}/${total} - ${percentage}%)`, context || 'PROGRESS');
354
+ }
355
+
356
+ private createProgressBar(percentage: number, width: number = 20): string {
357
+ const filled = Math.round((percentage / 100) * width);
358
+ const empty = width - filled;
359
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
360
+ return `[${bar}]`;
361
+ }
362
+
363
+ // Success and failure helpers
364
+ success(message: string, context?: string, metadata?: Record<string, any>): void {
365
+ this.info(`✅ ${message}`, context, metadata);
366
+ }
367
+
368
+ failure(message: string, context?: string, metadata?: Record<string, any>, error?: Error): void {
369
+ this.error(`❌ ${message}`, context, metadata, error);
370
+ }
371
+
372
+ // Method to close file streams (useful for graceful shutdown)
373
+ close(): void {
374
+ if (this.logStream) {
375
+ this.logStream.end();
376
+ }
377
+ }
378
+ }
379
+
380
+ // Create default logger instance
381
+ const defaultConfig: Partial<LoggerConfig> = {
382
+ level: process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG,
383
+ enableConsole: true,
384
+ enableFile: process.env.NODE_ENV === 'production',
385
+ logDir: './logs',
386
+ format: process.env.NODE_ENV === 'production' ? 'json' : 'pretty',
387
+ };
388
+
389
+ export const logger = new Logger(defaultConfig);
390
+
391
+ // Export the Logger class for custom instances
392
+ export { Logger };
393
+
394
+ // Graceful shutdown handling
395
+ process.on('SIGINT', () => {
396
+ logger.info('Received SIGINT, closing logger...');
397
+ logger.close();
398
+ process.exit(0);
399
+ });
400
+
401
+ process.on('SIGTERM', () => {
402
+ logger.info('Received SIGTERM, closing logger...');
403
+ logger.close();
404
+ process.exit(0);
405
+ });
package/src/lib/pusher.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Pusher from 'pusher';
2
+ import { logger } from './logger.js';
2
3
 
3
4
  // Server-side Pusher instance
4
5
  export const pusher = new Pusher({
@@ -15,9 +16,9 @@ export class PusherService {
15
16
  static async emitTaskComplete(workspaceId: string, event: string, data: any) {
16
17
  try {
17
18
  const channel = `workspace_${workspaceId}`;
18
- const eventName = `${workspaceId}_${event}`;
19
+ const eventName = event;
19
20
  await pusher.trigger(channel, eventName, data);
20
- console.log(`📡 Pusher notification sent: ${eventName} to ${channel}`);
21
+ logger.info(`📡 Pusher notification sent: ${eventName} to ${channel}`);
21
22
  } catch (error) {
22
23
  console.error('❌ Pusher notification error:', error);
23
24
  }
@@ -88,11 +89,22 @@ export class PusherService {
88
89
  });
89
90
  }
90
91
 
92
+ // Emit analysis progress update (single event for all progress updates)
93
+ static async emitAnalysisProgress(workspaceId: string, progress: any) {
94
+ try {
95
+ const channel = `workspace_${workspaceId}`;
96
+ await pusher.trigger(channel, 'analysis_progress', progress);
97
+ logger.info(`📡 Analysis progress sent to ${channel}: ${progress.status}`);
98
+ } catch (error) {
99
+ console.error('❌ Pusher progress notification error:', error);
100
+ }
101
+ }
102
+
91
103
  // Emit channel-specific events (for chat messages)
92
104
  static async emitChannelEvent(channelId: string, event: string, data: any) {
93
105
  try {
94
106
  const channel = channelId; // Use channelId directly as channel name
95
- const eventName = `${channelId}_${event}`;
107
+ const eventName = event;
96
108
  await pusher.trigger(channel, eventName, data);
97
109
  console.log(`📡 Pusher notification sent: ${eventName} to ${channel}`);
98
110
  } catch (error) {
@@ -1,18 +1,18 @@
1
- // src/server/lib/gcs.ts
2
- import { Storage } from '@google-cloud/storage';
1
+ // src/server/lib/storage.ts
2
+ import { createClient } from '@supabase/supabase-js';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
- // Initialize Google Cloud Storage
6
- const storage = new Storage({
7
- projectId: process.env.GCP_PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT_ID,
8
- credentials: process.env.GCP_CLIENT_EMAIL && process.env.GCP_PRIVATE_KEY ? {
9
- client_email: process.env.GCP_CLIENT_EMAIL,
10
- private_key: process.env.GCP_PRIVATE_KEY?.replace(/\\n/g, "\n"),
11
- } : undefined,
12
- keyFilename: process.env.GOOGLE_CLOUD_KEY_FILE || process.env.GCP_KEY_FILE,
13
- });
5
+ // Initialize Supabase Storage
6
+ const supabaseUrl = process.env.SUPABASE_URL;
7
+ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
14
8
 
15
- const bucketName = process.env.GCP_BUCKET || process.env.GOOGLE_CLOUD_BUCKET_NAME || 'your-bucket-name';
9
+ if (!supabaseUrl || !supabaseServiceKey) {
10
+ throw new Error('Missing required Supabase environment variables: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY');
11
+ }
12
+
13
+ const supabase = createClient(supabaseUrl, supabaseServiceKey);
14
+
15
+ const bucketName = process.env.SUPABASE_BUCKET || 'media';
16
16
 
17
17
  export interface UploadResult {
18
18
  url: string;
@@ -20,35 +20,39 @@ export interface UploadResult {
20
20
  objectKey: string;
21
21
  }
22
22
 
23
- export async function uploadToGCS(
23
+ export async function uploadToSupabase(
24
24
  fileBuffer: Buffer,
25
25
  fileName: string,
26
26
  contentType: string,
27
27
  makePublic: boolean = false
28
28
  ): Promise<UploadResult> {
29
- const bucket = storage.bucket(bucketName);
30
29
  const objectKey = `podcasts/${uuidv4()}_${fileName}`;
31
- const file = bucket.file(objectKey);
32
30
 
33
- // Upload the file
34
- await file.save(fileBuffer, {
35
- metadata: {
31
+ // Upload the file to Supabase Storage
32
+ const { data, error } = await supabase.storage
33
+ .from(bucketName)
34
+ .upload(objectKey, fileBuffer, {
36
35
  contentType,
37
- },
38
- public: makePublic,
39
- });
36
+ upsert: false,
37
+ });
40
38
 
41
- const url = `gs://${bucketName}/${objectKey}`;
39
+ if (error) {
40
+ throw new Error(`Failed to upload file to Supabase: ${error.message}`);
41
+ }
42
+
43
+ const url = `${supabaseUrl}/storage/v1/object/public/${bucketName}/${objectKey}`;
42
44
 
43
45
  // Generate signed URL for private files
44
46
  let signedUrl: string | undefined;
45
47
  if (!makePublic) {
46
- const [signedUrlResult] = await file.getSignedUrl({
47
- version: 'v4',
48
- action: 'read',
49
- expires: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
50
- });
51
- signedUrl = signedUrlResult;
48
+ const { data: signedUrlData, error: signedUrlError } = await supabase.storage
49
+ .from(bucketName)
50
+ .createSignedUrl(objectKey, 24 * 60 * 60); // 24 hours
51
+
52
+ if (signedUrlError) {
53
+ throw new Error(`Failed to generate signed URL: ${signedUrlError.message}`);
54
+ }
55
+ signedUrl = signedUrlData.signedUrl;
52
56
  }
53
57
 
54
58
  return {
@@ -59,38 +63,39 @@ export async function uploadToGCS(
59
63
  }
60
64
 
61
65
  export async function generateSignedUrl(objectKey: string, expiresInHours: number = 24): Promise<string> {
62
- const bucket = storage.bucket(bucketName);
63
- const file = bucket.file(objectKey);
66
+ const { data, error } = await supabase.storage
67
+ .from(bucketName)
68
+ .createSignedUrl(objectKey, expiresInHours * 60 * 60);
64
69
 
65
- const [signedUrl] = await file.getSignedUrl({
66
- version: 'v4',
67
- action: 'read',
68
- expires: Date.now() + expiresInHours * 60 * 60 * 1000,
69
- });
70
+ if (error) {
71
+ throw new Error(`Failed to generate signed URL: ${error.message}`);
72
+ }
70
73
 
71
- return signedUrl;
74
+ return data.signedUrl;
72
75
  }
73
76
 
74
- export async function deleteFromGCS(objectKey: string): Promise<void> {
75
- const bucket = storage.bucket(bucketName);
76
- const file = bucket.file(objectKey);
77
-
78
- await file.delete();
77
+ export async function deleteFromSupabase(objectKey: string): Promise<void> {
78
+ const { error } = await supabase.storage
79
+ .from(bucketName)
80
+ .remove([objectKey]);
81
+
82
+ if (error) {
83
+ throw new Error(`Failed to delete file from Supabase: ${error.message}`);
84
+ }
79
85
  }
80
86
 
81
87
  export async function makeFilePublic(objectKey: string): Promise<void> {
82
- const bucket = storage.bucket(bucketName);
83
- const file = bucket.file(objectKey);
84
-
85
- await file.makePublic();
88
+ // In Supabase, files are public by default when uploaded to public buckets
89
+ // For private buckets, you would need to update the bucket policy
90
+ // This function is kept for compatibility but may not be needed
91
+ console.log(`File ${objectKey} is already public in Supabase Storage`);
86
92
  }
87
93
 
88
94
  export async function makeFilePrivate(objectKey: string): Promise<void> {
89
- const bucket = storage.bucket(bucketName);
90
- const file = bucket.file(objectKey);
91
-
92
- await file.makePrivate();
95
+ // In Supabase, you would need to update the bucket policy to make files private
96
+ // This function is kept for compatibility but may not be needed
97
+ console.log(`File ${objectKey} privacy is controlled by bucket policy in Supabase Storage`);
93
98
  }
94
99
 
95
-
96
- export const bucket = storage.bucket(bucketName);
100
+ // Export supabase client for direct access if needed
101
+ export const supabaseClient = supabase;
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+ import { ValidationError } from './errors.js';
3
+
4
+ /**
5
+ * Common validation schemas
6
+ */
7
+ export const commonSchemas = {
8
+ id: z.string().cuid(),
9
+ email: z.string().email(),
10
+ url: z.string().url(),
11
+ pagination: z.object({
12
+ page: z.number().int().positive().default(1),
13
+ limit: z.number().int().positive().max(100).default(20),
14
+ }),
15
+ search: z.object({
16
+ query: z.string().min(1).max(200),
17
+ }),
18
+ };
19
+
20
+ /**
21
+ * Enums for type safety
22
+ */
23
+ export const ArtifactType = z.enum([
24
+ 'STUDY_GUIDE',
25
+ 'FLASHCARD_SET',
26
+ 'WORKSHEET',
27
+ 'MEETING_SUMMARY',
28
+ 'PODCAST_EPISODE',
29
+ ]);
30
+
31
+ export const Difficulty = z.enum(['EASY', 'MEDIUM', 'HARD']);
32
+
33
+ export const QuestionType = z.enum([
34
+ 'MULTIPLE_CHOICE',
35
+ 'TEXT',
36
+ 'NUMERIC',
37
+ 'TRUE_FALSE',
38
+ 'MATCHING',
39
+ 'FILL_IN_THE_BLANK',
40
+ ]);
41
+
42
+ /**
43
+ * Validation helper that throws ValidationError
44
+ */
45
+ export function validateSchema<T extends z.ZodType>(
46
+ schema: T,
47
+ data: unknown
48
+ ): z.infer<T> {
49
+ const result = schema.safeParse(data);
50
+ if (!result.success) {
51
+ const errors = result.error.message;
52
+ throw new ValidationError(`Validation failed: ${errors}`);
53
+ }
54
+ return result.data;
55
+ }
56
+
57
+ /**
58
+ * Sanitize string inputs
59
+ */
60
+ export function sanitizeString(input: string, maxLength: number = 10000): string {
61
+ return input
62
+ .trim()
63
+ .slice(0, maxLength)
64
+ .replace(/[<>]/g, ''); // Basic XSS prevention
65
+ }
66
+
67
+ /**
68
+ * Validate ownership
69
+ */
70
+ export function validateOwnership(ownerId: string, userId: string): void {
71
+ if (ownerId !== userId) {
72
+ throw new ValidationError('You do not have permission to access this resource');
73
+ }
74
+ }
75
+
@@ -7,6 +7,7 @@ import { worksheets } from './worksheets.js';
7
7
  import { studyguide } from './studyguide.js';
8
8
  import { podcast } from './podcast.js';
9
9
  import { chat } from './chat.js';
10
+ import { members } from './members.js';
10
11
 
11
12
  export const appRouter = router({
12
13
  auth,
@@ -16,6 +17,10 @@ export const appRouter = router({
16
17
  studyguide,
17
18
  podcast,
18
19
  chat,
20
+ // Public member endpoints (for invitation acceptance)
21
+ member: router({
22
+ acceptInvite: members.acceptInvite,
23
+ }),
19
24
  });
20
25
 
21
26
  // Export type for client inference