@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.
Files changed (51) hide show
  1. package/package.json +1 -6
  2. package/prisma/schema.prisma +228 -0
  3. package/src/exportType.ts +9 -0
  4. package/src/index.ts +94 -0
  5. package/src/lib/fileUpload.ts +163 -0
  6. package/src/lib/googleCloudStorage.ts +94 -0
  7. package/src/lib/prisma.ts +16 -0
  8. package/src/lib/thumbnailGenerator.ts +185 -0
  9. package/src/logger.ts +163 -0
  10. package/src/middleware/auth.ts +191 -0
  11. package/src/middleware/logging.ts +54 -0
  12. package/src/routers/_app.ts +34 -0
  13. package/src/routers/agenda.ts +79 -0
  14. package/src/routers/announcement.ts +134 -0
  15. package/src/routers/assignment.ts +1614 -0
  16. package/src/routers/attendance.ts +284 -0
  17. package/src/routers/auth.ts +286 -0
  18. package/src/routers/class.ts +753 -0
  19. package/src/routers/event.ts +509 -0
  20. package/src/routers/file.ts +96 -0
  21. package/src/routers/section.ts +138 -0
  22. package/src/routers/user.ts +82 -0
  23. package/src/socket/handlers.ts +143 -0
  24. package/src/trpc.ts +90 -0
  25. package/src/types/trpc.ts +15 -0
  26. package/src/utils/email.ts +11 -0
  27. package/src/utils/generateInviteCode.ts +8 -0
  28. package/src/utils/logger.ts +156 -0
  29. package/tsconfig.json +17 -0
  30. package/generated/prisma/client.d.ts +0 -1
  31. package/generated/prisma/client.js +0 -4
  32. package/generated/prisma/default.d.ts +0 -1
  33. package/generated/prisma/default.js +0 -4
  34. package/generated/prisma/edge.d.ts +0 -1
  35. package/generated/prisma/edge.js +0 -389
  36. package/generated/prisma/index-browser.js +0 -375
  37. package/generated/prisma/index.d.ts +0 -34865
  38. package/generated/prisma/index.js +0 -410
  39. package/generated/prisma/libquery_engine-darwin-arm64.dylib.node +0 -0
  40. package/generated/prisma/package.json +0 -140
  41. package/generated/prisma/runtime/edge-esm.js +0 -34
  42. package/generated/prisma/runtime/edge.js +0 -34
  43. package/generated/prisma/runtime/index-browser.d.ts +0 -370
  44. package/generated/prisma/runtime/index-browser.js +0 -16
  45. package/generated/prisma/runtime/library.d.ts +0 -3647
  46. package/generated/prisma/runtime/library.js +0 -146
  47. package/generated/prisma/runtime/react-native.js +0 -83
  48. package/generated/prisma/runtime/wasm.js +0 -35
  49. package/generated/prisma/schema.prisma +0 -304
  50. package/generated/prisma/wasm.d.ts +0 -1
  51. 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.2",
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
+ }