@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/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 +238 -22
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +79 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +39 -1
- package/dist/routers/folder.d.ts +1 -0
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +1 -0
- 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/assignment.ts +43 -1
- package/src/routers/folder.ts +1 -0
- package/src/routers/user.ts +200 -25
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: [
|
|
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:
|
|
45
|
-
|
|
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
|
});
|
package/src/lib/fileUpload.ts
CHANGED
|
@@ -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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
67
|
+
const options: any = {
|
|
66
68
|
version: 'v4',
|
|
67
|
-
action:
|
|
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
|
});
|
package/src/routers/folder.ts
CHANGED
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
|
});
|