@studious-lms/server 1.1.2 → 1.1.4

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/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { appRouter } from './routers/_app.js';
9
9
  import { createTRPCContext, createCallerFactory } from './trpc.js';
10
10
  import { logger } from './utils/logger.js';
11
11
  import { setupSocketHandlers } from './socket/handlers.js';
12
+ import { bucket } from './lib/googleCloudStorage.js';
12
13
 
13
14
  dotenv.config();
14
15
 
@@ -16,10 +17,55 @@ const app = express();
16
17
 
17
18
  // CORS middleware
18
19
  app.use(cors({
19
- origin: [process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', 'http://localhost:3000'],
20
+ origin: [
21
+ 'http://localhost:3000', // Frontend development server
22
+ 'http://localhost:3001', // Server port
23
+ 'http://127.0.0.1:3000', // Alternative localhost
24
+ 'http://127.0.0.1:3001', // Alternative localhost
25
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
26
+ ],
20
27
  credentials: true,
28
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
29
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
30
+ optionsSuccessStatus: 200
21
31
  }));
22
32
 
33
+ // Handle preflight OPTIONS requests
34
+ app.options('*', (req, res) => {
35
+ const allowedOrigins = [
36
+ 'http://localhost:3000',
37
+ 'http://localhost:3001',
38
+ 'http://127.0.0.1:3000',
39
+ 'http://127.0.0.1:3001',
40
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
41
+ ];
42
+
43
+ const origin = req.headers.origin;
44
+ if (origin && allowedOrigins.includes(origin)) {
45
+ res.header('Access-Control-Allow-Origin', origin);
46
+ } else {
47
+ res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
48
+ }
49
+
50
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
51
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
52
+ res.header('Access-Control-Allow-Credentials', 'true');
53
+ res.sendStatus(200);
54
+ });
55
+
56
+ // CORS debugging middleware
57
+ app.use((req, res, next) => {
58
+ if (req.method === 'OPTIONS' || req.path.includes('trpc')) {
59
+ logger.info('CORS Request', {
60
+ method: req.method,
61
+ path: req.path,
62
+ origin: req.headers.origin,
63
+ userAgent: req.headers['user-agent']
64
+ });
65
+ }
66
+ next();
67
+ });
68
+
23
69
  // Response time logging middleware
24
70
  app.use((req, res, next) => {
25
71
  const start = Date.now();
@@ -41,10 +87,16 @@ const httpServer = createServer(app);
41
87
  // Setup Socket.IO
42
88
  const io = new Server(httpServer, {
43
89
  cors: {
44
- origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
45
- methods: ['GET', 'POST'],
90
+ origin: [
91
+ 'http://localhost:3000', // Frontend development server
92
+ 'http://localhost:3001', // Server port
93
+ 'http://127.0.0.1:3000', // Alternative localhost
94
+ 'http://127.0.0.1:3001', // Alternative localhost
95
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
96
+ ],
97
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
46
98
  credentials: true,
47
- allowedHeaders: ['Access-Control-Allow-Origin']
99
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Access-Control-Allow-Origin', 'x-user']
48
100
  },
49
101
  transports: ['websocket', 'polling'],
50
102
  pingTimeout: 60000,
@@ -62,6 +114,50 @@ io.engine.on('connection_error', (err: Error) => {
62
114
  // Setup socket handlers
63
115
  setupSocketHandlers(io);
64
116
 
117
+ // File serving endpoint for secure file access
118
+ app.get('/api/files/:filePath', async (req, res) => {
119
+ try {
120
+ const filePath = decodeURIComponent(req.params.filePath);
121
+ console.log('File request:', { filePath, originalPath: req.params.filePath });
122
+
123
+ // Get file from Google Cloud Storage
124
+ const file = bucket.file(filePath);
125
+ const [exists] = await file.exists();
126
+
127
+ console.log('File exists:', exists, 'for path:', filePath);
128
+
129
+ if (!exists) {
130
+ return res.status(404).json({ error: 'File not found', filePath });
131
+ }
132
+
133
+ // Get file metadata
134
+ const [metadata] = await file.getMetadata();
135
+
136
+ // Set appropriate headers
137
+ res.set({
138
+ 'Content-Type': metadata.contentType || 'application/octet-stream',
139
+ 'Content-Length': metadata.size,
140
+ 'Cache-Control': 'public, max-age=31536000', // 1 year cache
141
+ 'ETag': metadata.etag,
142
+ });
143
+
144
+ // Stream file to response
145
+ const stream = file.createReadStream();
146
+ stream.pipe(res);
147
+
148
+ stream.on('error', (error) => {
149
+ console.error('Error streaming file:', error);
150
+ if (!res.headersSent) {
151
+ res.status(500).json({ error: 'Error streaming file' });
152
+ }
153
+ });
154
+
155
+ } catch (error) {
156
+ console.error('Error serving file:', error);
157
+ res.status(500).json({ error: 'Internal server error' });
158
+ }
159
+ });
160
+
65
161
  // Create caller
66
162
  const createCaller = createCallerFactory(appRouter);
67
163
 
@@ -91,4 +187,15 @@ logger.info('Configurations', {
91
187
  PORT: process.env.PORT,
92
188
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
93
189
  LOG_MODE: process.env.LOG_MODE,
190
+ });
191
+
192
+ // Log CORS configuration
193
+ logger.info('CORS Configuration', {
194
+ allowedOrigins: [
195
+ 'http://localhost:3000',
196
+ 'http://localhost:3001',
197
+ 'http://127.0.0.1:3000',
198
+ 'http://127.0.0.1:3001',
199
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
200
+ ]
94
201
  });
@@ -11,6 +11,13 @@ export interface FileData {
11
11
  data: string; // base64 encoded file data
12
12
  }
13
13
 
14
+ export interface DirectFileData {
15
+ name: string;
16
+ type: string;
17
+ size: number;
18
+ // No data field - for direct file uploads
19
+ }
20
+
14
21
  export interface UploadedFile {
15
22
  id: string;
16
23
  name: string;
@@ -35,8 +42,25 @@ export async function uploadFile(
35
42
  assignmentId?: string
36
43
  ): Promise<UploadedFile> {
37
44
  try {
45
+ // Validate file extension matches MIME type
46
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
47
+ const mimeType = file.type.toLowerCase();
48
+
49
+ const extensionMimeMap: Record<string, string[]> = {
50
+ 'jpg': ['image/jpeg'],
51
+ 'jpeg': ['image/jpeg'],
52
+ 'png': ['image/png'],
53
+ 'gif': ['image/gif'],
54
+ 'webp': ['image/webp']
55
+ };
56
+
57
+ if (fileExtension && extensionMimeMap[fileExtension]) {
58
+ if (!extensionMimeMap[fileExtension].includes(mimeType)) {
59
+ throw new Error(`File extension .${fileExtension} does not match MIME type ${mimeType}`);
60
+ }
61
+ }
62
+
38
63
  // Create a unique filename
39
- const fileExtension = file.name.split('.').pop();
40
64
  const uniqueFilename = `${uuidv4()}.${fileExtension}`;
41
65
 
42
66
  // // Construct the full path
@@ -50,9 +74,10 @@ export async function uploadFile(
50
74
  // // Generate and store thumbnail if supported
51
75
  let thumbnailId: string | undefined;
52
76
  try {
53
- // // Convert base64 to buffer for thumbnail generation
54
- const base64Data = file.data.split(',')[1];
55
- const fileBuffer = Buffer.from(base64Data, 'base64');
77
+ // // Convert base64 to buffer for thumbnail generation
78
+ // Handle both data URI format (data:image/jpeg;base64,...) and raw base64
79
+ const base64Data = file.data.includes(',') ? file.data.split(',')[1] : file.data;
80
+ const fileBuffer = Buffer.from(base64Data, 'base64');
56
81
 
57
82
  // // Generate thumbnail directly from buffer
58
83
  const thumbnailBuffer = await generateMediaThumbnail(fileBuffer, file.type);
@@ -79,6 +104,7 @@ export async function uploadFile(
79
104
  }
80
105
  } catch (error) {
81
106
  console.warn('Failed to generate thumbnail:', error);
107
+ // Continue without thumbnail - this is not a critical failure
82
108
  }
83
109
 
84
110
  // Create file record in database
@@ -1,3 +1,5 @@
1
+ import dotenv from 'dotenv';
2
+ dotenv.config();
1
3
  import { Storage } from '@google-cloud/storage';
2
4
  import { TRPCError } from '@trpc/server';
3
5
 
@@ -9,7 +11,7 @@ const storage = new Storage({
9
11
  },
10
12
  });
11
13
 
12
- const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME || '');
14
+ export const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME!);
13
15
 
14
16
  // Short expiration time for signed URLs (5 minutes)
15
17
  const SIGNED_URL_EXPIRATION = 5 * 60 * 1000;
@@ -60,13 +62,20 @@ export async function uploadFile(
60
62
  * @param filePath The path of the file in the bucket
61
63
  * @returns The signed URL
62
64
  */
63
- export async function getSignedUrl(filePath: string): Promise<string> {
65
+ export async function getSignedUrl(filePath: string, action: 'read' | 'write' = 'read', contentType?: string): Promise<string> {
64
66
  try {
65
- const [url] = await bucket.file(filePath).getSignedUrl({
67
+ const options: any = {
66
68
  version: 'v4',
67
- action: 'read',
69
+ action: action,
68
70
  expires: Date.now() + SIGNED_URL_EXPIRATION,
69
- });
71
+ };
72
+
73
+ // For write operations, add content type if provided
74
+ if (action === 'write' && contentType) {
75
+ options.contentType = contentType;
76
+ }
77
+
78
+ const [url] = await bucket.file(filePath).getSignedUrl(options);
70
79
  return url;
71
80
  } catch (error) {
72
81
  console.error('Error getting signed URL:', error);
@@ -75,6 +75,7 @@ const updateSubmissionSchema = z.object({
75
75
  newAttachments: z.array(fileSchema).optional(),
76
76
  existingFileIds: z.array(z.string()).optional(),
77
77
  removedAttachments: z.array(z.string()).optional(),
78
+ feedback: z.string().optional(),
78
79
  rubricGrades: z.array(z.object({
79
80
  criteriaId: z.string(),
80
81
  selectedLevelId: z.string(),
@@ -1130,7 +1131,7 @@ export const assignmentRouter = createTRPCRouter({
1130
1131
  });
1131
1132
  }
1132
1133
 
1133
- const { submissionId, return: returnSubmission, gradeReceived, newAttachments, existingFileIds, removedAttachments, rubricGrades } = input;
1134
+ const { submissionId, return: returnSubmission, gradeReceived, newAttachments, existingFileIds, removedAttachments, rubricGrades, feedback } = input;
1134
1135
 
1135
1136
  const submission = await prisma.submission.findFirst({
1136
1137
  where: {
@@ -1285,6 +1286,7 @@ export const assignmentRouter = createTRPCRouter({
1285
1286
  data: {
1286
1287
  ...(gradeReceived !== undefined && { gradeReceived }),
1287
1288
  ...(rubricGrades && { rubricState: JSON.stringify(rubricGrades) }),
1289
+ ...(feedback && { teacherComments: feedback }),
1288
1290
  ...(removedAttachments && removedAttachments.length > 0 && {
1289
1291
  annotations: {
1290
1292
  deleteMany: {
@@ -1292,6 +1294,7 @@ export const assignmentRouter = createTRPCRouter({
1292
1294
  },
1293
1295
  },
1294
1296
  }),
1297
+ ...(returnSubmission as unknown as boolean && { returned: returnSubmission }),
1295
1298
  },
1296
1299
  include: {
1297
1300
  attachments: {
@@ -1746,6 +1749,45 @@ export const assignmentRouter = createTRPCRouter({
1746
1749
  },
1747
1750
  });
1748
1751
 
1752
+ return updatedAssignment;
1753
+ }),
1754
+ detachGradingBoundary: protectedTeacherProcedure
1755
+ .input(z.object({
1756
+ classId: z.string(),
1757
+ assignmentId: z.string(),
1758
+ }))
1759
+ .mutation(async ({ ctx, input }) => {
1760
+ const { assignmentId } = input;
1761
+
1762
+ const assignment = await prisma.assignment.findFirst({
1763
+ where: {
1764
+ id: assignmentId,
1765
+ },
1766
+ });
1767
+
1768
+ if (!assignment) {
1769
+ throw new TRPCError({
1770
+ code: "NOT_FOUND",
1771
+ message: "Assignment not found",
1772
+ });
1773
+ }
1774
+
1775
+ const updatedAssignment = await prisma.assignment.update({
1776
+ where: { id: assignmentId },
1777
+ data: {
1778
+ gradingBoundary: {
1779
+ disconnect: true,
1780
+ },
1781
+ },
1782
+ include: {
1783
+ attachments: true,
1784
+ section: true,
1785
+ teacher: true,
1786
+ eventAttached: true,
1787
+ gradingBoundary: true,
1788
+ },
1789
+ });
1790
+
1749
1791
  return updatedAssignment;
1750
1792
  }),
1751
1793
  });
@@ -182,6 +182,7 @@ export const folderRouter = createTRPCRouter({
182
182
  select: {
183
183
  id: true,
184
184
  name: true,
185
+ color: true,
185
186
  _count: {
186
187
  select: {
187
188
  files: true,
@@ -3,17 +3,58 @@ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
3
  import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import { uploadFiles, type UploadedFile } from "../lib/fileUpload.js";
6
+ import { getSignedUrl } from "../lib/googleCloudStorage.js";
7
+ import { logger } from "../utils/logger.js";
8
+ import { bucket } from "../lib/googleCloudStorage.js";
6
9
 
7
- const fileSchema = z.object({
8
- name: z.string(),
9
- type: z.string(),
10
- size: z.number(),
11
- data: z.string(), // base64 encoded file data
10
+ // Helper function to convert file path to backend proxy URL
11
+ function getFileUrl(filePath: string | null): string | null {
12
+ if (!filePath) return null;
13
+
14
+ // If it's already a full URL (DiceBear or external), return as is
15
+ if (filePath.startsWith('http')) {
16
+ return filePath;
17
+ }
18
+
19
+ // Convert GCS path to full backend proxy URL
20
+ const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001';
21
+ return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
22
+ }
23
+
24
+ // For direct file uploads (file already uploaded to GCS)
25
+ const fileUploadSchema = z.object({
26
+ filePath: z.string().min(1, "File path is required"),
27
+ fileName: z.string().min(1, "File name is required"),
28
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files (JPEG, PNG, GIF, WebP) are allowed"),
29
+ fileSize: z.number().max(5 * 1024 * 1024, "File size must be less than 5MB"),
30
+ });
31
+
32
+ // For DiceBear avatar URL
33
+ const dicebearSchema = z.object({
34
+ url: z.string().url("Invalid DiceBear avatar URL"),
35
+ });
36
+
37
+ const profileSchema = z.object({
38
+ displayName: z.string().nullable().optional().transform(val => val === null ? undefined : val),
39
+ bio: z.string().nullable().optional().transform(val => val === null ? undefined : val),
40
+ location: z.string().nullable().optional().transform(val => val === null ? undefined : val),
41
+ website: z.union([
42
+ z.string().url(),
43
+ z.literal(""),
44
+ z.null().transform(() => undefined)
45
+ ]).optional(),
12
46
  });
13
47
 
14
48
  const updateProfileSchema = z.object({
15
- profile: z.record(z.any()),
16
- profilePicture: fileSchema.optional(),
49
+ profile: profileSchema.optional(),
50
+ // Support both custom file upload and DiceBear avatar
51
+ profilePicture: fileUploadSchema.optional(),
52
+ dicebearAvatar: dicebearSchema.optional(),
53
+ });
54
+
55
+ const getUploadUrlSchema = z.object({
56
+ fileName: z.string().min(1, "File name is required"),
57
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files are allowed"),
17
58
  });
18
59
 
19
60
  export const userRouter = createTRPCRouter({
@@ -31,7 +72,6 @@ export const userRouter = createTRPCRouter({
31
72
  select: {
32
73
  id: true,
33
74
  username: true,
34
- profile: true,
35
75
  },
36
76
  });
37
77
 
@@ -42,7 +82,30 @@ export const userRouter = createTRPCRouter({
42
82
  });
43
83
  }
44
84
 
45
- return user;
85
+ // Get user profile separately
86
+ const userProfile = await prisma.userProfile.findUnique({
87
+ where: { userId: ctx.user.id },
88
+ });
89
+
90
+ return {
91
+ id: user.id,
92
+ username: user.username,
93
+ profile: userProfile ? {
94
+ displayName: (userProfile as any).displayName || null,
95
+ bio: (userProfile as any).bio || null,
96
+ location: (userProfile as any).location || null,
97
+ website: (userProfile as any).website || null,
98
+ profilePicture: getFileUrl((userProfile as any).profilePicture),
99
+ profilePictureThumbnail: getFileUrl((userProfile as any).profilePictureThumbnail),
100
+ } : {
101
+ displayName: null,
102
+ bio: null,
103
+ location: null,
104
+ website: null,
105
+ profilePicture: null,
106
+ profilePictureThumbnail: null,
107
+ },
108
+ };
46
109
  }),
47
110
 
48
111
  updateProfile: protectedProcedure
@@ -55,28 +118,140 @@ export const userRouter = createTRPCRouter({
55
118
  });
56
119
  }
57
120
 
58
- let uploadedFiles: UploadedFile[] = [];
121
+ // Get current profile to clean up old profile picture
122
+ const currentProfile = await prisma.userProfile.findUnique({
123
+ where: { userId: ctx.user.id },
124
+ });
125
+
126
+ let profilePictureUrl: string | null = null;
127
+ let profilePictureThumbnail: string | null = null;
128
+
129
+ // Handle custom profile picture (already uploaded to GCS)
59
130
  if (input.profilePicture) {
60
- // Store profile picture in a user-specific directory
61
- uploadedFiles = await uploadFiles([input.profilePicture], ctx.user.id, `users/${ctx.user.id}/profile`);
62
-
63
- // Add profile picture path to profile data
64
- input.profile.profilePicture = uploadedFiles[0].path;
65
- input.profile.profilePictureThumbnail = uploadedFiles[0].thumbnailId;
131
+ try {
132
+ // File is already uploaded to GCS, just use the path
133
+ profilePictureUrl = input.profilePicture.filePath;
134
+
135
+ // Generate thumbnail for the uploaded file
136
+ // TODO: Implement thumbnail generation for direct uploads
137
+ profilePictureThumbnail = null;
138
+
139
+ // Clean up old profile picture if it exists
140
+ if ((currentProfile as any)?.profilePicture) {
141
+ // TODO: Implement file deletion logic here
142
+ // await deleteFile((currentProfile as any).profilePicture);
143
+ }
144
+ } catch (error) {
145
+ logger.error('Profile picture processing failed', {
146
+ userId: ctx.user.id,
147
+ error: error instanceof Error ? error.message : 'Unknown error'
148
+ });
149
+ throw new TRPCError({
150
+ code: "INTERNAL_SERVER_ERROR",
151
+ message: "Failed to process profile picture. Please try again.",
152
+ });
153
+ }
66
154
  }
67
155
 
68
- const updatedUser = await prisma.user.update({
69
- where: { id: ctx.user.id },
70
- data: {
71
- profile: input.profile,
156
+ // Handle DiceBear avatar URL
157
+ if (input.dicebearAvatar) {
158
+ profilePictureUrl = input.dicebearAvatar.url;
159
+ // No thumbnail for DiceBear avatars since they're SVG URLs
160
+ profilePictureThumbnail = null;
161
+ }
162
+
163
+ // Prepare update data
164
+ const updateData: any = {};
165
+ if (input.profile) {
166
+ if (input.profile.displayName !== undefined && input.profile.displayName !== null) {
167
+ updateData.displayName = input.profile.displayName;
168
+ }
169
+ if (input.profile.bio !== undefined && input.profile.bio !== null) {
170
+ updateData.bio = input.profile.bio;
171
+ }
172
+ if (input.profile.location !== undefined && input.profile.location !== null) {
173
+ updateData.location = input.profile.location;
174
+ }
175
+ if (input.profile.website !== undefined && input.profile.website !== null) {
176
+ updateData.website = input.profile.website;
177
+ }
178
+ }
179
+ if (profilePictureUrl !== null) updateData.profilePicture = profilePictureUrl;
180
+ if (profilePictureThumbnail !== null) updateData.profilePictureThumbnail = profilePictureThumbnail;
181
+
182
+ // Upsert user profile with structured data
183
+ const updatedProfile = await prisma.userProfile.upsert({
184
+ where: { userId: ctx.user.id },
185
+ create: {
186
+ userId: ctx.user.id,
187
+ ...updateData,
72
188
  },
73
- select: {
74
- id: true,
75
- username: true,
76
- profile: true,
189
+ update: {
190
+ ...updateData,
191
+ updatedAt: new Date(),
77
192
  },
78
193
  });
79
194
 
80
- return updatedUser;
195
+ // Get username for response
196
+ const user = await prisma.user.findUnique({
197
+ where: { id: ctx.user.id },
198
+ select: { username: true },
199
+ });
200
+
201
+ return {
202
+ id: ctx.user.id,
203
+ username: user?.username || '',
204
+ profile: {
205
+ displayName: (updatedProfile as any).displayName || null,
206
+ bio: (updatedProfile as any).bio || null,
207
+ location: (updatedProfile as any).location || null,
208
+ website: (updatedProfile as any).website || null,
209
+ profilePicture: getFileUrl((updatedProfile as any).profilePicture),
210
+ profilePictureThumbnail: getFileUrl((updatedProfile as any).profilePictureThumbnail),
211
+ },
212
+ };
213
+ }),
214
+
215
+ getUploadUrl: protectedProcedure
216
+ .input(getUploadUrlSchema)
217
+ .mutation(async ({ ctx, input }) => {
218
+ if (!ctx.user) {
219
+ throw new TRPCError({
220
+ code: "UNAUTHORIZED",
221
+ message: "User must be authenticated",
222
+ });
223
+ }
224
+
225
+ try {
226
+ // Generate unique filename
227
+ const fileExtension = input.fileName.split('.').pop();
228
+ const uniqueFilename = `${ctx.user.id}-${Date.now()}.${fileExtension}`;
229
+ const filePath = `users/${ctx.user.id}/profile/${uniqueFilename}`;
230
+
231
+ // Generate signed URL for direct upload (write permission)
232
+ const uploadUrl = await getSignedUrl(filePath, 'write', input.fileType);
233
+
234
+ logger.info('Generated upload URL', {
235
+ userId: ctx.user.id,
236
+ filePath,
237
+ fileName: uniqueFilename,
238
+ fileType: input.fileType
239
+ });
240
+
241
+ return {
242
+ uploadUrl,
243
+ filePath,
244
+ fileName: uniqueFilename,
245
+ };
246
+ } catch (error) {
247
+ logger.error('Failed to generate upload URL', {
248
+ userId: ctx.user.id,
249
+ error: error instanceof Error ? error.message : 'Unknown error'
250
+ });
251
+ throw new TRPCError({
252
+ code: "INTERNAL_SERVER_ERROR",
253
+ message: "Failed to generate upload URL",
254
+ });
255
+ }
81
256
  }),
82
257
  });