@studious-lms/server 1.0.1 → 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/dist/index.js +4 -4
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/logging.js +1 -1
- package/dist/routers/_app.js +1 -1
- package/dist/routers/agenda.js +1 -1
- package/dist/routers/announcement.js +1 -1
- package/dist/routers/assignment.js +3 -3
- package/dist/routers/attendance.js +1 -1
- package/dist/routers/auth.js +2 -2
- package/dist/routers/class.js +2 -2
- package/dist/routers/event.js +1 -1
- package/dist/routers/file.js +2 -2
- package/dist/routers/section.js +1 -1
- package/dist/routers/user.js +2 -2
- package/dist/trpc.js +2 -2
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { prisma } from './prisma';
|
|
3
|
+
import { uploadFile, deleteFile, getSignedUrl } from './googleCloudStorage';
|
|
4
|
+
|
|
5
|
+
// Thumbnail size configuration
|
|
6
|
+
const THUMBNAIL_WIDTH = 200;
|
|
7
|
+
const THUMBNAIL_HEIGHT = 200;
|
|
8
|
+
|
|
9
|
+
// File type configurations
|
|
10
|
+
const SUPPORTED_IMAGE_TYPES = [
|
|
11
|
+
'image/jpeg',
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/gif',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'image/tiff',
|
|
16
|
+
'image/bmp',
|
|
17
|
+
'image/avif'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const DOCUMENT_TYPES = [
|
|
21
|
+
'application/pdf',
|
|
22
|
+
'application/msword',
|
|
23
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
|
24
|
+
'application/vnd.ms-excel',
|
|
25
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
|
26
|
+
'application/vnd.ms-powerpoint',
|
|
27
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
|
|
28
|
+
'text/plain',
|
|
29
|
+
'text/csv',
|
|
30
|
+
'application/json',
|
|
31
|
+
'text/html',
|
|
32
|
+
'text/javascript',
|
|
33
|
+
'text/css'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const VIDEO_TYPES = [
|
|
37
|
+
'video/mp4',
|
|
38
|
+
'video/webm',
|
|
39
|
+
'video/ogg',
|
|
40
|
+
'video/quicktime'
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const AUDIO_TYPES = [
|
|
44
|
+
'audio/mpeg',
|
|
45
|
+
'audio/ogg',
|
|
46
|
+
'audio/wav',
|
|
47
|
+
'audio/webm'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generates a thumbnail for an image or PDF file
|
|
52
|
+
* @param fileBuffer The file buffer
|
|
53
|
+
* @param fileType The MIME type of the file
|
|
54
|
+
* @returns Thumbnail buffer
|
|
55
|
+
*/
|
|
56
|
+
export async function generateMediaThumbnail(fileBuffer: Buffer, fileType: string): Promise<Buffer> {
|
|
57
|
+
if (fileType === 'application/pdf') {
|
|
58
|
+
// For PDFs, we need to use a different approach
|
|
59
|
+
try {
|
|
60
|
+
return await sharp(fileBuffer, {
|
|
61
|
+
density: 300, // Higher density for better quality
|
|
62
|
+
page: 0 // First page only
|
|
63
|
+
})
|
|
64
|
+
.resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, {
|
|
65
|
+
fit: 'inside',
|
|
66
|
+
withoutEnlargement: true,
|
|
67
|
+
})
|
|
68
|
+
.jpeg({ quality: 80 })
|
|
69
|
+
.toBuffer();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.warn('Failed to generate PDF thumbnail:', error);
|
|
72
|
+
return generateGenericThumbnail(fileType);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// For regular images
|
|
77
|
+
return sharp(fileBuffer)
|
|
78
|
+
.resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, {
|
|
79
|
+
fit: 'inside',
|
|
80
|
+
withoutEnlargement: true,
|
|
81
|
+
})
|
|
82
|
+
.jpeg({ quality: 80 })
|
|
83
|
+
.toBuffer();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generates a generic icon-based thumbnail for a file type
|
|
88
|
+
* @param fileType The MIME type of the file
|
|
89
|
+
* @returns Thumbnail buffer
|
|
90
|
+
*/
|
|
91
|
+
async function generateGenericThumbnail(fileType: string): Promise<Buffer> {
|
|
92
|
+
// Create a blank canvas with a colored background based on file type
|
|
93
|
+
const canvas = sharp({
|
|
94
|
+
create: {
|
|
95
|
+
width: THUMBNAIL_WIDTH,
|
|
96
|
+
height: THUMBNAIL_HEIGHT,
|
|
97
|
+
channels: 4,
|
|
98
|
+
background: { r: 245, g: 245, b: 245, alpha: 1 }
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Add a colored overlay based on file type
|
|
103
|
+
let color = { r: 200, g: 200, b: 200, alpha: 0.5 }; // Default gray
|
|
104
|
+
|
|
105
|
+
if (DOCUMENT_TYPES.includes(fileType)) {
|
|
106
|
+
color = { r: 52, g: 152, b: 219, alpha: 0.5 }; // Blue for documents
|
|
107
|
+
} else if (VIDEO_TYPES.includes(fileType)) {
|
|
108
|
+
color = { r: 231, g: 76, b: 60, alpha: 0.5 }; // Red for videos
|
|
109
|
+
} else if (AUDIO_TYPES.includes(fileType)) {
|
|
110
|
+
color = { r: 46, g: 204, b: 113, alpha: 0.5 }; // Green for audio
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return canvas
|
|
114
|
+
.composite([{
|
|
115
|
+
input: Buffer.from([color.r, color.g, color.b, Math.floor(color.alpha * 255)]),
|
|
116
|
+
raw: {
|
|
117
|
+
width: 1,
|
|
118
|
+
height: 1,
|
|
119
|
+
channels: 4
|
|
120
|
+
},
|
|
121
|
+
tile: true,
|
|
122
|
+
blend: 'overlay'
|
|
123
|
+
}])
|
|
124
|
+
.jpeg({ quality: 80 })
|
|
125
|
+
.toBuffer();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generates a thumbnail for a file
|
|
130
|
+
* @param fileName The name of the file in Google Cloud Storage
|
|
131
|
+
* @param fileType The MIME type of the file
|
|
132
|
+
* @returns The thumbnail buffer or null if thumbnail generation is not supported
|
|
133
|
+
*/
|
|
134
|
+
export async function generateThumbnail(fileName: string, fileType: string): Promise<Buffer | null> {
|
|
135
|
+
try {
|
|
136
|
+
const signedUrl = await getSignedUrl(fileName);
|
|
137
|
+
const response = await fetch(signedUrl);
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`Failed to download file from storage: ${response.status} ${response.statusText}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fileBuffer = await response.arrayBuffer();
|
|
144
|
+
|
|
145
|
+
if (SUPPORTED_IMAGE_TYPES.includes(fileType) || fileType === 'application/pdf') {
|
|
146
|
+
try {
|
|
147
|
+
const thumbnail = await generateMediaThumbnail(Buffer.from(fileBuffer), fileType);
|
|
148
|
+
return thumbnail;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return generateGenericThumbnail(fileType);
|
|
151
|
+
}
|
|
152
|
+
} else if ([...DOCUMENT_TYPES, ...VIDEO_TYPES, ...AUDIO_TYPES].includes(fileType)) {
|
|
153
|
+
return generateGenericThumbnail(fileType);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null; // Unsupported file type
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Stores a thumbnail in Google Cloud Storage and creates a File entry
|
|
164
|
+
* @param thumbnailBuffer The thumbnail buffer to store
|
|
165
|
+
* @param originalFileName The original file name
|
|
166
|
+
* @param userId The user ID who owns the file
|
|
167
|
+
* @returns The ID of the created thumbnail File
|
|
168
|
+
*/
|
|
169
|
+
export async function storeThumbnail(thumbnailBuffer: Buffer, originalFileName: string, userId: string): Promise<string> {
|
|
170
|
+
// Convert buffer to base64 for uploadFile function
|
|
171
|
+
const base64Data = `data:image/jpeg;base64,${thumbnailBuffer.toString('base64')}`;
|
|
172
|
+
const thumbnailFileName = await uploadFile(base64Data, `thumbnails/${originalFileName}_thumb`, 'image/jpeg');
|
|
173
|
+
|
|
174
|
+
// Create a new File entry for the thumbnail
|
|
175
|
+
const newThumbnail = await prisma.file.create({
|
|
176
|
+
data: {
|
|
177
|
+
name: `${originalFileName}_thumb.jpg`,
|
|
178
|
+
path: thumbnailFileName,
|
|
179
|
+
type: 'image/jpeg',
|
|
180
|
+
userId: userId,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return newThumbnail.id;
|
|
185
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export enum LogLevel {
|
|
2
|
+
INFO = 'info',
|
|
3
|
+
WARN = 'warn',
|
|
4
|
+
ERROR = 'error',
|
|
5
|
+
DEBUG = 'debug'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type LogMode = 'silent' | 'minimal' | 'normal' | 'verbose';
|
|
9
|
+
|
|
10
|
+
interface LogMessage {
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
message: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
context?: Record<string, any>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Environment detection
|
|
18
|
+
const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
|
|
19
|
+
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
20
|
+
|
|
21
|
+
// ANSI color codes for Node.js
|
|
22
|
+
const ansiColors = {
|
|
23
|
+
reset: '\x1b[0m',
|
|
24
|
+
bright: '\x1b[1m',
|
|
25
|
+
dim: '\x1b[2m',
|
|
26
|
+
red: '\x1b[31m',
|
|
27
|
+
green: '\x1b[32m',
|
|
28
|
+
yellow: '\x1b[33m',
|
|
29
|
+
blue: '\x1b[34m',
|
|
30
|
+
magenta: '\x1b[35m',
|
|
31
|
+
cyan: '\x1b[36m',
|
|
32
|
+
white: '\x1b[37m',
|
|
33
|
+
gray: '\x1b[90m'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// CSS color codes for browser console
|
|
37
|
+
const cssColors = {
|
|
38
|
+
reset: '%c',
|
|
39
|
+
info: 'color: #2196F3; font-weight: bold;',
|
|
40
|
+
warn: 'color: #FFC107; font-weight: bold;',
|
|
41
|
+
error: 'color: #F44336; font-weight: bold;',
|
|
42
|
+
debug: 'color: #9C27B0; font-weight: bold;',
|
|
43
|
+
gray: 'color: #9E9E9E;',
|
|
44
|
+
context: 'color: #757575; font-style: italic;'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
class Logger {
|
|
48
|
+
private static instance: Logger;
|
|
49
|
+
private isDevelopment: boolean;
|
|
50
|
+
private mode: LogMode;
|
|
51
|
+
private levelEmojis: Record<LogLevel, string>;
|
|
52
|
+
|
|
53
|
+
private constructor() {
|
|
54
|
+
this.isDevelopment = process.env.NODE_ENV === 'development';
|
|
55
|
+
this.mode = (process.env.LOG_MODE as LogMode) || 'normal';
|
|
56
|
+
|
|
57
|
+
this.levelEmojis = {
|
|
58
|
+
[LogLevel.INFO]: 'ℹ️',
|
|
59
|
+
[LogLevel.WARN]: '⚠️',
|
|
60
|
+
[LogLevel.ERROR]: '❌',
|
|
61
|
+
[LogLevel.DEBUG]: '🔍'
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public static getInstance(): Logger {
|
|
66
|
+
if (!Logger.instance) {
|
|
67
|
+
Logger.instance = new Logger();
|
|
68
|
+
}
|
|
69
|
+
return Logger.instance;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public setMode(mode: LogMode) {
|
|
73
|
+
this.mode = mode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private shouldLog(level: LogLevel): boolean {
|
|
77
|
+
const silent = [LogLevel.ERROR];
|
|
78
|
+
const minimal = [LogLevel.ERROR, LogLevel.WARN];
|
|
79
|
+
const normal = [LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO];
|
|
80
|
+
|
|
81
|
+
if (this.mode === 'silent') return silent.includes(level);
|
|
82
|
+
if (this.mode === 'minimal') return minimal.includes(level);
|
|
83
|
+
if (this.mode === 'normal') return normal.includes(level);
|
|
84
|
+
return true; // verbose mode
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private formatMessage(logMessage: LogMessage): string | [string, ...string[]] {
|
|
88
|
+
const { level, message, timestamp, context } = logMessage;
|
|
89
|
+
const emoji = this.levelEmojis[level];
|
|
90
|
+
|
|
91
|
+
const timestampStr = `[${timestamp}]`;
|
|
92
|
+
const levelStr = `[${level.toUpperCase()}]`;
|
|
93
|
+
const messageStr = `${emoji} ${message}`;
|
|
94
|
+
|
|
95
|
+
const contextStr = context
|
|
96
|
+
? `\nContext: ${JSON.stringify(context, null, 2)}`
|
|
97
|
+
: '';
|
|
98
|
+
|
|
99
|
+
if (isNode) {
|
|
100
|
+
const color = ansiColors[level === LogLevel.INFO ? 'blue' :
|
|
101
|
+
level === LogLevel.WARN ? 'yellow' :
|
|
102
|
+
level === LogLevel.ERROR ? 'red' : 'magenta'];
|
|
103
|
+
|
|
104
|
+
return `${ansiColors.gray}${timestampStr}${ansiColors.reset} ${color}${levelStr}${ansiColors.reset} ${messageStr}${contextStr}`;
|
|
105
|
+
} else {
|
|
106
|
+
const color = cssColors[level];
|
|
107
|
+
return [
|
|
108
|
+
`${timestampStr} ${levelStr} ${messageStr}${contextStr}`,
|
|
109
|
+
cssColors.reset,
|
|
110
|
+
color,
|
|
111
|
+
cssColors.gray,
|
|
112
|
+
cssColors.context
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private log(level: LogLevel, message: string, context?: Record<string, any>) {
|
|
118
|
+
if (!this.shouldLog(level)) return;
|
|
119
|
+
|
|
120
|
+
const logMessage: LogMessage = {
|
|
121
|
+
level,
|
|
122
|
+
message,
|
|
123
|
+
timestamp: new Date().toISOString(),
|
|
124
|
+
context
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const formattedMessage = this.formatMessage(logMessage);
|
|
128
|
+
|
|
129
|
+
switch (level) {
|
|
130
|
+
case LogLevel.ERROR:
|
|
131
|
+
console.error(...(Array.isArray(formattedMessage) ? formattedMessage : [formattedMessage]));
|
|
132
|
+
break;
|
|
133
|
+
case LogLevel.WARN:
|
|
134
|
+
console.warn(...(Array.isArray(formattedMessage) ? formattedMessage : [formattedMessage]));
|
|
135
|
+
break;
|
|
136
|
+
case LogLevel.DEBUG:
|
|
137
|
+
if (this.isDevelopment) {
|
|
138
|
+
console.debug(...(Array.isArray(formattedMessage) ? formattedMessage : [formattedMessage]));
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
console.log(...(Array.isArray(formattedMessage) ? formattedMessage : [formattedMessage]));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public info(message: string, context?: Record<string, any>) {
|
|
147
|
+
this.log(LogLevel.INFO, message, context);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public warn(message: string, context?: Record<string, any>) {
|
|
151
|
+
this.log(LogLevel.WARN, message, context);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public error(message: string, context?: Record<string, any>) {
|
|
155
|
+
this.log(LogLevel.ERROR, message, context);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public debug(message: string, context?: Record<string, any>) {
|
|
159
|
+
this.log(LogLevel.DEBUG, message, context);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const logger = Logger.getInstance();
|