@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
@@ -0,0 +1,202 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { prisma } from "../lib/prisma.js";
3
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
4
+
5
+ export interface UsageLogRow {
6
+ userId: string | null;
7
+ model: string;
8
+ operation: string;
9
+ totalTokens: number | null;
10
+ promptTokens: number | null;
11
+ completionTokens: number | null;
12
+ createdAt: Date;
13
+ }
14
+
15
+ export interface UsageBucket {
16
+ requests: number;
17
+ promptTokens: number;
18
+ completionTokens: number;
19
+ totalTokens: number;
20
+ }
21
+
22
+ export interface UsageAggregation {
23
+ totals: UsageBucket;
24
+ byDay: Array<{ day: string } & UsageBucket>;
25
+ byModel: Array<{ model: string } & UsageBucket>;
26
+ byOperation: Array<{ operation: string } & UsageBucket>;
27
+ }
28
+
29
+ const emptyBucket = (): UsageBucket => ({
30
+ requests: 0,
31
+ promptTokens: 0,
32
+ completionTokens: 0,
33
+ totalTokens: 0,
34
+ });
35
+
36
+ function addToBucket(bucket: UsageBucket, row: UsageLogRow) {
37
+ bucket.requests += 1;
38
+ bucket.promptTokens += row.promptTokens ?? 0;
39
+ bucket.completionTokens += row.completionTokens ?? 0;
40
+ bucket.totalTokens += row.totalTokens ?? 0;
41
+ }
42
+
43
+ /** UTC calendar day (YYYY-MM-DD) for a timestamp. */
44
+ export function usageDayKey(date: Date): string {
45
+ return date.toISOString().slice(0, 10);
46
+ }
47
+
48
+ /** Aggregate raw usage rows into totals and by-day / by-model / by-operation buckets. */
49
+ export function aggregateUsage(rows: UsageLogRow[]): UsageAggregation {
50
+ const totals = emptyBucket();
51
+ const byDay = new Map<string, UsageBucket>();
52
+ const byModel = new Map<string, UsageBucket>();
53
+ const byOperation = new Map<string, UsageBucket>();
54
+
55
+ for (const row of rows) {
56
+ addToBucket(totals, row);
57
+
58
+ const dayKey = usageDayKey(row.createdAt);
59
+ for (const [map, key] of [
60
+ [byDay, dayKey],
61
+ [byModel, row.model],
62
+ [byOperation, row.operation],
63
+ ] as const) {
64
+ let bucket = map.get(key);
65
+ if (!bucket) {
66
+ bucket = emptyBucket();
67
+ map.set(key, bucket);
68
+ }
69
+ addToBucket(bucket, row);
70
+ }
71
+ }
72
+
73
+ return {
74
+ totals,
75
+ byDay: [...byDay.entries()]
76
+ .sort(([a], [b]) => a.localeCompare(b))
77
+ .map(([day, bucket]) => ({ day, ...bucket })),
78
+ byModel: [...byModel.entries()]
79
+ .sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
80
+ .map(([model, bucket]) => ({ model, ...bucket })),
81
+ byOperation: [...byOperation.entries()]
82
+ .sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
83
+ .map(([operation, bucket]) => ({ operation, ...bucket })),
84
+ };
85
+ }
86
+
87
+ /** Rank users by total token usage, descending. Rows without a userId are skipped. */
88
+ export function aggregateTopUsers(
89
+ rows: UsageLogRow[],
90
+ limit: number,
91
+ ): Array<{ userId: string } & UsageBucket> {
92
+ const byUser = new Map<string, UsageBucket>();
93
+ for (const row of rows) {
94
+ if (!row.userId) continue;
95
+ let bucket = byUser.get(row.userId);
96
+ if (!bucket) {
97
+ bucket = emptyBucket();
98
+ byUser.set(row.userId, bucket);
99
+ }
100
+ addToBucket(bucket, row);
101
+ }
102
+
103
+ return [...byUser.entries()]
104
+ .sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
105
+ .slice(0, limit)
106
+ .map(([userId, bucket]) => ({ userId, ...bucket }));
107
+ }
108
+
109
+ const USAGE_ROW_SELECT = {
110
+ userId: true,
111
+ model: true,
112
+ operation: true,
113
+ totalTokens: true,
114
+ promptTokens: true,
115
+ completionTokens: true,
116
+ createdAt: true,
117
+ } as const;
118
+
119
+ function createdAtRange(startDate?: Date, endDate?: Date) {
120
+ if (!startDate && !endDate) return undefined;
121
+ return {
122
+ ...(startDate ? { gte: startDate } : {}),
123
+ ...(endDate ? { lte: endDate } : {}),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Per-school aggregate AI usage over a date range. School admins only.
129
+ */
130
+ export async function getSchoolAiUsage(
131
+ userId: string,
132
+ input: {
133
+ schoolId: string;
134
+ startDate?: Date;
135
+ endDate?: Date;
136
+ topUsersLimit?: number;
137
+ },
138
+ ) {
139
+ const membership = await findActiveSchoolAdminMembership(
140
+ input.schoolId,
141
+ userId,
142
+ );
143
+ if (!membership) {
144
+ throw new TRPCError({
145
+ code: "FORBIDDEN",
146
+ message: "Only school admins can view school AI usage",
147
+ });
148
+ }
149
+
150
+ const rows = await prisma.aiUsageLog.findMany({
151
+ where: {
152
+ schoolId: input.schoolId,
153
+ ...(createdAtRange(input.startDate, input.endDate)
154
+ ? { createdAt: createdAtRange(input.startDate, input.endDate) }
155
+ : {}),
156
+ },
157
+ select: USAGE_ROW_SELECT,
158
+ });
159
+
160
+ const topUsers = aggregateTopUsers(rows, input.topUsersLimit ?? 10);
161
+ const userIds = topUsers.map((entry) => entry.userId);
162
+ const users = userIds.length
163
+ ? await prisma.user.findMany({
164
+ where: { id: { in: userIds } },
165
+ select: {
166
+ id: true,
167
+ username: true,
168
+ profile: { select: { displayName: true } },
169
+ },
170
+ })
171
+ : [];
172
+ const userById = new Map(users.map((user) => [user.id, user]));
173
+
174
+ return {
175
+ ...aggregateUsage(rows),
176
+ topUsers: topUsers.map((entry) => ({
177
+ ...entry,
178
+ username: userById.get(entry.userId)?.username ?? null,
179
+ displayName: userById.get(entry.userId)?.profile?.displayName ?? null,
180
+ })),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * The calling user's own AI usage over a date range.
186
+ */
187
+ export async function getMyAiUsage(
188
+ userId: string,
189
+ input: { startDate?: Date; endDate?: Date } = {},
190
+ ) {
191
+ const rows = await prisma.aiUsageLog.findMany({
192
+ where: {
193
+ userId,
194
+ ...(createdAtRange(input.startDate, input.endDate)
195
+ ? { createdAt: createdAtRange(input.startDate, input.endDate) }
196
+ : {}),
197
+ },
198
+ select: USAGE_ROW_SELECT,
199
+ });
200
+
201
+ return aggregateUsage(rows);
202
+ }
@@ -124,6 +124,11 @@ export async function createAnnouncementRecord(
124
124
  receiverIds: classData.students.map((s) => s.id),
125
125
  title: `New announcement in ${classData.name}`,
126
126
  content: `${remarks}\n\n[View announcement](/class/${classId})`,
127
+ senderId: userId,
128
+ type: "ANNOUNCEMENT",
129
+ entityType: "ANNOUNCEMENT",
130
+ entityId: announcement.id,
131
+ classId,
127
132
  }).catch((error) => {
128
133
  logger.error("Failed to send announcement notifications:", error);
129
134
  });
@@ -23,7 +23,7 @@ import {
23
23
  } from "../models/assignment.js";
24
24
  import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
25
25
  import { deleteFile } from "../lib/googleCloudStorage.js";
26
- import { sendToMultiple } from "./notification.js";
26
+ import { sendNotification, sendToMultiple } from "./notification.js";
27
27
  import { enqueueRunJob } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
@@ -317,6 +317,11 @@ export async function createAssignmentRecord(
317
317
  receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
318
318
  title: `New assignment in ${classData.name}`,
319
319
  content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
320
+ senderId: userId,
321
+ type: "ASSIGNMENT_POSTED",
322
+ entityType: "ASSIGNMENT",
323
+ entityId: assignment.id,
324
+ classId,
320
325
  }).catch((error) => {
321
326
  logger.error("Failed to send assignment notifications:", error);
322
327
  });
@@ -607,6 +612,11 @@ export async function updateAssignmentRecord(
607
612
  receiverIds,
608
613
  title: `New assignment in ${classData.name}`,
609
614
  content: `"${updatedAssignment.title}" is due ${new Date(updatedAssignment.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classData.id}/assignments/${updatedAssignment.id})`,
615
+ senderId: userId,
616
+ type: "ASSIGNMENT_POSTED",
617
+ entityType: "ASSIGNMENT",
618
+ entityId: updatedAssignment.id,
619
+ classId: classData.id,
610
620
  }).catch((error) => {
611
621
  logger.error("Failed to send assignment publish notifications:", error);
612
622
  });
@@ -877,11 +887,28 @@ export async function updateSubmissionAsTeacherRecord(
877
887
  }
878
888
 
879
889
  if (returnSubmission !== undefined) {
880
- return prisma.submission.update({
890
+ const updated = await prisma.submission.update({
881
891
  where: { id: submissionId },
882
892
  data: { returned: !submission.returned },
883
893
  include: submissionTeacherInclude,
884
894
  });
895
+
896
+ if (updated.returned) {
897
+ sendNotification({
898
+ receiverId: submission.studentId,
899
+ title: `Assignment returned in ${submission.assignment.class.name}`,
900
+ content: `"${submission.assignment.title}" has been returned.\n\n[View assignment](/class/${submission.assignment.class.id}/assignments/${submission.assignment.id})`,
901
+ senderId: teacherId,
902
+ type: "ASSIGNMENT_RETURNED",
903
+ entityType: "ASSIGNMENT",
904
+ entityId: submission.assignment.id,
905
+ classId: submission.assignment.class.id,
906
+ }).catch((error) => {
907
+ logger.error("Failed to send assignment returned notification:", error);
908
+ });
909
+ }
910
+
911
+ return updated;
885
912
  }
886
913
 
887
914
  if (existingFileIds?.length) {
@@ -143,11 +143,19 @@ async function notifyMarkedLateOrAbsent(input: {
143
143
  receiverIds: lateReceiverIds,
144
144
  title: `Marked late in ${input.className}`,
145
145
  content: `You were marked late for ${attendanceLabel}.\n[View attendance](${link})`,
146
+ type: "ATTENDANCE",
147
+ entityType: "ATTENDANCE",
148
+ entityId: input.eventId,
149
+ classId: input.classId,
146
150
  }),
147
151
  sendAttendanceNotification({
148
152
  receiverIds: absentReceiverIds,
149
153
  title: `Marked absent in ${input.className}`,
150
154
  content: `You were marked absent for ${attendanceLabel}.\n[View attendance](${link})`,
155
+ type: "ATTENDANCE",
156
+ entityType: "ATTENDANCE",
157
+ entityId: input.eventId,
158
+ classId: input.classId,
151
159
  }),
152
160
  ]);
153
161
  }
@@ -157,6 +165,10 @@ async function sendAttendanceNotification(input: {
157
165
  receiverIds: string[];
158
166
  title: string;
159
167
  content: string;
168
+ type: "ATTENDANCE";
169
+ entityType: string;
170
+ entityId: string | null;
171
+ classId: string;
160
172
  }) {
161
173
  if (input.receiverIds.length === 0) {
162
174
  return;
@@ -785,6 +785,10 @@ export async function publishLabDraft(
785
785
  receiverIds: classData.students.map((s) => s.id),
786
786
  title: `New assignment in ${classData.name}`,
787
787
  content: `"${published.title}" is due ${new Date(published.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${published.id})`,
788
+ type: "ASSIGNMENT_POSTED",
789
+ entityType: "ASSIGNMENT",
790
+ entityId: published.id,
791
+ classId,
788
792
  }).catch((error) => {
789
793
  logger.error("Failed to send lab draft publish notifications:", error);
790
794
  });
@@ -1,4 +1,4 @@
1
- import type { DiscussionType, Prisma, ReactionType } from "@prisma/client";
1
+ import type { DiscussionType, NotificationType, Prisma, ReactionType } from "@prisma/client";
2
2
  import type { AppTransactionClient } from "../lib/prisma.js";
3
3
  import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
@@ -266,14 +266,29 @@ function triggerDiscussionEvent(classId: string, event: string, payload: unknown
266
266
  });
267
267
  }
268
268
 
269
- async function notify(receiverId: string | null | undefined, senderId: string, title: string, content: string) {
269
+ async function notify(
270
+ receiverId: string | null | undefined,
271
+ senderId: string,
272
+ title: string,
273
+ content: string,
274
+ meta: { type: NotificationType; classId: string; discussionId: string }
275
+ ) {
270
276
  if (!receiverId || receiverId === senderId) return;
271
- await sendNotification({ receiverId, title, content }).catch((error) => {
277
+ await sendNotification({
278
+ receiverId,
279
+ senderId,
280
+ title,
281
+ content,
282
+ type: meta.type,
283
+ entityType: "DISCUSSION",
284
+ entityId: meta.discussionId,
285
+ classId: meta.classId,
286
+ }).catch((error) => {
272
287
  logger.error("Failed to send discussion notification", { receiverId, error });
273
288
  });
274
289
  }
275
290
 
276
- async function notifyMentionedUsers(classId: string, senderId: string, body: string, link: string) {
291
+ async function notifyMentionedUsers(classId: string, senderId: string, body: string, link: string, discussionId: string) {
277
292
  const usernames = [...new Set(Array.from(body.matchAll(/(^|\s)@([a-zA-Z0-9_.-]{2,32})/g)).map((match) => match[2]))];
278
293
  if (!usernames.length) return;
279
294
  const members = await prisma.user.findMany({
@@ -287,7 +302,15 @@ async function notifyMentionedUsers(classId: string, senderId: string, body: str
287
302
  },
288
303
  select: { id: true },
289
304
  });
290
- await Promise.all(members.map((member) => notify(member.id, senderId, "You were mentioned in a discussion", link)));
305
+ await Promise.all(
306
+ members.map((member) =>
307
+ notify(member.id, senderId, "You were mentioned in a discussion", link, {
308
+ type: "DISCUSSION_MENTION",
309
+ classId,
310
+ discussionId,
311
+ })
312
+ )
313
+ );
291
314
  }
292
315
 
293
316
  async function unreadCountsForDiscussions(userId: string, discussionIds: string[]) {
@@ -441,7 +464,7 @@ export async function createDiscussion(userId: string, input: {
441
464
  });
442
465
 
443
466
  triggerDiscussionEvent(input.classId, "discussion-created", { discussion: serializeDiscussion(discussion, userId) });
444
- await notifyMentionedUsers(input.classId, userId, input.body, `/class/${input.classId}/discussions/${discussion.id}`);
467
+ await notifyMentionedUsers(input.classId, userId, input.body, `/class/${input.classId}/discussions/${discussion.id}`, discussion.id);
445
468
 
446
469
  // Auto-index discussion for RAG search
447
470
  enqueueEmbedding(input.classId, 'DISCUSSION', discussion.id);
@@ -530,9 +553,17 @@ export async function replyToDiscussion(userId: string, input: {
530
553
  });
531
554
  const link = `/class/${discussion.classId}/discussions/${discussion.id}#${reply.id}`;
532
555
  await Promise.all([
533
- notify(discussion.authorId, userId, "New reply to your discussion", link),
534
- notify(parent?.authorId, userId, "New reply in a discussion thread", link),
535
- notifyMentionedUsers(discussion.classId, userId, input.body, link),
556
+ notify(discussion.authorId, userId, "New reply to your discussion", link, {
557
+ type: "DISCUSSION_REPLY",
558
+ classId: discussion.classId,
559
+ discussionId: discussion.id,
560
+ }),
561
+ notify(parent?.authorId, userId, "New reply in a discussion thread", link, {
562
+ type: "DISCUSSION_REPLY",
563
+ classId: discussion.classId,
564
+ discussionId: discussion.id,
565
+ }),
566
+ notifyMentionedUsers(discussion.classId, userId, input.body, link, discussion.id),
536
567
  ]);
537
568
  triggerDiscussionEvent(discussion.classId, "reply-created", { discussionId: discussion.id, reply: serializeReply(reply, userId) });
538
569
 
@@ -582,7 +613,11 @@ export async function pinDiscussion(userId: string, discussionId: string, isPinn
582
613
  const { discussion, isTeacher } = await getDiscussionForUser(discussionId, userId);
583
614
  if (!isTeacher) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can pin discussions" });
584
615
  const updated = await prisma.discussion.update({ where: { id: discussionId }, data: { isPinned }, include: discussionInclude });
585
- await notify(discussion.authorId, userId, isPinned ? "Your discussion was pinned" : "Your discussion was unpinned", `/class/${discussion.classId}/discussions/${discussionId}`);
616
+ await notify(discussion.authorId, userId, isPinned ? "Your discussion was pinned" : "Your discussion was unpinned", `/class/${discussion.classId}/discussions/${discussionId}`, {
617
+ type: "DISCUSSION_REPLY",
618
+ classId: discussion.classId,
619
+ discussionId,
620
+ });
586
621
  triggerDiscussionEvent(discussion.classId, "discussion-updated", { discussion: serializeDiscussion(updated, userId) });
587
622
  return { discussion: serializeDiscussion(updated, userId) };
588
623
  }
@@ -611,7 +646,11 @@ export async function markAcceptedAnswer(userId: string, replyId: string, accept
611
646
  }
612
647
  return tx.discussionReply.update({ where: { id: replyId }, data: { isAcceptedAnswer: accepted }, include: replyInclude });
613
648
  });
614
- await notify(reply.authorId, userId, accepted ? "Your answer was accepted" : "Your answer was unmarked", `/class/${reply.discussion.classId}/discussions/${reply.discussionId}#${replyId}`);
649
+ await notify(reply.authorId, userId, accepted ? "Your answer was accepted" : "Your answer was unmarked", `/class/${reply.discussion.classId}/discussions/${reply.discussionId}#${replyId}`, {
650
+ type: "DISCUSSION_REPLY",
651
+ classId: reply.discussion.classId,
652
+ discussionId: reply.discussionId,
653
+ });
615
654
  triggerDiscussionEvent(reply.discussion.classId, "reply-updated", { discussionId: reply.discussionId, reply: serializeReply(updated, userId) });
616
655
  return { reply: serializeReply(updated, userId) };
617
656
  }
@@ -215,6 +215,7 @@ export async function getDiscussionSummary(
215
215
  {
216
216
  reason: "discussion_summary",
217
217
  metadata: { discussionId, classId: discussion.classId },
218
+ attribution: { userId, classId: discussion.classId },
218
219
  },
219
220
  );
220
221
 
@@ -1,18 +1,63 @@
1
1
  /**
2
- * Notification service – list, send, and mark notifications as read.
2
+ * Notification service – list, emit, and mark notifications as read.
3
+ *
4
+ * Notifications are only emitted server-side by domain services (assignments,
5
+ * announcements, discussions, attendance, …); there is no public "send"
6
+ * endpoint. New notifications are also pushed in realtime over Pusher on the
7
+ * receiver's private per-user channel.
3
8
  */
4
9
  import { TRPCError } from "@trpc/server";
10
+ import type { NotificationType } from "@prisma/client";
5
11
  import {
6
- findNotificationsByReceiverId,
12
+ findNotificationsByReceiverIdPaginated,
7
13
  findNotificationByIdForReceiver,
14
+ countUnreadNotifications,
8
15
  createNotification,
9
16
  createManyNotifications,
10
17
  markNotificationAsReadForReceiver,
18
+ markAllNotificationsAsReadForReceiver,
19
+ type NotificationCreateData,
11
20
  } from "../models/notification.js";
21
+ import { pusher, userNotificationChannel } from "../lib/pusher.js";
22
+ import { logger } from "../utils/logger.js";
12
23
 
13
- /** List notifications for a user. */
14
- export async function listNotifications(userId: string) {
15
- return findNotificationsByReceiverId(userId);
24
+ export const NOTIFICATION_CREATED_EVENT = "notification-created";
25
+
26
+ type CreatedNotification = Awaited<ReturnType<typeof createNotification>>;
27
+
28
+ /** Pusher batch triggers accept at most 10 events per call. */
29
+ const PUSHER_BATCH_SIZE = 10;
30
+
31
+ async function pushNotifications(notifications: CreatedNotification[]) {
32
+ for (let i = 0; i < notifications.length; i += PUSHER_BATCH_SIZE) {
33
+ const batch = notifications.slice(i, i + PUSHER_BATCH_SIZE).map((notification) => ({
34
+ channel: userNotificationChannel(notification.receiverId),
35
+ name: NOTIFICATION_CREATED_EVENT,
36
+ data: notification,
37
+ }));
38
+ try {
39
+ await pusher.triggerBatch(batch);
40
+ } catch (error) {
41
+ logger.error("Failed to push notifications over Pusher", { error });
42
+ }
43
+ }
44
+ }
45
+
46
+ /** List notifications for a user, newest first, cursor-paginated. */
47
+ export async function listNotifications(
48
+ userId: string,
49
+ input?: { limit?: number; cursor?: string | null }
50
+ ) {
51
+ const limit = input?.limit ?? 20;
52
+ const rows = await findNotificationsByReceiverIdPaginated(userId, limit, input?.cursor);
53
+
54
+ let nextCursor: string | null = null;
55
+ if (rows.length > limit) {
56
+ const nextItem = rows.pop();
57
+ nextCursor = nextItem!.id;
58
+ }
59
+
60
+ return { items: rows, nextCursor };
16
61
  }
17
62
 
18
63
  /** Get a single notification by ID for its receiver. */
@@ -29,29 +74,41 @@ export async function getNotification(userId: string, id: string) {
29
74
  return notification;
30
75
  }
31
76
 
32
- /** Send a notification to one user. */
33
- export async function sendNotification(input: {
34
- receiverId: string;
35
- title: string;
36
- content: string;
37
- }) {
38
- return createNotification(input);
77
+ /** Number of unread notifications for a user. */
78
+ export function getUnreadCount(userId: string) {
79
+ return countUnreadNotifications(userId);
80
+ }
81
+
82
+ /**
83
+ * Emit a notification to one user (server-side only) and push it over Pusher.
84
+ */
85
+ export async function sendNotification(input: NotificationCreateData) {
86
+ const notification = await createNotification(input);
87
+ void pushNotifications([notification]);
88
+ return notification;
39
89
  }
40
90
 
41
- /** Send the same notification to multiple users. */
91
+ /**
92
+ * Emit the same notification to multiple users (server-side only) and push
93
+ * each over Pusher.
94
+ */
42
95
  export async function sendToMultiple(input: {
43
96
  receiverIds: string[];
44
97
  title: string;
45
98
  content: string;
99
+ senderId?: string | null;
100
+ type?: NotificationType;
101
+ entityType?: string | null;
102
+ entityId?: string | null;
103
+ classId?: string | null;
46
104
  }) {
47
- const result = await createManyNotifications(
48
- input.receiverIds.map((receiverId) => ({
49
- receiverId,
50
- title: input.title,
51
- content: input.content,
52
- }))
105
+ const { receiverIds, ...rest } = input;
106
+ if (receiverIds.length === 0) return [];
107
+ const notifications = await createManyNotifications(
108
+ receiverIds.map((receiverId) => ({ receiverId, ...rest }))
53
109
  );
54
- return result;
110
+ void pushNotifications(notifications);
111
+ return notifications;
55
112
  }
56
113
 
57
114
  /** Mark a notification as read for its receiver. */
@@ -67,3 +124,9 @@ export async function markAsRead(userId: string, id: string) {
67
124
 
68
125
  return notification;
69
126
  }
127
+
128
+ /** Mark all notifications as read for a user. */
129
+ export async function markAllAsRead(userId: string) {
130
+ const result = await markAllNotificationsAsReadForReceiver(userId);
131
+ return { count: result.count };
132
+ }
@@ -745,6 +745,7 @@ export async function getStudentProgressInsights(
745
745
  {
746
746
  reason: "student_progress_insights",
747
747
  metadata: { viewerId, classId, studentId },
748
+ attribution: { userId: viewerId, classId },
748
749
  },
749
750
  );
750
751
  setStudentProgressInferenceCache(
@@ -792,6 +793,7 @@ export async function getStudentProgressForecast(
792
793
  {
793
794
  reason: "student_progress_forecast",
794
795
  metadata: { viewerId, classId, studentId },
796
+ attribution: { userId: viewerId, classId },
795
797
  },
796
798
  );
797
799
  setStudentProgressInferenceCache(
@@ -839,6 +841,7 @@ export async function getStudentProgressFocus(
839
841
  {
840
842
  reason: "student_progress_focus",
841
843
  metadata: { viewerId, classId, studentId },
844
+ attribution: { userId: viewerId, classId },
842
845
  },
843
846
  );
844
847
  setStudentProgressInferenceCache(
@@ -901,6 +904,7 @@ export async function chatAboutStudentProgress(
901
904
  classId: input.classId,
902
905
  studentId: input.studentId,
903
906
  },
907
+ attribution: { userId: viewerId, classId: input.classId },
904
908
  });
905
909
  if (typeof response !== "string" || response.trim().length === 0) {
906
910
  throw new Error("Student progress chat returned an empty response");
@@ -121,6 +121,10 @@ export async function exportUserData(userId: string) {
121
121
  content: true,
122
122
  read: true,
123
123
  createdAt: true,
124
+ type: true,
125
+ entityType: true,
126
+ entityId: true,
127
+ classId: true,
124
128
  },
125
129
  take: 200,
126
130
  orderBy: { createdAt: "desc" },
@@ -626,7 +626,7 @@ export async function regradeQuestion(
626
626
  message: "Only class teachers can regrade worksheet answers",
627
627
  });
628
628
  }
629
- return regradeWorksheetPipeline(worksheetResponseId, progressId);
629
+ return regradeWorksheetPipeline(worksheetResponseId, progressId, userId);
630
630
  }
631
631
 
632
632
  export async function regradeAll(
@@ -645,7 +645,7 @@ export async function regradeAll(
645
645
  });
646
646
  }
647
647
 
648
- await regradeAllPipeline(worksheetResponseId);
648
+ await regradeAllPipeline(worksheetResponseId, userId);
649
649
  }
650
650
 
651
651
  export async function gradeAnswer(