@studious-lms/server 1.1.3 → 1.1.5

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.
@@ -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
  });