@studious-lms/server 1.0.2 → 1.0.3
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/package.json +1 -6
- package/prisma/schema.prisma +228 -0
- package/src/exportType.ts +9 -0
- package/src/index.ts +94 -0
- package/src/lib/fileUpload.ts +163 -0
- package/src/lib/googleCloudStorage.ts +94 -0
- package/src/lib/prisma.ts +16 -0
- package/src/lib/thumbnailGenerator.ts +185 -0
- package/src/logger.ts +163 -0
- package/src/middleware/auth.ts +191 -0
- package/src/middleware/logging.ts +54 -0
- package/src/routers/_app.ts +34 -0
- package/src/routers/agenda.ts +79 -0
- package/src/routers/announcement.ts +134 -0
- package/src/routers/assignment.ts +1614 -0
- package/src/routers/attendance.ts +284 -0
- package/src/routers/auth.ts +286 -0
- package/src/routers/class.ts +753 -0
- package/src/routers/event.ts +509 -0
- package/src/routers/file.ts +96 -0
- package/src/routers/section.ts +138 -0
- package/src/routers/user.ts +82 -0
- package/src/socket/handlers.ts +143 -0
- package/src/trpc.ts +90 -0
- package/src/types/trpc.ts +15 -0
- package/src/utils/email.ts +11 -0
- package/src/utils/generateInviteCode.ts +8 -0
- package/src/utils/logger.ts +156 -0
- package/tsconfig.json +17 -0
- package/generated/prisma/client.d.ts +0 -1
- package/generated/prisma/client.js +0 -4
- package/generated/prisma/default.d.ts +0 -1
- package/generated/prisma/default.js +0 -4
- package/generated/prisma/edge.d.ts +0 -1
- package/generated/prisma/edge.js +0 -389
- package/generated/prisma/index-browser.js +0 -375
- package/generated/prisma/index.d.ts +0 -34865
- package/generated/prisma/index.js +0 -410
- package/generated/prisma/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/generated/prisma/package.json +0 -140
- package/generated/prisma/runtime/edge-esm.js +0 -34
- package/generated/prisma/runtime/edge.js +0 -34
- package/generated/prisma/runtime/index-browser.d.ts +0 -370
- package/generated/prisma/runtime/index-browser.js +0 -16
- package/generated/prisma/runtime/library.d.ts +0 -3647
- package/generated/prisma/runtime/library.js +0 -146
- package/generated/prisma/runtime/react-native.js +0 -83
- package/generated/prisma/runtime/wasm.js +0 -35
- package/generated/prisma/schema.prisma +0 -304
- package/generated/prisma/wasm.d.ts +0 -1
- package/generated/prisma/wasm.js +0 -375
package/package.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studious-lms/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Backend server for Studious application",
|
|
5
5
|
"main": "dist/exportType.js",
|
|
6
6
|
"types": "dist/exportType.d.ts",
|
|
7
|
-
"files": [
|
|
8
|
-
"dist/**/*",
|
|
9
|
-
"generated/**/*",
|
|
10
|
-
"README.md"
|
|
11
|
-
],
|
|
12
7
|
"scripts": {
|
|
13
8
|
"dev": "ts-node-dev -r tsconfig-paths/register --respawn --transpile-only src/index.ts",
|
|
14
9
|
"build": "tsc",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// This is your Prisma schema file,
|
|
2
|
+
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
3
|
+
|
|
4
|
+
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
|
5
|
+
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
|
6
|
+
|
|
7
|
+
generator client {
|
|
8
|
+
provider = "prisma-client-js"
|
|
9
|
+
// output = "../generated/prisma"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
datasource db {
|
|
13
|
+
provider = "postgresql"
|
|
14
|
+
url = env("DATABASE_URL")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
enum AssignmentType {
|
|
18
|
+
HOMEWORK
|
|
19
|
+
QUIZ
|
|
20
|
+
TEST
|
|
21
|
+
PROJECT
|
|
22
|
+
ESSAY
|
|
23
|
+
DISCUSSION
|
|
24
|
+
PRESENTATION
|
|
25
|
+
LAB
|
|
26
|
+
OTHER
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
model User {
|
|
31
|
+
id String @id @default(uuid())
|
|
32
|
+
username String
|
|
33
|
+
email String
|
|
34
|
+
password String
|
|
35
|
+
profile UserProfile?
|
|
36
|
+
verified Boolean @default(false)
|
|
37
|
+
|
|
38
|
+
profileId String? @unique
|
|
39
|
+
|
|
40
|
+
teacherIn Class[] @relation("UserTeacherToClass")
|
|
41
|
+
studentIn Class[] @relation("UserStudentToClass")
|
|
42
|
+
|
|
43
|
+
submissions Submission[]
|
|
44
|
+
sessions Session[]
|
|
45
|
+
files File[]
|
|
46
|
+
assignments Assignment[]
|
|
47
|
+
events Event[]
|
|
48
|
+
announcements Announcement[]
|
|
49
|
+
|
|
50
|
+
presentAttendance Attendance[] @relation("PresentAttendance")
|
|
51
|
+
lateAttendance Attendance[] @relation("LateAttendance")
|
|
52
|
+
absentAttendance Attendance[] @relation("AbsentAttendance")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
model UserProfile {
|
|
56
|
+
id String @id @default(uuid())
|
|
57
|
+
userId String @unique
|
|
58
|
+
user User? @relation(fields: [userId], references: [id])
|
|
59
|
+
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
model Class {
|
|
63
|
+
id String @id @default(uuid())
|
|
64
|
+
name String
|
|
65
|
+
subject String
|
|
66
|
+
color String? @default("#3B82F6")
|
|
67
|
+
section String
|
|
68
|
+
announcements Announcement[]
|
|
69
|
+
assignments Assignment[]
|
|
70
|
+
attendance Attendance[]
|
|
71
|
+
events Event[]
|
|
72
|
+
sections Section[]
|
|
73
|
+
sessions Session[]
|
|
74
|
+
students User[] @relation("UserStudentToClass")
|
|
75
|
+
teachers User[] @relation("UserTeacherToClass")
|
|
76
|
+
markSchemes MarkScheme[] @relation("ClassToMarkScheme")
|
|
77
|
+
gradingBoundaries GradingBoundary[] @relation("ClassToGradingBoundary")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
model MarkScheme {
|
|
81
|
+
id String @id @default(uuid())
|
|
82
|
+
classId String
|
|
83
|
+
class Class[] @relation("ClassToMarkScheme")
|
|
84
|
+
structured String
|
|
85
|
+
assignments Assignment[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
model GradingBoundary {
|
|
89
|
+
id String @id @default(uuid())
|
|
90
|
+
classId String
|
|
91
|
+
class Class[] @relation("ClassToGradingBoundary")
|
|
92
|
+
structured String
|
|
93
|
+
assignments Assignment[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
model File {
|
|
97
|
+
id String @id @default(uuid())
|
|
98
|
+
name String
|
|
99
|
+
path String
|
|
100
|
+
size Int?
|
|
101
|
+
type String
|
|
102
|
+
user User? @relation(fields: [userId], references: [id])
|
|
103
|
+
userId String?
|
|
104
|
+
uploadedAt DateTime? @default(now())
|
|
105
|
+
|
|
106
|
+
// Thumbnail relationship
|
|
107
|
+
thumbnail File? @relation("Thumbnail", fields: [thumbnailId], references: [id])
|
|
108
|
+
thumbnailId String? @unique
|
|
109
|
+
originalFile File? @relation("Thumbnail")
|
|
110
|
+
|
|
111
|
+
assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
112
|
+
assignmentId String?
|
|
113
|
+
|
|
114
|
+
submission Submission? @relation("SubmissionFile", fields: [submissionId], references: [id], onDelete: Cascade)
|
|
115
|
+
submissionId String?
|
|
116
|
+
|
|
117
|
+
annotations Submission? @relation("SubmissionAnnotations", fields: [annotationId], references: [id], onDelete: Cascade)
|
|
118
|
+
annotationId String?
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
model Assignment {
|
|
122
|
+
id String @id @default(uuid())
|
|
123
|
+
title String
|
|
124
|
+
instructions String
|
|
125
|
+
dueDate DateTime
|
|
126
|
+
createdAt DateTime? @default(now())
|
|
127
|
+
modifiedAt DateTime? @updatedAt
|
|
128
|
+
teacher User @relation(fields: [teacherId], references: [id], onDelete: NoAction)
|
|
129
|
+
teacherId String
|
|
130
|
+
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
131
|
+
classId String
|
|
132
|
+
attachments File[]
|
|
133
|
+
submissions Submission[]
|
|
134
|
+
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
|
135
|
+
sectionId String?
|
|
136
|
+
graded Boolean @default(false)
|
|
137
|
+
maxGrade Int? @default(0)
|
|
138
|
+
weight Float @default(1)
|
|
139
|
+
type AssignmentType @default(HOMEWORK)
|
|
140
|
+
eventAttached Event? @relation(fields: [eventId], references: [id], onDelete: NoAction)
|
|
141
|
+
eventId String?
|
|
142
|
+
markScheme MarkScheme? @relation(fields: [markSchemeId], references: [id], onDelete: Cascade)
|
|
143
|
+
markSchemeId String?
|
|
144
|
+
gradingBoundary GradingBoundary? @relation(fields: [gradingBoundaryId], references: [id], onDelete: Cascade)
|
|
145
|
+
gradingBoundaryId String?
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
model Announcement {
|
|
149
|
+
id String @id @default(uuid())
|
|
150
|
+
remarks String
|
|
151
|
+
teacher User @relation(fields: [teacherId], references: [id])
|
|
152
|
+
teacherId String
|
|
153
|
+
createdAt DateTime @default(now())
|
|
154
|
+
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
155
|
+
classId String
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
model Submission {
|
|
159
|
+
id String @id @default(uuid())
|
|
160
|
+
createdAt DateTime @default(now())
|
|
161
|
+
modifiedAt DateTime @default(now())
|
|
162
|
+
|
|
163
|
+
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
164
|
+
assignmentId String
|
|
165
|
+
|
|
166
|
+
student User @relation(fields: [studentId], references: [id])
|
|
167
|
+
studentId String
|
|
168
|
+
|
|
169
|
+
attachments File[] @relation("SubmissionFile")
|
|
170
|
+
annotations File[] @relation("SubmissionAnnotations")
|
|
171
|
+
|
|
172
|
+
gradeReceived Int?
|
|
173
|
+
|
|
174
|
+
rubricState String?
|
|
175
|
+
|
|
176
|
+
submittedAt DateTime?
|
|
177
|
+
submitted Boolean? @default(false)
|
|
178
|
+
returned Boolean? @default(false)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
model Section {
|
|
182
|
+
id String @id @default(uuid())
|
|
183
|
+
name String
|
|
184
|
+
classId String
|
|
185
|
+
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
186
|
+
assignments Assignment[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
model Session {
|
|
190
|
+
id String @id @default(uuid())
|
|
191
|
+
createdAt DateTime? @default(now())
|
|
192
|
+
expiresAt DateTime?
|
|
193
|
+
userId String?
|
|
194
|
+
user User? @relation(fields: [userId], references: [id], onDelete: NoAction)
|
|
195
|
+
classId String?
|
|
196
|
+
class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
model Event {
|
|
200
|
+
id String @id @default(uuid())
|
|
201
|
+
name String?
|
|
202
|
+
startTime DateTime
|
|
203
|
+
endTime DateTime
|
|
204
|
+
location String?
|
|
205
|
+
remarks String?
|
|
206
|
+
userId String?
|
|
207
|
+
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
208
|
+
classId String?
|
|
209
|
+
class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
210
|
+
color String? @default("#3B82F6")
|
|
211
|
+
assignmentsAttached Assignment[]
|
|
212
|
+
attendance Attendance[]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
model Attendance {
|
|
216
|
+
id String @id @default(uuid())
|
|
217
|
+
date DateTime @default(now())
|
|
218
|
+
|
|
219
|
+
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
220
|
+
classId String
|
|
221
|
+
|
|
222
|
+
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
|
223
|
+
eventId String?
|
|
224
|
+
|
|
225
|
+
present User[] @relation("PresentAttendance")
|
|
226
|
+
late User[] @relation("LateAttendance")
|
|
227
|
+
absent User[] @relation("AbsentAttendance")
|
|
228
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export types for the server
|
|
3
|
+
* This is used to export the types for the server
|
|
4
|
+
* to the client via npmjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { AppRouter } from "./routers/_app";
|
|
8
|
+
export type { RouterInputs } from "./routers/_app";
|
|
9
|
+
export type { RouterOutputs } from "./routers/_app";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import type { Request, Response } from 'express';
|
|
3
|
+
import { createServer } from 'http';
|
|
4
|
+
import { Server } from 'socket.io';
|
|
5
|
+
import cors from 'cors';
|
|
6
|
+
import dotenv from 'dotenv';
|
|
7
|
+
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
|
8
|
+
import { appRouter } from './routers/_app';
|
|
9
|
+
import { createTRPCContext, createCallerFactory } from './trpc';
|
|
10
|
+
import { logger } from './utils/logger';
|
|
11
|
+
import { setupSocketHandlers } from './socket/handlers';
|
|
12
|
+
|
|
13
|
+
dotenv.config();
|
|
14
|
+
|
|
15
|
+
const app = express();
|
|
16
|
+
|
|
17
|
+
// CORS middleware
|
|
18
|
+
app.use(cors({
|
|
19
|
+
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
|
20
|
+
credentials: true,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Response time logging middleware
|
|
24
|
+
app.use((req, res, next) => {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
res.on('finish', () => {
|
|
27
|
+
const duration = Date.now() - start;
|
|
28
|
+
logger.info('Request completed', {
|
|
29
|
+
method: req.method,
|
|
30
|
+
path: req.path,
|
|
31
|
+
statusCode: res.statusCode,
|
|
32
|
+
duration: `${duration}ms`
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
next();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Create HTTP server
|
|
39
|
+
const httpServer = createServer(app);
|
|
40
|
+
|
|
41
|
+
// Setup Socket.IO
|
|
42
|
+
const io = new Server(httpServer, {
|
|
43
|
+
cors: {
|
|
44
|
+
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
|
45
|
+
methods: ['GET', 'POST'],
|
|
46
|
+
credentials: true,
|
|
47
|
+
allowedHeaders: ['Access-Control-Allow-Origin']
|
|
48
|
+
},
|
|
49
|
+
transports: ['websocket', 'polling'],
|
|
50
|
+
pingTimeout: 60000,
|
|
51
|
+
pingInterval: 25000,
|
|
52
|
+
connectTimeout: 45000,
|
|
53
|
+
path: '/socket.io/',
|
|
54
|
+
allowEIO3: true
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Add server-level logging
|
|
58
|
+
io.engine.on('connection_error', (err) => {
|
|
59
|
+
logger.error('Socket connection error', { error: err.message });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Setup socket handlers
|
|
63
|
+
setupSocketHandlers(io);
|
|
64
|
+
|
|
65
|
+
// Create caller
|
|
66
|
+
const createCaller = createCallerFactory(appRouter);
|
|
67
|
+
|
|
68
|
+
// Setup tRPC middleware
|
|
69
|
+
app.use(
|
|
70
|
+
'/trpc',
|
|
71
|
+
createExpressMiddleware({
|
|
72
|
+
router: appRouter,
|
|
73
|
+
createContext: async ({ req, res }: { req: Request; res: Response }) => {
|
|
74
|
+
return createTRPCContext({ req, res });
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const PORT = process.env.PORT || 3001;
|
|
80
|
+
|
|
81
|
+
httpServer.listen(PORT, () => {
|
|
82
|
+
logger.info(`Server running on port ${PORT}`, {
|
|
83
|
+
port: PORT,
|
|
84
|
+
services: ['tRPC', 'Socket.IO']
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// log all env variables
|
|
89
|
+
logger.info('Configurations', {
|
|
90
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
91
|
+
PORT: process.env.PORT,
|
|
92
|
+
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
|
93
|
+
LOG_MODE: process.env.LOG_MODE,
|
|
94
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
import { uploadFile as uploadToGCS, getSignedUrl } from "./googleCloudStorage";
|
|
4
|
+
import { generateThumbnail, storeThumbnail, generateMediaThumbnail } from "./thumbnailGenerator";
|
|
5
|
+
import { prisma } from "./prisma";
|
|
6
|
+
|
|
7
|
+
export interface FileData {
|
|
8
|
+
name: string;
|
|
9
|
+
type: string;
|
|
10
|
+
size: number;
|
|
11
|
+
data: string; // base64 encoded file data
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UploadedFile {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
type: string;
|
|
18
|
+
size: number;
|
|
19
|
+
path: string;
|
|
20
|
+
thumbnailId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Uploads a single file to Google Cloud Storage and creates a file record
|
|
25
|
+
* @param file The file data to upload
|
|
26
|
+
* @param userId The ID of the user uploading the file
|
|
27
|
+
* @param directory Optional directory to store the file in
|
|
28
|
+
* @param assignmentId Optional assignment ID to associate the file with
|
|
29
|
+
* @returns The uploaded file record
|
|
30
|
+
*/
|
|
31
|
+
export async function uploadFile(
|
|
32
|
+
file: FileData,
|
|
33
|
+
userId: string,
|
|
34
|
+
directory?: string,
|
|
35
|
+
assignmentId?: string
|
|
36
|
+
): Promise<UploadedFile> {
|
|
37
|
+
try {
|
|
38
|
+
// Create a unique filename
|
|
39
|
+
const fileExtension = file.name.split('.').pop();
|
|
40
|
+
const uniqueFilename = `${uuidv4()}.${fileExtension}`;
|
|
41
|
+
|
|
42
|
+
// Construct the full path
|
|
43
|
+
const filePath = directory
|
|
44
|
+
? `${directory}/${uniqueFilename}`
|
|
45
|
+
: uniqueFilename;
|
|
46
|
+
|
|
47
|
+
// Upload to Google Cloud Storage
|
|
48
|
+
const uploadedPath = await uploadToGCS(file.data, filePath, file.type);
|
|
49
|
+
|
|
50
|
+
// Generate and store thumbnail if supported
|
|
51
|
+
let thumbnailId: string | undefined;
|
|
52
|
+
try {
|
|
53
|
+
// Convert base64 to buffer for thumbnail generation
|
|
54
|
+
const base64Data = file.data.split(',')[1];
|
|
55
|
+
const fileBuffer = Buffer.from(base64Data, 'base64');
|
|
56
|
+
|
|
57
|
+
// Generate thumbnail directly from buffer
|
|
58
|
+
const thumbnailBuffer = await generateMediaThumbnail(fileBuffer, file.type);
|
|
59
|
+
if (thumbnailBuffer) {
|
|
60
|
+
// Store thumbnail in a thumbnails directory
|
|
61
|
+
const thumbnailPath = `thumbnails/${filePath}`;
|
|
62
|
+
const thumbnailBase64 = `data:image/jpeg;base64,${thumbnailBuffer.toString('base64')}`;
|
|
63
|
+
await uploadToGCS(thumbnailBase64, thumbnailPath, 'image/jpeg');
|
|
64
|
+
|
|
65
|
+
// Create thumbnail file record
|
|
66
|
+
const thumbnailFile = await prisma.file.create({
|
|
67
|
+
data: {
|
|
68
|
+
name: `${file.name}_thumb.jpg`,
|
|
69
|
+
type: 'image/jpeg',
|
|
70
|
+
path: thumbnailPath,
|
|
71
|
+
user: {
|
|
72
|
+
connect: { id: userId }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
thumbnailId = thumbnailFile.id;
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.warn('Failed to generate thumbnail:', error);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Create file record in database
|
|
84
|
+
const fileRecord = await prisma.file.create({
|
|
85
|
+
data: {
|
|
86
|
+
name: file.name,
|
|
87
|
+
type: file.type,
|
|
88
|
+
size: file.size,
|
|
89
|
+
path: uploadedPath,
|
|
90
|
+
user: {
|
|
91
|
+
connect: { id: userId }
|
|
92
|
+
},
|
|
93
|
+
...(thumbnailId && {
|
|
94
|
+
thumbnail: {
|
|
95
|
+
connect: { id: thumbnailId }
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
...(assignmentId && {
|
|
99
|
+
assignment: {
|
|
100
|
+
connect: { id: assignmentId }
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Return file information
|
|
107
|
+
return {
|
|
108
|
+
id: fileRecord.id,
|
|
109
|
+
name: file.name,
|
|
110
|
+
type: file.type,
|
|
111
|
+
size: file.size,
|
|
112
|
+
path: uploadedPath,
|
|
113
|
+
thumbnailId: thumbnailId
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('Error uploading file:', error);
|
|
117
|
+
throw new TRPCError({
|
|
118
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
119
|
+
message: 'Failed to upload file',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Uploads multiple files
|
|
126
|
+
* @param files Array of files to upload
|
|
127
|
+
* @param userId The ID of the user uploading the files
|
|
128
|
+
* @param directory Optional subdirectory to store the files in
|
|
129
|
+
* @returns Array of uploaded file information
|
|
130
|
+
*/
|
|
131
|
+
export async function uploadFiles(
|
|
132
|
+
files: FileData[],
|
|
133
|
+
userId: string,
|
|
134
|
+
directory?: string
|
|
135
|
+
): Promise<UploadedFile[]> {
|
|
136
|
+
try {
|
|
137
|
+
const uploadPromises = files.map(file => uploadFile(file, userId, directory));
|
|
138
|
+
return await Promise.all(uploadPromises);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('Error uploading files:', error);
|
|
141
|
+
throw new TRPCError({
|
|
142
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
143
|
+
message: 'Failed to upload files',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Gets a signed URL for a file
|
|
150
|
+
* @param filePath The path of the file in Google Cloud Storage
|
|
151
|
+
* @returns The signed URL
|
|
152
|
+
*/
|
|
153
|
+
export async function getFileUrl(filePath: string): Promise<string> {
|
|
154
|
+
try {
|
|
155
|
+
return await getSignedUrl(filePath);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('Error getting signed URL:', error);
|
|
158
|
+
throw new TRPCError({
|
|
159
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
160
|
+
message: 'Failed to get file URL',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Storage } from '@google-cloud/storage';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
|
|
4
|
+
const storage = new Storage({
|
|
5
|
+
projectId: process.env.GOOGLE_CLOUD_PROJECT_ID,
|
|
6
|
+
credentials: {
|
|
7
|
+
client_email: process.env.GOOGLE_CLOUD_CLIENT_EMAIL,
|
|
8
|
+
private_key: process.env.GOOGLE_CLOUD_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME || '');
|
|
13
|
+
|
|
14
|
+
// Short expiration time for signed URLs (5 minutes)
|
|
15
|
+
const SIGNED_URL_EXPIRATION = 5 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Uploads a file to Google Cloud Storage
|
|
19
|
+
* @param base64Data Base64 encoded file data
|
|
20
|
+
* @param filePath The path where the file should be stored
|
|
21
|
+
* @param contentType The MIME type of the file
|
|
22
|
+
* @returns The path of the uploaded file
|
|
23
|
+
*/
|
|
24
|
+
export async function uploadFile(
|
|
25
|
+
base64Data: string,
|
|
26
|
+
filePath: string,
|
|
27
|
+
contentType: string
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
try {
|
|
30
|
+
// Remove the data URL prefix if present
|
|
31
|
+
const base64Content = base64Data.includes('base64,')
|
|
32
|
+
? base64Data.split('base64,')[1]
|
|
33
|
+
: base64Data;
|
|
34
|
+
|
|
35
|
+
// Convert base64 to buffer
|
|
36
|
+
const fileBuffer = Buffer.from(base64Content, 'base64');
|
|
37
|
+
|
|
38
|
+
// Create a new file in the bucket
|
|
39
|
+
const file = bucket.file(filePath);
|
|
40
|
+
|
|
41
|
+
// Upload the file
|
|
42
|
+
await file.save(fileBuffer, {
|
|
43
|
+
metadata: {
|
|
44
|
+
contentType,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return filePath;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error('Error uploading to Google Cloud Storage:', error);
|
|
51
|
+
throw new TRPCError({
|
|
52
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
53
|
+
message: 'Failed to upload file to storage',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets a signed URL for a file
|
|
60
|
+
* @param filePath The path of the file in the bucket
|
|
61
|
+
* @returns The signed URL
|
|
62
|
+
*/
|
|
63
|
+
export async function getSignedUrl(filePath: string): Promise<string> {
|
|
64
|
+
try {
|
|
65
|
+
const [url] = await bucket.file(filePath).getSignedUrl({
|
|
66
|
+
version: 'v4',
|
|
67
|
+
action: 'read',
|
|
68
|
+
expires: Date.now() + SIGNED_URL_EXPIRATION,
|
|
69
|
+
});
|
|
70
|
+
return url;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error('Error getting signed URL:', error);
|
|
73
|
+
throw new TRPCError({
|
|
74
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
75
|
+
message: 'Failed to get signed URL',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deletes a file from Google Cloud Storage
|
|
82
|
+
* @param filePath The path of the file to delete
|
|
83
|
+
*/
|
|
84
|
+
export async function deleteFile(filePath: string): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await bucket.file(filePath).delete();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Error deleting file from Google Cloud Storage:', error);
|
|
89
|
+
throw new TRPCError({
|
|
90
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
91
|
+
message: 'Failed to delete file from storage',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
|
|
3
|
+
const prismaClientSingleton = () => {
|
|
4
|
+
return new PrismaClient();
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// Prevent multiple instances of Prisma Client in development
|
|
8
|
+
declare global {
|
|
9
|
+
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const prisma = globalThis.prisma ?? prismaClientSingleton();
|
|
13
|
+
|
|
14
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
15
|
+
globalThis.prisma = prisma;
|
|
16
|
+
}
|