@studious-lms/server 1.19.0 → 1.21.0

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 (155) hide show
  1. package/Dockerfile +5 -2
  2. package/dist/index.js +8 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/pusher.d.ts +3 -1
  5. package/dist/lib/pusher.d.ts.map +1 -1
  6. package/dist/lib/pusher.js +5 -3
  7. package/dist/lib/pusher.js.map +1 -1
  8. package/dist/middleware/security.d.ts.map +1 -1
  9. package/dist/middleware/security.js +4 -4
  10. package/dist/middleware/security.js.map +1 -1
  11. package/dist/models/class.d.ts +1 -1
  12. package/dist/models/class.d.ts.map +1 -1
  13. package/dist/models/class.js +5 -4
  14. package/dist/models/class.js.map +1 -1
  15. package/dist/models/conversation.d.ts +17 -17
  16. package/dist/models/notification.d.ts +63 -11
  17. package/dist/models/notification.d.ts.map +1 -1
  18. package/dist/models/notification.js +24 -10
  19. package/dist/models/notification.js.map +1 -1
  20. package/dist/pipelines/aiLabChat.d.ts +1 -0
  21. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  22. package/dist/pipelines/aiLabChat.js +7 -2
  23. package/dist/pipelines/aiLabChat.js.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.d.ts +2 -2
  25. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  26. package/dist/pipelines/aiNewtonChat.js +12 -4
  27. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  28. package/dist/pipelines/gradeWorksheet.d.ts +3 -3
  29. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  30. package/dist/pipelines/gradeWorksheet.js +13 -9
  31. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  32. package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
  33. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  34. package/dist/pipelines/pdfToWorksheet.js +3 -2
  35. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  36. package/dist/routers/_app.d.ts +322 -82
  37. package/dist/routers/_app.d.ts.map +1 -1
  38. package/dist/routers/announcement.d.ts.map +1 -1
  39. package/dist/routers/announcement.js +2 -2
  40. package/dist/routers/announcement.js.map +1 -1
  41. package/dist/routers/assignment.d.ts +74 -0
  42. package/dist/routers/assignment.d.ts.map +1 -1
  43. package/dist/routers/assignment.js +190 -2
  44. package/dist/routers/assignment.js.map +1 -1
  45. package/dist/routers/notifications.d.ts +51 -41
  46. package/dist/routers/notifications.d.ts.map +1 -1
  47. package/dist/routers/notifications.js +22 -18
  48. package/dist/routers/notifications.js.map +1 -1
  49. package/dist/routers/school.d.ts +30 -0
  50. package/dist/routers/school.d.ts.map +1 -1
  51. package/dist/routers/school.js +11 -2
  52. package/dist/routers/school.js.map +1 -1
  53. package/dist/routers/user.d.ts +12 -0
  54. package/dist/routers/user.d.ts.map +1 -1
  55. package/dist/routers/user.js +11 -2
  56. package/dist/routers/user.js.map +1 -1
  57. package/dist/seedDatabase.d.ts +4 -0
  58. package/dist/seedDatabase.d.ts.map +1 -1
  59. package/dist/services/aiChatStream.d.ts +5 -0
  60. package/dist/services/aiChatStream.d.ts.map +1 -1
  61. package/dist/services/aiChatStream.js +32 -3
  62. package/dist/services/aiChatStream.js.map +1 -1
  63. package/dist/services/aiStream.d.ts +5 -0
  64. package/dist/services/aiStream.d.ts.map +1 -1
  65. package/dist/services/aiStream.js +29 -3
  66. package/dist/services/aiStream.js.map +1 -1
  67. package/dist/services/aiUsage.d.ts +72 -0
  68. package/dist/services/aiUsage.d.ts.map +1 -0
  69. package/dist/services/aiUsage.js +150 -0
  70. package/dist/services/aiUsage.js.map +1 -0
  71. package/dist/services/announcement.d.ts.map +1 -1
  72. package/dist/services/announcement.js +7 -2
  73. package/dist/services/announcement.js.map +1 -1
  74. package/dist/services/assignment.d.ts.map +1 -1
  75. package/dist/services/assignment.js +29 -4
  76. package/dist/services/assignment.js.map +1 -1
  77. package/dist/services/attendance.js +10 -2
  78. package/dist/services/attendance.js.map +1 -1
  79. package/dist/services/class.d.ts.map +1 -1
  80. package/dist/services/class.js +6 -2
  81. package/dist/services/class.js.map +1 -1
  82. package/dist/services/discussion.d.ts.map +1 -1
  83. package/dist/services/discussion.js +41 -12
  84. package/dist/services/discussion.js.map +1 -1
  85. package/dist/services/discussionSummary.d.ts.map +1 -1
  86. package/dist/services/discussionSummary.js +3 -2
  87. package/dist/services/discussionSummary.js.map +1 -1
  88. package/dist/services/notification.d.ts +79 -16
  89. package/dist/services/notification.d.ts.map +1 -1
  90. package/dist/services/notification.js +64 -16
  91. package/dist/services/notification.js.map +1 -1
  92. package/dist/services/studentProgress.d.ts.map +1 -1
  93. package/dist/services/studentProgress.js +6 -2
  94. package/dist/services/studentProgress.js.map +1 -1
  95. package/dist/services/userPrivacy.d.ts +4 -0
  96. package/dist/services/userPrivacy.d.ts.map +1 -1
  97. package/dist/services/userPrivacy.js +6 -2
  98. package/dist/services/userPrivacy.js.map +1 -1
  99. package/dist/services/worksheet.js +4 -4
  100. package/dist/services/worksheet.js.map +1 -1
  101. package/dist/utils/inference.d.ts +27 -0
  102. package/dist/utils/inference.d.ts.map +1 -1
  103. package/dist/utils/inference.js +23 -3
  104. package/dist/utils/inference.js.map +1 -1
  105. package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
  106. package/dist/workers/processors/aiLabChat.js +4 -3
  107. package/dist/workers/processors/aiLabChat.js.map +1 -1
  108. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  109. package/dist/workers/processors/aiNewtonChat.js +5 -5
  110. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  111. package/dist/workers/processors/gradeWorksheet.js +4 -4
  112. package/dist/workers/processors/gradeWorksheet.js.map +1 -1
  113. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  114. package/dist/workers/processors/pdfToWorksheet.js +7 -4
  115. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  116. package/docker-entrypoint.sh +7 -0
  117. package/package.json +1 -1
  118. package/prisma/migrations/20260708011456_add_file_annotations/migration.sql +35 -0
  119. package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
  120. package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
  121. package/prisma/schema.prisma +99 -43
  122. package/src/index.ts +7 -0
  123. package/src/lib/pusher.ts +3 -1
  124. package/src/middleware/security.ts +2 -2
  125. package/src/models/class.ts +3 -2
  126. package/src/models/notification.ts +42 -13
  127. package/src/pipelines/aiLabChat.ts +6 -1
  128. package/src/pipelines/aiNewtonChat.ts +10 -0
  129. package/src/pipelines/gradeWorksheet.ts +25 -7
  130. package/src/pipelines/pdfToWorksheet.ts +3 -0
  131. package/src/routers/announcement.ts +0 -1
  132. package/src/routers/assignment.ts +214 -0
  133. package/src/routers/notifications.ts +26 -24
  134. package/src/routers/school.ts +12 -0
  135. package/src/routers/user.ts +12 -0
  136. package/src/services/aiChatStream.ts +35 -0
  137. package/src/services/aiStream.ts +34 -0
  138. package/src/services/aiUsage.ts +202 -0
  139. package/src/services/announcement.ts +5 -0
  140. package/src/services/assignment.ts +29 -2
  141. package/src/services/attendance.ts +12 -0
  142. package/src/services/class.ts +4 -0
  143. package/src/services/discussion.ts +50 -11
  144. package/src/services/discussionSummary.ts +1 -0
  145. package/src/services/notification.ts +83 -20
  146. package/src/services/studentProgress.ts +4 -0
  147. package/src/services/userPrivacy.ts +4 -0
  148. package/src/services/worksheet.ts +2 -2
  149. package/src/utils/inference.ts +40 -2
  150. package/src/workers/processors/aiLabChat.ts +2 -1
  151. package/src/workers/processors/aiNewtonChat.ts +3 -1
  152. package/src/workers/processors/gradeWorksheet.ts +2 -2
  153. package/src/workers/processors/pdfToWorksheet.ts +5 -2
  154. package/tests/integration/routers/notification.test.ts +68 -20
  155. package/tests/unit/services/aiUsage.test.ts +105 -0
@@ -79,7 +79,11 @@ INSTRUCTIONS:
79
79
  - Mark isCorrect=true only if the student earned full or near-full marks.`;
80
80
  }
81
81
 
82
- const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
82
+ const gradeWorksheetQuestion = async (
83
+ worksheetResponseId: string,
84
+ worksheetQuestionProgressId: string,
85
+ actingUserId?: string | null,
86
+ ) => {
83
87
 
84
88
  await removeAllPreviousAIComments(worksheetQuestionProgressId);
85
89
 
@@ -155,6 +159,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
155
159
  questionId: worksheetQuestionProgressId,
156
160
  studentId: worksheetResponse.studentId,
157
161
  },
162
+ attribution: {
163
+ userId: actingUserId ?? worksheetResponse.studentId,
164
+ classId: worksheetResponse.worksheet.classId,
165
+ },
158
166
  },
159
167
  ).catch((error) => {
160
168
  logger.error('Failed to grade worksheet response', { error });
@@ -259,7 +267,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
259
267
 
260
268
  const DO_NOT_INFERENCE_STATUSES = [JobStatus.CANCELLED, JobStatus.PENDING, JobStatus.COMPLETED];
261
269
 
262
- export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
270
+ export const gradeWorksheetPipeline = async (
271
+ worksheetResponseId: string,
272
+ actingUserId?: string | null,
273
+ ) => {
263
274
  logger.info('Grading worksheet response', { worksheetResponseId });
264
275
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
265
276
  where: { id: worksheetResponseId },
@@ -349,7 +360,7 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
349
360
  }
350
361
 
351
362
  try {
352
- await gradeWorksheetQuestion(worksheetResponseId, response.id);
363
+ await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
353
364
  } catch (questionError) {
354
365
  logger.error('Failed to grade individual question, continuing with remaining questions', {
355
366
  questionId: response.questionId,
@@ -387,7 +398,10 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
387
398
  return updatedStudentQuestionProgress;
388
399
  };
389
400
 
390
- export const regradeAllPipeline = async (worksheetResponseId: string) => {
401
+ export const regradeAllPipeline = async (
402
+ worksheetResponseId: string,
403
+ actingUserId?: string | null,
404
+ ) => {
391
405
  logger.info('Regrading all questions for worksheet response', { worksheetResponseId });
392
406
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
393
407
  where: { id: worksheetResponseId },
@@ -434,7 +448,7 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
434
448
  // Re-grade each question sequentially
435
449
  for (const response of worksheetResponse.responses) {
436
450
  try {
437
- await gradeWorksheetQuestion(worksheetResponseId, response.id);
451
+ await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
438
452
  } catch (questionError) {
439
453
  logger.error('Failed to regrade question, continuing with remaining', {
440
454
  questionId: response.questionId,
@@ -444,7 +458,11 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
444
458
  }
445
459
  };
446
460
 
447
- export const regradeWorksheetPipeline = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
461
+ export const regradeWorksheetPipeline = async (
462
+ worksheetResponseId: string,
463
+ worksheetQuestionProgressId: string,
464
+ actingUserId?: string | null,
465
+ ) => {
448
466
  logger.info('Regrading worksheet response', { worksheetResponseId, worksheetQuestionProgressId });
449
467
  try {
450
468
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
@@ -479,7 +497,7 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
479
497
  where: { id: worksheetQuestionProgressId },
480
498
  });
481
499
 
482
- await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
500
+ await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId, actingUserId);
483
501
 
484
502
  return updatedStudentQuestionProgress;
485
503
  } catch (error) {
@@ -179,6 +179,8 @@ export interface PdfToWorksheetOptions {
179
179
  * channel. This is the path used by the BullMQ worker.
180
180
  */
181
181
  worksheetId?: string;
182
+ /** Acting user for AI usage attribution. */
183
+ userId?: string | null;
182
184
  }
183
185
 
184
186
  /**
@@ -265,6 +267,7 @@ export async function pdfToWorksheet(
265
267
  classId,
266
268
  filename,
267
269
  },
270
+ attribution: { userId: options.userId, classId },
268
271
  }
269
272
  );
270
273
  } catch (error) {
@@ -21,7 +21,6 @@ import { getReactions as getCommentReactions } from "../services/comment.js";
21
21
  import { logger } from "../utils/logger.js";
22
22
  import { createDirectUploadFiles, type UploadedFile, type DirectUploadFile, confirmDirectUpload } from "../lib/fileUpload.js";
23
23
  import { deleteFile } from "../lib/googleCloudStorage.js";
24
- import { sendToMultiple } from "../services/notification.js";
25
24
 
26
25
  // Schema for direct file uploads (no base64 data)
27
26
  const directFileSchema = z.object({
@@ -180,6 +180,30 @@ const confirmAnnotationUploadSchema = z.object({
180
180
  errorMessage: z.string().optional(),
181
181
  });
182
182
 
183
+ const listFileAnnotationsSchema = z.object({
184
+ classId: z.string(),
185
+ submissionId: z.string(),
186
+ fileId: z.string(),
187
+ });
188
+
189
+ const upsertFileAnnotationSchema = z.object({
190
+ classId: z.string(),
191
+ submissionId: z.string(),
192
+ fileId: z.string(),
193
+ id: z.string().optional(),
194
+ page: z.number().int().min(1),
195
+ type: z.enum(["HIGHLIGHT", "INK", "COMMENT", "TEXT"]),
196
+ geometry: z.record(z.string(), z.unknown()),
197
+ text: z.string().optional(),
198
+ color: z.string().optional(),
199
+ });
200
+
201
+ const deleteFileAnnotationSchema = z.object({
202
+ classId: z.string(),
203
+ submissionId: z.string(),
204
+ id: z.string(),
205
+ });
206
+
183
207
  const updateUploadProgressSchema = z.object({
184
208
  fileId: z.string(),
185
209
  progress: z.number().min(0).max(100),
@@ -437,6 +461,11 @@ export const assignmentRouter = createTRPCRouter({
437
461
  receiverIds,
438
462
  title: `New comment on ${assignment.title}`,
439
463
  content: `${getCommenterName(comment)} commented in ${assignment.class.name}:\n\n${input.content}\n\n[View assignment](/class/${assignment.class.id}/assignments/${assignment.id})`,
464
+ senderId: authorId,
465
+ type: "COMMENT_REPLY",
466
+ entityType: "ASSIGNMENT",
467
+ entityId: assignment.id,
468
+ classId: assignment.class.id,
440
469
  }).catch((error) => {
441
470
  logger.error("Failed to send assignment comment notifications:", error);
442
471
  });
@@ -559,6 +588,11 @@ export const assignmentRouter = createTRPCRouter({
559
588
  receiverIds,
560
589
  title: `New private comment on ${submission.assignment.title}`,
561
590
  content: `${getCommenterName(comment)} commented on a submission in ${submission.assignment.class.name}:\n\n${input.content}\n\n[View submission](/class/${submission.assignment.class.id}/assignments/${submission.assignment.id}/submissions/${submission.id})`,
591
+ senderId: authorId,
592
+ type: "COMMENT_REPLY",
593
+ entityType: "ASSIGNMENT",
594
+ entityId: submission.assignment.id,
595
+ classId: submission.assignment.class.id,
562
596
  }).catch((error) => {
563
597
  logger.error("Failed to send submission comment notifications:", error);
564
598
  });
@@ -956,6 +990,186 @@ export const assignmentRouter = createTRPCRouter({
956
990
  };
957
991
  }),
958
992
 
993
+ listFileAnnotations: protectedClassMemberProcedure
994
+ .input(listFileAnnotationsSchema)
995
+ .query(async ({ ctx, input }) => {
996
+ const { classId, submissionId, fileId } = input;
997
+
998
+ const submission = await prisma.submission.findFirst({
999
+ where: {
1000
+ id: submissionId,
1001
+ assignment: { classId },
1002
+ },
1003
+ select: {
1004
+ id: true,
1005
+ studentId: true,
1006
+ returned: true,
1007
+ assignment: {
1008
+ select: {
1009
+ class: {
1010
+ select: {
1011
+ teachers: { where: { id: ctx.user!.id }, select: { id: true } },
1012
+ },
1013
+ },
1014
+ },
1015
+ },
1016
+ },
1017
+ });
1018
+
1019
+ if (!submission) {
1020
+ throw new TRPCError({
1021
+ code: "NOT_FOUND",
1022
+ message: "Submission not found",
1023
+ });
1024
+ }
1025
+
1026
+ const isTeacher = submission.assignment.class.teachers.length > 0;
1027
+ const isOwningStudent = submission.studentId === ctx.user!.id;
1028
+
1029
+ if (!isTeacher && !(isOwningStudent && submission.returned)) {
1030
+ throw new TRPCError({
1031
+ code: "FORBIDDEN",
1032
+ message: "You don't have access to these annotations",
1033
+ });
1034
+ }
1035
+
1036
+ const annotations = await prisma.fileAnnotation.findMany({
1037
+ where: { submissionId, fileId },
1038
+ orderBy: { createdAt: "asc" },
1039
+ include: {
1040
+ author: {
1041
+ select: {
1042
+ id: true,
1043
+ username: true,
1044
+ profile: { select: { displayName: true } },
1045
+ },
1046
+ },
1047
+ },
1048
+ });
1049
+
1050
+ return { annotations };
1051
+ }),
1052
+
1053
+ upsertFileAnnotation: protectedTeacherProcedure
1054
+ .input(upsertFileAnnotationSchema)
1055
+ .mutation(async ({ ctx, input }) => {
1056
+ const { classId, submissionId, fileId, id, page, type, geometry, text, color } = input;
1057
+
1058
+ const submission = await prisma.submission.findFirst({
1059
+ where: {
1060
+ id: submissionId,
1061
+ assignment: {
1062
+ classId,
1063
+ class: {
1064
+ teachers: {
1065
+ some: { id: ctx.user!.id },
1066
+ },
1067
+ },
1068
+ },
1069
+ },
1070
+ select: { id: true },
1071
+ });
1072
+
1073
+ if (!submission) {
1074
+ throw new TRPCError({
1075
+ code: "NOT_FOUND",
1076
+ message: "Submission not found or you are not a teacher of this class",
1077
+ });
1078
+ }
1079
+
1080
+ const file = await prisma.file.findFirst({
1081
+ where: {
1082
+ id: fileId,
1083
+ OR: [{ submissionId }, { annotationId: submissionId }],
1084
+ },
1085
+ select: { id: true },
1086
+ });
1087
+
1088
+ if (!file) {
1089
+ throw new TRPCError({
1090
+ code: "NOT_FOUND",
1091
+ message: "File does not belong to this submission",
1092
+ });
1093
+ }
1094
+
1095
+ if (id) {
1096
+ const existing = await prisma.fileAnnotation.findFirst({
1097
+ where: { id, submissionId, fileId },
1098
+ select: { id: true },
1099
+ });
1100
+
1101
+ if (!existing) {
1102
+ throw new TRPCError({
1103
+ code: "NOT_FOUND",
1104
+ message: "Annotation not found",
1105
+ });
1106
+ }
1107
+
1108
+ const annotation = await prisma.fileAnnotation.update({
1109
+ where: { id },
1110
+ data: {
1111
+ page,
1112
+ type,
1113
+ geometry: geometry as object,
1114
+ text: text ?? null,
1115
+ color: color ?? null,
1116
+ },
1117
+ });
1118
+
1119
+ return { annotation };
1120
+ }
1121
+
1122
+ const annotation = await prisma.fileAnnotation.create({
1123
+ data: {
1124
+ fileId,
1125
+ submissionId,
1126
+ authorId: ctx.user!.id,
1127
+ page,
1128
+ type,
1129
+ geometry: geometry as object,
1130
+ text: text ?? null,
1131
+ color: color ?? null,
1132
+ },
1133
+ });
1134
+
1135
+ return { annotation };
1136
+ }),
1137
+
1138
+ deleteFileAnnotation: protectedTeacherProcedure
1139
+ .input(deleteFileAnnotationSchema)
1140
+ .mutation(async ({ ctx, input }) => {
1141
+ const { classId, submissionId, id } = input;
1142
+
1143
+ const annotation = await prisma.fileAnnotation.findFirst({
1144
+ where: {
1145
+ id,
1146
+ submissionId,
1147
+ submission: {
1148
+ assignment: {
1149
+ classId,
1150
+ class: {
1151
+ teachers: {
1152
+ some: { id: ctx.user!.id },
1153
+ },
1154
+ },
1155
+ },
1156
+ },
1157
+ },
1158
+ select: { id: true },
1159
+ });
1160
+
1161
+ if (!annotation) {
1162
+ throw new TRPCError({
1163
+ code: "NOT_FOUND",
1164
+ message: "Annotation not found or you are not a teacher of this class",
1165
+ });
1166
+ }
1167
+
1168
+ await prisma.fileAnnotation.delete({ where: { id } });
1169
+
1170
+ return { success: true };
1171
+ }),
1172
+
959
1173
  updateUploadProgress: protectedProcedure
960
1174
  .input(updateUploadProgressSchema)
961
1175
  .mutation(async ({ ctx, input }) => {
@@ -3,41 +3,43 @@ import { z } from "zod";
3
3
  import {
4
4
  listNotifications,
5
5
  getNotification,
6
- sendNotification,
7
- sendToMultiple,
6
+ getUnreadCount,
8
7
  markAsRead,
8
+ markAllAsRead,
9
9
  } from "../services/notification.js";
10
10
 
11
+ /**
12
+ * Read-only notification API. Notifications are emitted exclusively by
13
+ * server-side domain services — there is intentionally no public "send"
14
+ * endpoint (the previous sendTo/sendToMultiple procedures allowed any logged-in
15
+ * user to notify arbitrary users and were removed).
16
+ */
11
17
  export const notificationRouter = createTRPCRouter({
12
- list: protectedProcedure.query(({ ctx }) =>
13
- listNotifications(ctx.user!.id)
14
- ),
18
+ list: protectedProcedure
19
+ .input(
20
+ z
21
+ .object({
22
+ limit: z.number().int().min(1).max(100).default(20),
23
+ cursor: z.string().nullish(),
24
+ })
25
+ .optional()
26
+ )
27
+ .query(({ ctx, input }) =>
28
+ listNotifications(ctx.user!.id, {
29
+ limit: input?.limit,
30
+ cursor: input?.cursor,
31
+ })
32
+ ),
15
33
 
16
34
  get: protectedProcedure
17
35
  .input(z.object({ id: z.string() }))
18
36
  .query(({ ctx, input }) => getNotification(ctx.user!.id, input.id)),
19
37
 
20
- sendTo: protectedProcedure
21
- .input(
22
- z.object({
23
- receiverId: z.string(),
24
- title: z.string(),
25
- content: z.string(),
26
- })
27
- )
28
- .mutation(({ input }) => sendNotification(input)),
29
-
30
- sendToMultiple: protectedProcedure
31
- .input(
32
- z.object({
33
- receiverIds: z.array(z.string()),
34
- title: z.string(),
35
- content: z.string(),
36
- })
37
- )
38
- .mutation(({ input }) => sendToMultiple(input)),
38
+ unreadCount: protectedProcedure.query(({ ctx }) => getUnreadCount(ctx.user!.id)),
39
39
 
40
40
  markAsRead: protectedProcedure
41
41
  .input(z.object({ id: z.string() }))
42
42
  .mutation(({ ctx, input }) => markAsRead(ctx.user!.id, input.id)),
43
+
44
+ markAllAsRead: protectedProcedure.mutation(({ ctx }) => markAllAsRead(ctx.user!.id)),
43
45
  });
@@ -27,6 +27,7 @@ import {
27
27
  updateMemberRole,
28
28
  updateSchoolDetails,
29
29
  } from "../services/school.js";
30
+ import { getSchoolAiUsage } from "../services/aiUsage.js";
30
31
 
31
32
  const schoolRoleSchema = z.enum(["ADMIN", "TEACHER", "STUDENT"]);
32
33
 
@@ -142,6 +143,17 @@ export const schoolRouter = createTRPCRouter({
142
143
  .input(schoolIdSchema)
143
144
  .query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
144
145
 
146
+ getAiUsage: protectedProcedure
147
+ .input(
148
+ z.object({
149
+ schoolId: z.string(),
150
+ startDate: z.date().optional(),
151
+ endDate: z.date().optional(),
152
+ topUsersLimit: z.number().min(1).max(50).optional(),
153
+ })
154
+ )
155
+ .query(({ ctx, input }) => getSchoolAiUsage(ctx.user!.id, input)),
156
+
145
157
  listClasses: protectedProcedure
146
158
  .input(schoolIdSchema)
147
159
  .query(({ ctx, input }) => listClasses(ctx.user!.id, input.schoolId)),
@@ -10,6 +10,7 @@ import {
10
10
  deleteUserAccount,
11
11
  exportUserData,
12
12
  } from "../services/userPrivacy.js";
13
+ import { getMyAiUsage } from "../services/aiUsage.js";
13
14
 
14
15
  const fileUploadSchema = z.object({
15
16
  filePath: z.string().min(1, "File path is required"),
@@ -89,6 +90,17 @@ export const userRouter = createTRPCRouter({
89
90
  exportUserData(ctx.user!.id),
90
91
  ),
91
92
 
93
+ getMyAiUsage: protectedProcedure
94
+ .input(
95
+ z
96
+ .object({
97
+ startDate: z.date().optional(),
98
+ endDate: z.date().optional(),
99
+ })
100
+ .optional(),
101
+ )
102
+ .query(({ ctx, input }) => getMyAiUsage(ctx.user!.id, input ?? {})),
103
+
92
104
  deleteAccount: protectedProcedure
93
105
  .input(
94
106
  z.object({
@@ -16,6 +16,7 @@ import { openai } from '@ai-sdk/openai';
16
16
  import { getRedis } from '../lib/redis.js';
17
17
  import { pusher, chatChannel } from '../lib/pusher.js';
18
18
  import { logger } from '../utils/logger.js';
19
+ import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
19
20
 
20
21
  const STREAM_REDIS_TTL = 600; // 10 minutes
21
22
  const STREAM_KEY_PREFIX = 'stream:';
@@ -179,6 +180,10 @@ export interface StreamChatOptions {
179
180
  maxOutputTokens?: number;
180
181
  /** Max agent steps (tool round-trips). Defaults to 6. */
181
182
  maxSteps?: number;
183
+ /** Reason recorded on the AiUsageLog row. Defaults to 'chat_stream'. */
184
+ usageReason?: string;
185
+ /** Who/what this usage is attributed to (user / school / class). */
186
+ attribution?: AiUsageAttribution;
182
187
  }
183
188
 
184
189
  export interface StreamChatResult {
@@ -207,6 +212,8 @@ export async function streamChatResponse(
207
212
  temperature = 0.7,
208
213
  maxOutputTokens = 4096,
209
214
  maxSteps = 6,
215
+ usageReason = 'chat_stream',
216
+ attribution,
210
217
  } = options;
211
218
 
212
219
  const redis = getRedis();
@@ -332,7 +339,35 @@ export async function streamChatResponse(
332
339
  }
333
340
  }
334
341
 
342
+ const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
343
+ await logInferenceUsage({
344
+ model,
345
+ operation: 'stream.text',
346
+ success: true,
347
+ estimatedInputTokens: 0,
348
+ reason: usageReason,
349
+ usage: usage
350
+ ? {
351
+ promptTokens: usage.inputTokens,
352
+ completionTokens: usage.outputTokens,
353
+ totalTokens: usage.totalTokens,
354
+ }
355
+ : undefined,
356
+ attribution,
357
+ });
358
+
335
359
  return { text: fullText, toolCalls, parts };
360
+ } catch (error) {
361
+ await logInferenceUsage({
362
+ model,
363
+ operation: 'stream.text',
364
+ success: false,
365
+ estimatedInputTokens: 0,
366
+ reason: usageReason,
367
+ error,
368
+ attribution,
369
+ });
370
+ throw error;
336
371
  } finally {
337
372
  if (redis) {
338
373
  await redis.del(streamKey).catch(() => {});
@@ -8,6 +8,7 @@ import { getRedis } from '../lib/redis.js';
8
8
  import { prisma } from '../lib/prisma.js';
9
9
  import { pusher, chatChannel } from '../lib/pusher.js';
10
10
  import { getAIUserId, ensureAIUserExists } from '../utils/aiUser.js';
11
+ import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
11
12
  import { logger } from '../utils/logger.js';
12
13
  import { JobStatus } from '@prisma/client';
13
14
  import { aiTools, type ToolCallResult } from './aiTools.js';
@@ -25,6 +26,10 @@ export interface StreamOptions {
25
26
  model?: string;
26
27
  temperature?: number;
27
28
  maxOutputTokens?: number;
29
+ /** Reason recorded on the AiUsageLog row. Defaults to 'ai_stream'. */
30
+ usageReason?: string;
31
+ /** Who/what this usage is attributed to (user / school / class). */
32
+ attribution?: AiUsageAttribution;
28
33
  }
29
34
 
30
35
  /**
@@ -42,6 +47,8 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
42
47
  model = 'gpt-4o-mini',
43
48
  temperature = 0.7,
44
49
  maxOutputTokens = 4096,
50
+ usageReason = 'ai_stream',
51
+ attribution,
45
52
  } = options;
46
53
 
47
54
  await ensureAIUserExists();
@@ -146,6 +153,23 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
146
153
  attachments: [],
147
154
  });
148
155
 
156
+ const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
157
+ await logInferenceUsage({
158
+ model,
159
+ operation: 'stream.text',
160
+ success: true,
161
+ estimatedInputTokens: 0,
162
+ reason: usageReason,
163
+ usage: usage
164
+ ? {
165
+ promptTokens: usage.inputTokens,
166
+ completionTokens: usage.outputTokens,
167
+ totalTokens: usage.totalTokens,
168
+ }
169
+ : undefined,
170
+ attribution: attribution ?? { classId },
171
+ });
172
+
149
173
  logger.info('AI stream completed', {
150
174
  aiMessageId,
151
175
  conversationId,
@@ -155,6 +179,16 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
155
179
  } catch (error) {
156
180
  logger.error('AI stream failed', { error, aiMessageId, conversationId });
157
181
 
182
+ await logInferenceUsage({
183
+ model,
184
+ operation: 'stream.text',
185
+ success: false,
186
+ estimatedInputTokens: 0,
187
+ reason: usageReason,
188
+ error,
189
+ attribution: attribution ?? { classId },
190
+ });
191
+
158
192
  await prisma.message.update({
159
193
  where: { id: aiMessageId },
160
194
  data: { status: JobStatus.FAILED },