@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.
- package/dist/index.js +99 -4
- package/dist/lib/fileUpload.d.ts +5 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +19 -3
- package/dist/lib/googleCloudStorage.d.ts +2 -1
- package/dist/lib/googleCloudStorage.d.ts.map +1 -1
- package/dist/lib/googleCloudStorage.js +12 -5
- package/dist/routers/_app.d.ts +78 -22
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/auth.js +9 -1
- package/dist/routers/user.d.ts +39 -11
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +185 -25
- package/package.json +1 -1
- package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +15 -0
- package/prisma/schema.prisma +10 -1
- package/src/index.ts +111 -4
- package/src/lib/fileUpload.ts +30 -4
- package/src/lib/googleCloudStorage.ts +14 -5
- package/src/routers/auth.ts +9 -1
- package/src/routers/user.ts +200 -25
package/src/routers/user.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
profile: true,
|
|
189
|
+
update: {
|
|
190
|
+
...updateData,
|
|
191
|
+
updatedAt: new Date(),
|
|
77
192
|
},
|
|
78
193
|
});
|
|
79
194
|
|
|
80
|
-
|
|
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
|
});
|