@studious-lms/server 1.4.1 → 1.4.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 (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
@@ -7,10 +7,9 @@ import { prisma } from '../lib/prisma.js';
7
7
  import { chatChannel, pusher } from '../lib/pusher.js';
8
8
  import { ensureAIUserExists, getAIUserId } from './aiUser.js';
9
9
  import { env } from '../lib/config/env.js';
10
+ import { Prisma } from '@prisma/client';
10
11
  import { ZodSchema } from 'zod';
11
- import { zodTextFormat } from "openai/helpers/zod";
12
-
13
-
12
+ import { zodTextFormat } from 'openai/helpers/zod';
14
13
 
15
14
  export const inferenceClient = new OpenAI({
16
15
  apiKey: env.INFERENCE_API_KEY,
@@ -19,6 +18,38 @@ export const inferenceClient = new OpenAI({
19
18
 
20
19
  export const openAIClient = new OpenAI();
21
20
 
21
+ const DEFAULT_INFERENCE_MODEL = 'gpt-5-nano';
22
+
23
+ type InferenceMessage = {
24
+ role: 'user' | 'assistant' | 'system';
25
+ content: string;
26
+ };
27
+
28
+ export interface InferenceOptions {
29
+ reason?: string;
30
+ metadata?: Prisma.InputJsonValue;
31
+ model?: string;
32
+ temperature?: number;
33
+ maxTokens?: number;
34
+ }
35
+
36
+ interface InferenceUsage {
37
+ promptTokens?: number;
38
+ completionTokens?: number;
39
+ totalTokens?: number;
40
+ }
41
+
42
+ interface InferenceAuditLog {
43
+ model: string;
44
+ operation: 'chat.completions' | 'responses.parse';
45
+ success: boolean;
46
+ estimatedInputTokens: number;
47
+ reason: string;
48
+ metadata?: Prisma.InputJsonValue;
49
+ usage?: InferenceUsage;
50
+ error?: unknown;
51
+ }
52
+
22
53
  // Types for lab chat context
23
54
  export interface LabChatContext {
24
55
  subject: string;
@@ -39,6 +70,116 @@ export interface InferenceResponse {
39
70
  finishReason: string;
40
71
  }
41
72
 
73
+ function normalizeInferenceInput(
74
+ content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
75
+ ): InferenceMessage[] {
76
+ if (typeof content === 'string') {
77
+ return [{ role: 'user', content }];
78
+ }
79
+
80
+ return content.map((message) => ({
81
+ role: message.role as InferenceMessage['role'],
82
+ content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
83
+ }));
84
+ }
85
+
86
+ function getInferenceReason(options?: InferenceOptions) {
87
+ return options?.reason?.trim() || 'unspecified';
88
+ }
89
+
90
+ function serializeInferenceError(error: unknown) {
91
+ if (error instanceof Error) {
92
+ return error.message;
93
+ }
94
+
95
+ if (typeof error === 'string') {
96
+ return error;
97
+ }
98
+
99
+ try {
100
+ return JSON.stringify(error);
101
+ } catch {
102
+ return 'Unknown inference error';
103
+ }
104
+ }
105
+
106
+ async function logInferenceUsage({
107
+ model,
108
+ operation,
109
+ success,
110
+ estimatedInputTokens,
111
+ reason,
112
+ metadata,
113
+ usage,
114
+ error,
115
+ }: InferenceAuditLog) {
116
+ const context = {
117
+ reason,
118
+ model,
119
+ operation,
120
+ success,
121
+ estimatedInputTokens,
122
+ promptTokens: usage?.promptTokens,
123
+ completionTokens: usage?.completionTokens,
124
+ totalTokens: usage?.totalTokens,
125
+ metadata,
126
+ ...(error ? { error: serializeInferenceError(error) } : {}),
127
+ };
128
+
129
+ try {
130
+ await prisma.aiUsageLog.create({
131
+ data: {
132
+ reason,
133
+ model,
134
+ operation,
135
+ success,
136
+ estimatedInputTokens,
137
+ promptTokens: usage?.promptTokens,
138
+ completionTokens: usage?.completionTokens,
139
+ totalTokens: usage?.totalTokens,
140
+ ...(metadata !== undefined ? { metadata } : {}),
141
+ ...(error ? { error: serializeInferenceError(error) } : {}),
142
+ },
143
+ });
144
+ } catch (loggingError) {
145
+ logger.error('Failed to persist AI usage log', {
146
+ ...context,
147
+ loggingError: serializeInferenceError(loggingError),
148
+ });
149
+ return;
150
+ }
151
+
152
+ logger.info('AI usage logged', context);
153
+ }
154
+
155
+ function getChatCompletionUsage(completion: {
156
+ usage?: {
157
+ prompt_tokens?: number;
158
+ completion_tokens?: number;
159
+ total_tokens?: number;
160
+ };
161
+ }): InferenceUsage {
162
+ return {
163
+ promptTokens: completion.usage?.prompt_tokens,
164
+ completionTokens: completion.usage?.completion_tokens,
165
+ totalTokens: completion.usage?.total_tokens,
166
+ };
167
+ }
168
+
169
+ function getResponseUsage(completion: {
170
+ usage?: {
171
+ input_tokens?: number;
172
+ output_tokens?: number;
173
+ total_tokens?: number;
174
+ };
175
+ }): InferenceUsage {
176
+ return {
177
+ promptTokens: completion.usage?.input_tokens,
178
+ completionTokens: completion.usage?.output_tokens,
179
+ totalTokens: completion.usage?.total_tokens,
180
+ };
181
+ }
182
+
42
183
  /**
43
184
  * Centralized function to send AI messages to conversations
44
185
  * Handles database storage and Pusher broadcasting
@@ -65,10 +206,8 @@ export async function sendAIMessage(
65
206
  createdAt: Date;
66
207
  meta?: Record<string, any>;
67
208
  }> {
68
- // Ensure AI user exists
69
209
  await ensureAIUserExists();
70
210
 
71
- // Create message in database
72
211
  const aiMessage = await prisma.message.create({
73
212
  data: {
74
213
  content,
@@ -94,17 +233,15 @@ export async function sendAIMessage(
94
233
  contentLength: content.length,
95
234
  });
96
235
 
97
- // Prepare sender info
98
236
  const senderInfo = {
99
237
  id: getAIUserId(),
100
238
  username: 'Newton_AI',
101
239
  profile: {
102
- displayName: "Newton AI",
240
+ displayName: 'Newton AI',
103
241
  profilePicture: options.customSender?.profilePicture || null,
104
242
  },
105
243
  };
106
244
 
107
- // Broadcast via Pusher
108
245
  try {
109
246
  await pusher.trigger(chatChannel(conversationId), 'new-message', {
110
247
  id: aiMessage.id,
@@ -115,7 +252,7 @@ export async function sendAIMessage(
115
252
  sender: senderInfo,
116
253
  mentionedUserIds: [],
117
254
  meta: aiMessage.meta,
118
- attachments: aiMessage.attachments.map(attachment => ({
255
+ attachments: aiMessage.attachments.map((attachment) => ({
119
256
  id: attachment.id,
120
257
  attachmentId: attachment.id,
121
258
  name: attachment.name,
@@ -140,132 +277,89 @@ export async function sendAIMessage(
140
277
 
141
278
  export async function inference<T>(
142
279
  content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
143
- format?: ZodSchema
280
+ format?: ZodSchema,
281
+ options?: InferenceOptions,
144
282
  ): Promise<T> {
145
- try {
283
+ const input = normalizeInferenceInput(content);
284
+ const reason = getInferenceReason(options);
285
+ const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
286
+ const estimatedInputTokens = input.reduce(
287
+ (total, message) => total + estimateTokenCount(message.content),
288
+ 0,
289
+ );
290
+ const operation = format ? 'responses.parse' : 'chat.completions';
146
291
 
292
+ try {
147
293
  if (!format) {
148
294
  const completion = await openAIClient.chat.completions.create({
149
- model: 'gpt-5-nano',
150
- messages: typeof content === 'string' ? [
151
- {
152
- role: 'user',
153
- content: content,
154
- },
155
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
295
+ model,
296
+ messages: input,
297
+ ...(options?.temperature !== undefined
298
+ ? { temperature: options.temperature }
299
+ : {}),
300
+ ...(options?.maxTokens !== undefined
301
+ ? { max_tokens: options.maxTokens }
302
+ : {}),
303
+ });
304
+
305
+ await logInferenceUsage({
306
+ model,
307
+ operation,
308
+ success: true,
309
+ estimatedInputTokens,
310
+ reason,
311
+ metadata: options?.metadata,
312
+ usage: getChatCompletionUsage(completion),
156
313
  });
157
314
 
158
315
  return completion.choices[0]?.message?.content as T;
159
316
  }
160
317
 
161
318
  const completion = await openAIClient.responses.parse({
162
- model: 'gpt-5-nano',
163
- input: typeof content === 'string' ? [
164
- {
165
- role: 'user',
166
- content: content,
167
- },
168
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
169
- ...(format ? { text: {
170
- format: zodTextFormat(format, "newton_response_format"),
171
- },
172
- } : {}),
173
- });
174
-
175
- console.log({
176
- model: 'gpt-5-nano',
177
- input: typeof content === 'string' ? [
178
- {
179
- role: 'user',
180
- content: content,
181
- },
182
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
183
- ...(format ? { text: {
184
- format: zodTextFormat(format, "newton_response_format"),
185
- },
186
- } : {}),
319
+ model,
320
+ input,
321
+ text: {
322
+ format: zodTextFormat(format, 'newton_response_format'),
323
+ },
187
324
  });
188
325
 
189
-
190
326
  if (!completion) {
191
327
  throw new Error('No response generated from inference API');
192
328
  }
193
329
 
194
- // if (format) {
195
- // if (typeof completion.output === 'string') {
196
- // return JSON.parse(completion.output);
197
- // }
198
- // return JSON.parse(completion.output);
199
- // }
330
+ await logInferenceUsage({
331
+ model,
332
+ operation,
333
+ success: true,
334
+ estimatedInputTokens,
335
+ reason,
336
+ metadata: options?.metadata,
337
+ usage: getResponseUsage(completion),
338
+ });
200
339
 
201
- return completion.output_parsed;
340
+ return completion.output_parsed as T;
202
341
  } catch (error) {
203
- logger.error('Failed to generate inference response', { error });
204
- throw error;
205
- }
206
- }
207
-
208
- /**
209
- * Simple inference function for general use
210
- */
211
- export async function generateInferenceResponse(
212
- subject: string,
213
- question: string,
214
- options: {
215
- model?: string;
216
- maxTokens?: number;
217
- } = {}
218
- ): Promise<InferenceResponse> {
219
- const { model = 'gpt-5-nano', maxTokens = 500 } = options;
220
-
221
- try {
222
- const completion = await openAIClient.chat.completions.create({
342
+ await logInferenceUsage({
223
343
  model,
224
- messages: [
225
- {
226
- role: 'system',
227
- content: `You are a helpful educational assistant for ${subject}. Provide clear, concise, and accurate answers. Keep responses educational and appropriate for students.`,
228
- },
229
- {
230
- role: 'user',
231
- content: question,
232
- },
233
- ],
234
- max_tokens: maxTokens,
235
- temperature: 0.5,
236
- // Remove OpenAI-specific parameters for Cohere compatibility
344
+ operation,
345
+ success: false,
346
+ estimatedInputTokens,
347
+ reason,
348
+ metadata: options?.metadata,
349
+ error,
237
350
  });
238
351
 
239
- const response = completion.choices[0]?.message?.content;
240
-
241
- if (!response) {
242
- throw new Error('No response generated from inference API');
243
- }
244
-
245
- return {
246
- content: response,
352
+ logger.error('Failed to generate inference response', {
353
+ error,
354
+ reason,
247
355
  model,
248
- tokensUsed: completion.usage?.total_tokens || 0,
249
- finishReason: completion.choices[0]?.finish_reason || 'unknown',
250
- };
251
-
252
- } catch (error) {
253
- logger.error('Failed to generate inference response', { error, subject, question: question.substring(0, 50) + '...' });
356
+ operation,
357
+ metadata: options?.metadata,
358
+ });
254
359
  throw error;
255
360
  }
256
361
  }
257
362
 
258
- /**
259
- * Validate inference configuration
260
- */
261
- export function validateInferenceConfig(): boolean {
262
- if (!env.INFERENCE_API_KEY) {
263
- logger.error('Inference API key not configured for Cohere');
264
- return false;
265
- }
266
- return true;
267
- }
268
-
269
363
  /**
270
364
  * Get available inference models (for admin/config purposes)
271
365
  */
@@ -278,7 +372,7 @@ export async function getAvailableModels(): Promise<string[]> {
278
372
  .sort();
279
373
  } catch (error) {
280
374
  logger.error('Failed to fetch inference models', { error });
281
- return ['command-r-plus', 'command-r', 'command-light']; // Fallback Cohere models
375
+ return ['command-r-plus', 'command-r', 'command-light'];
282
376
  }
283
377
  }
284
378
 
@@ -286,6 +380,5 @@ export async function getAvailableModels(): Promise<string[]> {
286
380
  * Estimate token count for a message (rough approximation)
287
381
  */
288
382
  export function estimateTokenCount(text: string): number {
289
- // Rough approximation: 1 token ≈ 4 characters for English text
290
383
  return Math.ceil(text.length / 4);
291
- }
384
+ }
@@ -9,6 +9,7 @@ import { appRouter } from '../src/routers/_app';
9
9
  import { createTRPCContext } from '../src/trpc';
10
10
  import { clearDatabase } from '../src/seedDatabase';
11
11
  import { execSync } from 'child_process';
12
+ import { SessionType } from '@prisma/client';
12
13
 
13
14
  async function runSetup() {
14
15
  const ctx = await createTRPCContext({
@@ -40,9 +41,18 @@ async function runSetup() {
40
41
  confirmPassword: 'password_is_1234',
41
42
  });
42
43
 
43
- const session1 = await prisma.session.findFirst({ where: { userId: user1.user.id } });
44
- const session2 = await prisma.session.findFirst({ where: { userId: user2.user.id } });
45
- const session3 = await prisma.session.findFirst({ where: { userId: user3.user.id } });
44
+ const session1 = await prisma.session.findFirst({
45
+ where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },
46
+ orderBy: { createdAt: 'desc' },
47
+ });
48
+ const session2 = await prisma.session.findFirst({
49
+ where: { userId: user2.user.id, type: SessionType.EMAIL_VERIFICATION },
50
+ orderBy: { createdAt: 'desc' },
51
+ });
52
+ const session3 = await prisma.session.findFirst({
53
+ where: { userId: user3.user.id, type: SessionType.EMAIL_VERIFICATION },
54
+ orderBy: { createdAt: 'desc' },
55
+ });
46
56
 
47
57
  if (!session1 || !session2 || !session3) {
48
58
  throw new Error('Failed to create sessions for test users');
@@ -59,4 +69,3 @@ export default async function setup(project: TestProject) {
59
69
  project.onTestsRerun(runSetup);
60
70
  await runSetup();
61
71
  }
62
-
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ createCorsOriginMatcher,
4
+ isValidCorsOriginPatternList,
5
+ parseCorsOriginPatterns,
6
+ parseCorsOrigins,
7
+ } from '../../src/lib/config/cors.js';
8
+
9
+ describe('CORS configuration', () => {
10
+ test('parses comma-separated origins while trimming empty entries', () => {
11
+ expect(parseCorsOrigins(' https://one.example, ,https://two.example ')).toEqual([
12
+ 'https://one.example',
13
+ 'https://two.example',
14
+ ]);
15
+ });
16
+
17
+ test('normalizes configured origin URLs before matching', () => {
18
+ const { allowedOrigins, isAllowedOrigin } = createCorsOriginMatcher({
19
+ NODE_ENV: 'production',
20
+ NEXT_PUBLIC_APP_URL: 'https://app.example/dashboard/',
21
+ CORS_ALLOWED_ORIGINS: 'https://preview.example/some/path/, https://branch.example/',
22
+ });
23
+
24
+ expect(allowedOrigins).toContain('https://app.example');
25
+ expect(allowedOrigins).toContain('https://preview.example');
26
+ expect(allowedOrigins).toContain('https://branch.example');
27
+ expect(isAllowedOrigin('https://preview.example/')).toBe(true);
28
+ expect(isAllowedOrigin('https://branch.example/path')).toBe(true);
29
+ });
30
+
31
+ test('allows exact configured origins', () => {
32
+ const { isAllowedOrigin } = createCorsOriginMatcher({
33
+ NODE_ENV: 'production',
34
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
35
+ CORS_ALLOWED_ORIGINS: 'https://preview.example,https://branch.example',
36
+ });
37
+
38
+ expect(isAllowedOrigin('https://www.studious.sh')).toBe(true);
39
+ expect(isAllowedOrigin('https://app.example')).toBe(true);
40
+ expect(isAllowedOrigin('https://preview.example')).toBe(true);
41
+ expect(isAllowedOrigin('https://branch.example')).toBe(true);
42
+ });
43
+
44
+ test('does not allow dev subdomains in production unless configured', () => {
45
+ const defaultProductionMatcher = createCorsOriginMatcher({
46
+ NODE_ENV: 'production',
47
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
48
+ });
49
+ const configuredProductionMatcher = createCorsOriginMatcher({
50
+ NODE_ENV: 'production',
51
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
52
+ CORS_ALLOWED_ORIGINS: 'https://dev.studious.sh',
53
+ });
54
+
55
+ expect(defaultProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(false);
56
+ expect(defaultProductionMatcher.isAllowedOrigin('https://www.dev.studious.sh')).toBe(false);
57
+ expect(configuredProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(true);
58
+ });
59
+
60
+ test('allows narrow configured origin patterns', () => {
61
+ const { isAllowedOrigin } = createCorsOriginMatcher({
62
+ NODE_ENV: 'production',
63
+ CORS_ALLOWED_ORIGIN_PATTERNS: '^https://studious-git-[a-z0-9-]+-studious-lms\\.vercel\\.app$',
64
+ });
65
+
66
+ expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
67
+ expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
68
+ });
69
+
70
+ test('rejects missing and unconfigured origins instead of using a fallback', () => {
71
+ const { isAllowedOrigin } = createCorsOriginMatcher({
72
+ NODE_ENV: 'production',
73
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
74
+ CORS_ALLOWED_ORIGINS: 'https://preview.example',
75
+ });
76
+
77
+ expect(isAllowedOrigin()).toBe(false);
78
+ expect(isAllowedOrigin('https://evil.example')).toBe(false);
79
+ expect(isAllowedOrigin('http://localhost:3000')).toBe(false);
80
+ });
81
+
82
+ test('compiles configured regex patterns', () => {
83
+ expect(parseCorsOriginPatterns('^https://one\\.example$, ^https://two\\.example$').map((pattern) => pattern.source)).toEqual([
84
+ '^https:\\/\\/one\\.example$',
85
+ '^https:\\/\\/two\\.example$',
86
+ ]);
87
+ });
88
+
89
+ test('validates origin pattern syntax for env parsing', () => {
90
+ expect(isValidCorsOriginPatternList()).toBe(true);
91
+ expect(isValidCorsOriginPatternList('')).toBe(true);
92
+ expect(isValidCorsOriginPatternList('^https://one\\.example$')).toBe(true);
93
+ expect(isValidCorsOriginPatternList('(')).toBe(false);
94
+ });
95
+
96
+ test('rejects overly long origin patterns', () => {
97
+ expect(isValidCorsOriginPatternList(`^https://${'a'.repeat(250)}\\.example$`)).toBe(false);
98
+ });
99
+
100
+ test('rejects origin patterns with nested quantifiers', () => {
101
+ expect(isValidCorsOriginPatternList('^(a+)+$')).toBe(false);
102
+ });
103
+ });