@studious-lms/server 1.1.20 → 1.1.22

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.
@@ -1,14 +1,15 @@
1
1
  import { TRPCError } from "@trpc/server";
2
2
  import { v4 as uuidv4 } from "uuid";
3
- import { uploadFile as uploadToGCS, getSignedUrl } from "./googleCloudStorage.js";
4
- import { generateThumbnail, storeThumbnail, generateMediaThumbnail } from "./thumbnailGenerator.js";
3
+ import { getSignedUrl, objectExists } from "./googleCloudStorage.js";
4
+ import { generateMediaThumbnail } from "./thumbnailGenerator.js";
5
5
  import { prisma } from "./prisma.js";
6
+ import { logger } from "../utils/logger.js";
6
7
 
7
8
  export interface FileData {
8
9
  name: string;
9
10
  type: string;
10
11
  size: number;
11
- data: string; // base64 encoded file data
12
+ // No data field - for direct file uploads
12
13
  }
13
14
 
14
15
  export interface DirectFileData {
@@ -27,13 +28,22 @@ export interface UploadedFile {
27
28
  thumbnailId?: string;
28
29
  }
29
30
 
31
+ export interface DirectUploadFile {
32
+ id: string;
33
+ name: string;
34
+ type: string;
35
+ size: number;
36
+ path: string;
37
+ uploadUrl: string;
38
+ uploadExpiresAt: Date;
39
+ uploadSessionId: string;
40
+ }
41
+
42
+ // DEPRECATED: These functions are no longer used - files are uploaded directly to GCS
43
+ // Use createDirectUploadFile() and createDirectUploadFiles() instead
44
+
30
45
  /**
31
- * Uploads a single file to Google Cloud Storage and creates a file record
32
- * @param file The file data to upload
33
- * @param userId The ID of the user uploading the file
34
- * @param directory Optional directory to store the file in
35
- * @param assignmentId Optional assignment ID to associate the file with
36
- * @returns The uploaded file record
46
+ * @deprecated Use createDirectUploadFile instead
37
47
  */
38
48
  export async function uploadFile(
39
49
  file: FileData,
@@ -41,6 +51,59 @@ export async function uploadFile(
41
51
  directory?: string,
42
52
  assignmentId?: string
43
53
  ): Promise<UploadedFile> {
54
+ throw new TRPCError({
55
+ code: 'NOT_IMPLEMENTED',
56
+ message: 'uploadFile is deprecated. Use createDirectUploadFile instead.',
57
+ });
58
+ }
59
+
60
+ /**
61
+ * @deprecated Use createDirectUploadFiles instead
62
+ */
63
+ export async function uploadFiles(
64
+ files: FileData[],
65
+ userId: string,
66
+ directory?: string
67
+ ): Promise<UploadedFile[]> {
68
+ throw new TRPCError({
69
+ code: 'NOT_IMPLEMENTED',
70
+ message: 'uploadFiles is deprecated. Use createDirectUploadFiles instead.',
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Gets a signed URL for a file
76
+ * @param filePath The path of the file in Google Cloud Storage
77
+ * @returns The signed URL
78
+ */
79
+ export async function getFileUrl(filePath: string): Promise<string> {
80
+ try {
81
+ return await getSignedUrl(filePath);
82
+ } catch (error) {
83
+ console.error('Error getting signed URL:', error);
84
+ throw new TRPCError({
85
+ code: 'INTERNAL_SERVER_ERROR',
86
+ message: 'Failed to get file URL',
87
+ });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Creates a file record for direct upload and generates signed URL
93
+ * @param file The file metadata (no base64 data)
94
+ * @param userId The ID of the user uploading the file
95
+ * @param directory Optional directory to store the file in
96
+ * @param assignmentId Optional assignment ID to associate the file with
97
+ * @param submissionId Optional submission ID to associate the file with
98
+ * @returns The direct upload file information with signed URL
99
+ */
100
+ export async function createDirectUploadFile(
101
+ file: DirectFileData,
102
+ userId: string,
103
+ directory?: string,
104
+ assignmentId?: string,
105
+ submissionId?: string
106
+ ): Promise<DirectUploadFile> {
44
107
  try {
45
108
  // Validate file extension matches MIME type
46
109
  const fileExtension = file.name.split('.').pop()?.toLowerCase();
@@ -63,59 +126,30 @@ export async function uploadFile(
63
126
  // Create a unique filename
64
127
  const uniqueFilename = `${uuidv4()}.${fileExtension}`;
65
128
 
66
- // // Construct the full path
129
+ // Construct the full path
67
130
  const filePath = directory
68
131
  ? `${directory}/${uniqueFilename}`
69
132
  : uniqueFilename;
70
133
 
71
- // // Upload to Google Cloud Storage
72
- const uploadedPath = await uploadToGCS(file.data, filePath, file.type);
134
+ // Generate upload session ID
135
+ const uploadSessionId = uuidv4();
73
136
 
74
- // // Generate and store thumbnail if supported
75
- let thumbnailId: string | undefined;
76
- try {
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');
81
-
82
- // // Generate thumbnail directly from buffer
83
- const thumbnailBuffer = await generateMediaThumbnail(fileBuffer, file.type);
84
- if (thumbnailBuffer) {
85
- // Store thumbnail in a thumbnails directory
86
- const thumbnailPath = `thumbnails/${filePath}`;
87
- const thumbnailBase64 = `data:image/jpeg;base64,${thumbnailBuffer.toString('base64')}`;
88
- await uploadToGCS(thumbnailBase64, thumbnailPath, 'image/jpeg');
89
-
90
- // Create thumbnail file record
91
- const thumbnailFile = await prisma.file.create({
92
- data: {
93
- name: `${file.name}_thumb.jpg${Math.random()}`,
94
- type: 'image/jpeg',
95
- path: thumbnailPath,
96
- // path: '/dummyPath' + Math.random().toString(36).substring(2, 15),
97
- user: {
98
- connect: { id: userId }
99
- }
100
- }
101
- });
102
-
103
- thumbnailId = thumbnailFile.id;
104
- }
105
- } catch (error) {
106
- console.warn('Failed to generate thumbnail:', error);
107
- // Continue without thumbnail - this is not a critical failure
108
- }
137
+ // Generate backend proxy upload URL (not direct GCS)
138
+ const baseUrl = process.env.BACKEND_URL || 'http://localhost:3001';
139
+ const uploadUrl = `${baseUrl}/api/upload/${encodeURIComponent(filePath)}`;
140
+ const uploadExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
109
141
 
110
- // Create file record in database
111
-
112
- // const uploadedPath = '/dummyPath' + Math.random().toString(36).substring(2, 15);
142
+ // Create file record in database with PENDING status
113
143
  const fileRecord = await prisma.file.create({
114
144
  data: {
115
145
  name: file.name,
116
146
  type: file.type,
117
147
  size: file.size,
118
- path: uploadedPath,
148
+ path: filePath,
149
+ uploadStatus: 'PENDING',
150
+ uploadUrl,
151
+ uploadExpiresAt,
152
+ uploadSessionId,
119
153
  user: {
120
154
  connect: { id: userId }
121
155
  },
@@ -124,75 +158,187 @@ export async function uploadFile(
124
158
  connect: {id: directory},
125
159
  },
126
160
  }),
127
- ...(thumbnailId && {
128
- thumbnail: {
129
- connect: { id: thumbnailId }
130
- }
131
- }),
132
161
  ...(assignmentId && {
133
162
  assignment: {
134
163
  connect: { id: assignmentId }
135
164
  }
165
+ }),
166
+ ...(submissionId && {
167
+ submission: {
168
+ connect: { id: submissionId }
169
+ }
136
170
  })
137
171
  },
138
172
  });
139
173
 
140
- // Return file information
141
174
  return {
142
175
  id: fileRecord.id,
143
176
  name: file.name,
144
177
  type: file.type,
145
178
  size: file.size,
146
- path: uploadedPath,
147
- thumbnailId: thumbnailId
179
+ path: filePath,
180
+ uploadUrl,
181
+ uploadExpiresAt,
182
+ uploadSessionId
148
183
  };
184
+ } catch (error) {
185
+ logger.error('Error creating direct upload file:', {error: error instanceof Error ? {
186
+ name: error.name,
187
+ message: error.message,
188
+ stack: error.stack,
189
+ } : error});
190
+ throw new TRPCError({
191
+ code: 'INTERNAL_SERVER_ERROR',
192
+ message: 'Failed to create direct upload file',
193
+ });
149
194
  }
150
- catch (error) {
151
- console.error('Error uploading file:', error);
195
+ }
196
+
197
+ /**
198
+ * Confirms a direct upload was successful
199
+ * @param fileId The ID of the file record
200
+ * @param uploadSuccess Whether the upload was successful
201
+ * @param errorMessage Optional error message if upload failed
202
+ */
203
+ export async function confirmDirectUpload(
204
+ fileId: string,
205
+ uploadSuccess: boolean,
206
+ errorMessage?: string
207
+ ): Promise<void> {
208
+ try {
209
+ // First fetch the file record to get the object path
210
+ const fileRecord = await prisma.file.findUnique({
211
+ where: { id: fileId },
212
+ select: { path: true }
213
+ });
214
+
215
+ if (!fileRecord) {
216
+ throw new TRPCError({
217
+ code: 'NOT_FOUND',
218
+ message: 'File record not found',
219
+ });
220
+ }
221
+
222
+ let actualUploadSuccess = uploadSuccess;
223
+ let actualErrorMessage = errorMessage;
224
+
225
+ // If uploadSuccess is true, verify the object actually exists in GCS
226
+ if (uploadSuccess) {
227
+ try {
228
+ const exists = await objectExists(process.env.GOOGLE_CLOUD_BUCKET_NAME!, fileRecord.path);
229
+ if (!exists) {
230
+ actualUploadSuccess = false;
231
+ actualErrorMessage = 'File upload reported as successful but object not found in Google Cloud Storage';
232
+ logger.error(`File upload verification failed for ${fileId}: object ${fileRecord.path} not found in GCS`);
233
+ }
234
+ } catch (error) {
235
+ logger.error(`Error verifying file existence in GCS for ${fileId}:`, {error: error instanceof Error ? {
236
+ name: error.name,
237
+ message: error.message,
238
+ stack: error.stack,
239
+ } : error});
240
+ actualUploadSuccess = false;
241
+ actualErrorMessage = 'Failed to verify file existence in Google Cloud Storage';
242
+ }
243
+ }
244
+
245
+ const updateData: any = {
246
+ uploadStatus: actualUploadSuccess ? 'COMPLETED' : 'FAILED',
247
+ uploadProgress: actualUploadSuccess ? 100 : 0,
248
+ };
249
+
250
+ if (!actualUploadSuccess && actualErrorMessage) {
251
+ updateData.uploadError = actualErrorMessage;
252
+ updateData.uploadRetryCount = { increment: 1 };
253
+ }
254
+
255
+ if (actualUploadSuccess) {
256
+ updateData.uploadedAt = new Date();
257
+ }
258
+
259
+ await prisma.file.update({
260
+ where: { id: fileId },
261
+ data: updateData
262
+ });
263
+ } catch (error) {
264
+ logger.error('Error confirming direct upload:', {error});
152
265
  throw new TRPCError({
153
266
  code: 'INTERNAL_SERVER_ERROR',
154
- message: 'Failed to upload file',
267
+ message: 'Failed to confirm upload',
155
268
  });
156
269
  }
157
270
  }
158
271
 
159
272
  /**
160
- * Uploads multiple files
161
- * @param files Array of files to upload
162
- * @param userId The ID of the user uploading the files
163
- * @param directory Optional subdirectory to store the files in
164
- * @returns Array of uploaded file information
273
+ * Updates upload progress for a direct upload
274
+ * @param fileId The ID of the file record
275
+ * @param progress Progress percentage (0-100)
165
276
  */
166
- export async function uploadFiles(
167
- files: FileData[],
168
- userId: string,
169
- directory?: string
170
- ): Promise<UploadedFile[]> {
277
+ export async function updateUploadProgress(
278
+ fileId: string,
279
+ progress: number
280
+ ): Promise<void> {
171
281
  try {
172
- const uploadPromises = files.map(file => uploadFile(file, userId, directory));
173
- return await Promise.all(uploadPromises);
282
+ // await prisma.file.update({
283
+ // where: { id: fileId },
284
+ // data: {
285
+ // uploadStatus: 'UPLOADING',
286
+ // uploadProgress: Math.min(100, Math.max(0, progress))
287
+ // }
288
+ // });
289
+ const current = await prisma.file.findUnique({ where: { id: fileId }, select: { uploadStatus: true } });
290
+ if (!current || ['COMPLETED','FAILED','CANCELLED'].includes(current.uploadStatus as string)) return;
291
+ const clamped = Math.min(100, Math.max(0, progress));
292
+ await prisma.file.update({
293
+ where: { id: fileId },
294
+ data: {
295
+ uploadStatus: 'UPLOADING',
296
+ uploadProgress: clamped
297
+ }
298
+ });
174
299
  } catch (error) {
175
- console.error('Error uploading files:', error);
300
+ logger.error('Error updating upload progress:', {error: error instanceof Error ? {
301
+ name: error.name,
302
+ message: error.message,
303
+ stack: error.stack,
304
+ } : error});
176
305
  throw new TRPCError({
177
306
  code: 'INTERNAL_SERVER_ERROR',
178
- message: 'Failed to upload files',
307
+ message: 'Failed to update upload progress',
179
308
  });
180
309
  }
181
310
  }
182
311
 
183
312
  /**
184
- * Gets a signed URL for a file
185
- * @param filePath The path of the file in Google Cloud Storage
186
- * @returns The signed URL
313
+ * Creates multiple direct upload files
314
+ * @param files Array of file metadata
315
+ * @param userId The ID of the user uploading the files
316
+ * @param directory Optional subdirectory to store the files in
317
+ * @param assignmentId Optional assignment ID to associate files with
318
+ * @param submissionId Optional submission ID to associate files with
319
+ * @returns Array of direct upload file information
187
320
  */
188
- export async function getFileUrl(filePath: string): Promise<string> {
321
+ export async function createDirectUploadFiles(
322
+ files: DirectFileData[],
323
+ userId: string,
324
+ directory?: string,
325
+ assignmentId?: string,
326
+ submissionId?: string
327
+ ): Promise<DirectUploadFile[]> {
189
328
  try {
190
- return await getSignedUrl(filePath);
329
+ const uploadPromises = files.map(file =>
330
+ createDirectUploadFile(file, userId, directory, assignmentId, submissionId)
331
+ );
332
+ return await Promise.all(uploadPromises);
191
333
  } catch (error) {
192
- console.error('Error getting signed URL:', error);
334
+ logger.error('Error creating direct upload files:', {error: error instanceof Error ? {
335
+ name: error.name,
336
+ message: error.message,
337
+ stack: error.stack,
338
+ } : error});
193
339
  throw new TRPCError({
194
340
  code: 'INTERNAL_SERVER_ERROR',
195
- message: 'Failed to get file URL',
341
+ message: 'Failed to create direct upload files',
196
342
  });
197
343
  }
198
344
  }
@@ -16,46 +16,8 @@ export const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME!);
16
16
  // Short expiration time for signed URLs (5 minutes)
17
17
  const SIGNED_URL_EXPIRATION = 5 * 60 * 1000;
18
18
 
19
- /**
20
- * Uploads a file to Google Cloud Storage
21
- * @param base64Data Base64 encoded file data
22
- * @param filePath The path where the file should be stored
23
- * @param contentType The MIME type of the file
24
- * @returns The path of the uploaded file
25
- */
26
- export async function uploadFile(
27
- base64Data: string,
28
- filePath: string,
29
- contentType: string
30
- ): Promise<string> {
31
- try {
32
- // Remove the data URL prefix if present
33
- const base64Content = base64Data.includes('base64,')
34
- ? base64Data.split('base64,')[1]
35
- : base64Data;
36
-
37
- // Convert base64 to buffer
38
- const fileBuffer = Buffer.from(base64Content, 'base64');
39
-
40
- // Create a new file in the bucket
41
- const file = bucket.file(filePath);
42
-
43
- // Upload the file
44
- await file.save(fileBuffer, {
45
- metadata: {
46
- contentType,
47
- },
48
- });
49
-
50
- return filePath;
51
- } catch (error) {
52
- console.error('Error uploading to Google Cloud Storage:', error);
53
- throw new TRPCError({
54
- code: 'INTERNAL_SERVER_ERROR',
55
- message: 'Failed to upload file to storage',
56
- });
57
- }
58
- }
19
+ // DEPRECATED: This function is no longer used - files are uploaded directly to GCS
20
+ // The backend proxy upload endpoint in index.ts handles direct uploads
59
21
 
60
22
  /**
61
23
  * Gets a signed URL for a file
@@ -100,4 +62,23 @@ export async function deleteFile(filePath: string): Promise<void> {
100
62
  message: 'Failed to delete file from storage',
101
63
  });
102
64
  }
65
+ }
66
+
67
+ /**
68
+ * Checks if an object exists in Google Cloud Storage
69
+ * @param bucketName The name of the bucket (unused, uses default bucket)
70
+ * @param objectPath The path of the object to check
71
+ * @returns Promise<boolean> True if the object exists, false otherwise
72
+ */
73
+ export async function objectExists(bucketName: string, objectPath: string): Promise<boolean> {
74
+ try {
75
+ const [exists] = await bucket.file(objectPath).exists();
76
+ return exists;
77
+ } catch (error) {
78
+ console.error('Error checking if object exists in Google Cloud Storage:', error);
79
+ throw new TRPCError({
80
+ code: 'INTERNAL_SERVER_ERROR',
81
+ message: 'Failed to check object existence',
82
+ });
83
+ }
103
84
  }
@@ -1,6 +1,6 @@
1
1
  import sharp from 'sharp';
2
2
  import { prisma } from './prisma.js';
3
- import { uploadFile, deleteFile, getSignedUrl } from './googleCloudStorage.js';
3
+ import { deleteFile, getSignedUrl } from './googleCloudStorage.js';
4
4
 
5
5
  // Thumbnail size configuration
6
6
  const THUMBNAIL_WIDTH = 200;
@@ -166,20 +166,5 @@ export async function generateThumbnail(fileName: string, fileType: string): Pro
166
166
  * @param userId The user ID who owns the file
167
167
  * @returns The ID of the created thumbnail File
168
168
  */
169
- export async function storeThumbnail(thumbnailBuffer: Buffer, originalFileName: string, userId: string): Promise<string> {
170
- // Convert buffer to base64 for uploadFile function
171
- const base64Data = `data:image/jpeg;base64,${thumbnailBuffer.toString('base64')}`;
172
- const thumbnailFileName = await uploadFile(base64Data, `thumbnails/${originalFileName}_thumb`, 'image/jpeg');
173
-
174
- // Create a new File entry for the thumbnail
175
- const newThumbnail = await prisma.file.create({
176
- data: {
177
- name: `${originalFileName}_thumb.jpg`,
178
- path: thumbnailFileName,
179
- type: 'image/jpeg',
180
- userId: userId,
181
- },
182
- });
183
-
184
- return newThumbnail.id;
185
- }
169
+ // DEPRECATED: This function is no longer used - thumbnails are generated during direct uploads
170
+ // Thumbnail generation is now handled in the direct upload flow