@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
|
@@ -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();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { prisma } from '../lib/prisma';
|
|
3
|
+
import type { MiddlewareContext } from '../types/trpc';
|
|
4
|
+
|
|
5
|
+
export const createAuthMiddleware = (t: any) => {
|
|
6
|
+
|
|
7
|
+
// Auth middleware
|
|
8
|
+
const isAuthed = t.middleware(async ({ next, ctx }: MiddlewareContext) => {
|
|
9
|
+
const startTime = Date.now();
|
|
10
|
+
// Get user from request headers
|
|
11
|
+
const userHeader = ctx.req.headers['x-user'];
|
|
12
|
+
|
|
13
|
+
if (!userHeader) {
|
|
14
|
+
throw new TRPCError({
|
|
15
|
+
code: 'UNAUTHORIZED',
|
|
16
|
+
message: 'Not authenticated - no token found',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const token = typeof userHeader === 'string' ? userHeader : userHeader[0];
|
|
22
|
+
|
|
23
|
+
// Find user by session token
|
|
24
|
+
const user = await prisma.user.findFirst({
|
|
25
|
+
where: {
|
|
26
|
+
sessions: {
|
|
27
|
+
some: {
|
|
28
|
+
id: token
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
select: {
|
|
33
|
+
id: true,
|
|
34
|
+
username: true,
|
|
35
|
+
// institutionId: true,
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!user) {
|
|
40
|
+
throw new TRPCError({
|
|
41
|
+
code: 'UNAUTHORIZED',
|
|
42
|
+
message: 'Invalid or expired session',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(user)
|
|
47
|
+
|
|
48
|
+
return next({
|
|
49
|
+
ctx: {
|
|
50
|
+
...ctx,
|
|
51
|
+
user,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.log(error)
|
|
56
|
+
throw new TRPCError({
|
|
57
|
+
code: 'UNAUTHORIZED',
|
|
58
|
+
message: 'Invalid user data',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Add computed flags middleware
|
|
64
|
+
const addComputedFlags = t.middleware(async ({ next, ctx }: MiddlewareContext) => {
|
|
65
|
+
if (!ctx.user) {
|
|
66
|
+
throw new TRPCError({
|
|
67
|
+
code: 'UNAUTHORIZED',
|
|
68
|
+
message: 'Not authenticated',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Get all classes where user is a teacher
|
|
73
|
+
const teacherClasses = await prisma.class.findMany({
|
|
74
|
+
where: {
|
|
75
|
+
teachers: {
|
|
76
|
+
some: {
|
|
77
|
+
id: ctx.user.id
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
select: {
|
|
82
|
+
id: true
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return next({
|
|
87
|
+
ctx: {
|
|
88
|
+
...ctx,
|
|
89
|
+
isTeacher: teacherClasses.length > 0,
|
|
90
|
+
teacherClassIds: teacherClasses.map((c: { id: string }) => c.id)
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Student middleware
|
|
96
|
+
const isMemberInClass = t.middleware(async ({ next, ctx, input }: MiddlewareContext) => {
|
|
97
|
+
if (!ctx.user) {
|
|
98
|
+
throw new TRPCError({
|
|
99
|
+
code: 'UNAUTHORIZED',
|
|
100
|
+
message: 'Not authenticated',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const classId = (input as { classId: string })?.classId;
|
|
105
|
+
|
|
106
|
+
if (!classId) {
|
|
107
|
+
throw new TRPCError({
|
|
108
|
+
code: 'BAD_REQUEST',
|
|
109
|
+
message: 'classId is required',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const isMember = await prisma.class.findFirst({
|
|
114
|
+
where: {
|
|
115
|
+
id: classId,
|
|
116
|
+
OR: [
|
|
117
|
+
{
|
|
118
|
+
students: {
|
|
119
|
+
some: {
|
|
120
|
+
id: ctx.user.id
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
teachers: {
|
|
126
|
+
some: {
|
|
127
|
+
id: ctx.user.id
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!isMember) {
|
|
136
|
+
throw new TRPCError({
|
|
137
|
+
code: 'FORBIDDEN',
|
|
138
|
+
message: 'Not a member in this class',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return next();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Teacher middleware
|
|
146
|
+
const isTeacherInClass = t.middleware(async ({ next, ctx, input }: MiddlewareContext) => {
|
|
147
|
+
if (!ctx.user) {
|
|
148
|
+
throw new TRPCError({
|
|
149
|
+
code: 'UNAUTHORIZED',
|
|
150
|
+
message: 'Not authenticated',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const classId = input.classId;
|
|
155
|
+
if (!classId) {
|
|
156
|
+
throw new TRPCError({
|
|
157
|
+
code: 'BAD_REQUEST',
|
|
158
|
+
message: 'classId is required',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const isTeacher = await prisma.class.findFirst({
|
|
163
|
+
where: {
|
|
164
|
+
id: classId,
|
|
165
|
+
teachers: {
|
|
166
|
+
some: {
|
|
167
|
+
id: ctx.user.id
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if (!isTeacher) {
|
|
176
|
+
throw new TRPCError({
|
|
177
|
+
code: 'FORBIDDEN',
|
|
178
|
+
message: 'Not a teacher in this class',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return next();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
isAuthed,
|
|
187
|
+
addComputedFlags,
|
|
188
|
+
isMemberInClass,
|
|
189
|
+
isTeacherInClass,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { MiddlewareContext } from '../types/trpc';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
export const createLoggingMiddleware = (t: any) => {
|
|
5
|
+
return t.middleware(async ({ path, type, next, ctx }: MiddlewareContext) => {
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
const requestId = crypto.randomUUID();
|
|
8
|
+
|
|
9
|
+
// Log request
|
|
10
|
+
logger.info('tRPC Request', {
|
|
11
|
+
requestId,
|
|
12
|
+
path,
|
|
13
|
+
type,
|
|
14
|
+
// input,
|
|
15
|
+
timestamp: new Date().toISOString(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await next();
|
|
20
|
+
const durationMs = Date.now() - start;
|
|
21
|
+
|
|
22
|
+
// Log successful response
|
|
23
|
+
logger.info('tRPC Response', {
|
|
24
|
+
requestId,
|
|
25
|
+
path,
|
|
26
|
+
type,
|
|
27
|
+
durationMs,
|
|
28
|
+
ok: result.ok,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return result;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const durationMs = Date.now() - start;
|
|
35
|
+
|
|
36
|
+
// Log error response
|
|
37
|
+
logger.error('tRPC Error' + path, {
|
|
38
|
+
requestId,
|
|
39
|
+
path,
|
|
40
|
+
type,
|
|
41
|
+
durationMs,
|
|
42
|
+
error: error instanceof Error ? {
|
|
43
|
+
path,
|
|
44
|
+
name: error.name,
|
|
45
|
+
message: error.message,
|
|
46
|
+
stack: error.stack,
|
|
47
|
+
} : error,
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createTRPCRouter } from "../trpc";
|
|
2
|
+
import { classRouter } from "./class";
|
|
3
|
+
import { announcementRouter } from "./announcement";
|
|
4
|
+
import { assignmentRouter } from "./assignment";
|
|
5
|
+
import { userRouter } from "./user";
|
|
6
|
+
import { createCallerFactory } from "../trpc";
|
|
7
|
+
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
|
8
|
+
import { sectionRouter } from "./section";
|
|
9
|
+
import { attendanceRouter } from "./attendance";
|
|
10
|
+
import { eventRouter } from "./event";
|
|
11
|
+
import { authRouter } from "./auth";
|
|
12
|
+
import { agendaRouter } from "./agenda";
|
|
13
|
+
import { fileRouter } from "./file";
|
|
14
|
+
|
|
15
|
+
export const appRouter = createTRPCRouter({
|
|
16
|
+
class: classRouter,
|
|
17
|
+
announcement: announcementRouter,
|
|
18
|
+
assignment: assignmentRouter,
|
|
19
|
+
user: userRouter,
|
|
20
|
+
section: sectionRouter,
|
|
21
|
+
attendance: attendanceRouter,
|
|
22
|
+
event: eventRouter,
|
|
23
|
+
auth: authRouter,
|
|
24
|
+
agenda: agendaRouter,
|
|
25
|
+
file: fileRouter,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Export type router type definition
|
|
29
|
+
export type AppRouter = typeof appRouter;
|
|
30
|
+
export type RouterInputs = inferRouterInputs<AppRouter>;
|
|
31
|
+
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
|
32
|
+
|
|
33
|
+
// Export caller
|
|
34
|
+
export const createCaller = createCallerFactory(appRouter);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|
3
|
+
import { prisma } from "../lib/prisma";
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import { addDays, startOfDay, endOfDay } from "date-fns";
|
|
6
|
+
|
|
7
|
+
export const agendaRouter = createTRPCRouter({
|
|
8
|
+
get: protectedProcedure
|
|
9
|
+
.input(z.object({
|
|
10
|
+
weekStart: z.string(),
|
|
11
|
+
}))
|
|
12
|
+
.query(async ({ ctx, input }) => {
|
|
13
|
+
if (!ctx.user) {
|
|
14
|
+
throw new TRPCError({
|
|
15
|
+
code: "UNAUTHORIZED",
|
|
16
|
+
message: "You must be logged in to get your agenda",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const weekStart = startOfDay(new Date(input.weekStart));
|
|
21
|
+
const weekEnd = endOfDay(addDays(weekStart, 6));
|
|
22
|
+
|
|
23
|
+
const [personalEvents, classEvents] = await Promise.all([
|
|
24
|
+
// Get personal events
|
|
25
|
+
prisma.event.findMany({
|
|
26
|
+
where: {
|
|
27
|
+
userId: ctx.user.id,
|
|
28
|
+
startTime: {
|
|
29
|
+
gte: weekStart,
|
|
30
|
+
lte: weekEnd,
|
|
31
|
+
},
|
|
32
|
+
class: {
|
|
33
|
+
is: null,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
include: {
|
|
37
|
+
class: true,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
// Get class events
|
|
41
|
+
prisma.event.findMany({
|
|
42
|
+
where: {
|
|
43
|
+
class: {
|
|
44
|
+
OR: [
|
|
45
|
+
{
|
|
46
|
+
teachers: {
|
|
47
|
+
some: {
|
|
48
|
+
id: ctx.user.id,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
students: {
|
|
54
|
+
some: {
|
|
55
|
+
id: ctx.user.id,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
startTime: {
|
|
62
|
+
gte: weekStart,
|
|
63
|
+
lte: weekEnd,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
include: {
|
|
67
|
+
class: true,
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
events: {
|
|
74
|
+
personal: personalEvents,
|
|
75
|
+
class: classEvents,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
});
|