@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
@@ -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
+ });