@studious-lms/server 1.19.0 → 1.20.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 (151) hide show
  1. package/dist/index.js +8 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/pusher.d.ts +3 -1
  4. package/dist/lib/pusher.d.ts.map +1 -1
  5. package/dist/lib/pusher.js +5 -3
  6. package/dist/lib/pusher.js.map +1 -1
  7. package/dist/middleware/security.d.ts.map +1 -1
  8. package/dist/middleware/security.js +4 -4
  9. package/dist/middleware/security.js.map +1 -1
  10. package/dist/models/class.d.ts +1 -1
  11. package/dist/models/class.d.ts.map +1 -1
  12. package/dist/models/class.js +5 -4
  13. package/dist/models/class.js.map +1 -1
  14. package/dist/models/conversation.d.ts +17 -17
  15. package/dist/models/notification.d.ts +63 -11
  16. package/dist/models/notification.d.ts.map +1 -1
  17. package/dist/models/notification.js +24 -10
  18. package/dist/models/notification.js.map +1 -1
  19. package/dist/pipelines/aiLabChat.d.ts +1 -0
  20. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  21. package/dist/pipelines/aiLabChat.js +7 -2
  22. package/dist/pipelines/aiLabChat.js.map +1 -1
  23. package/dist/pipelines/aiNewtonChat.d.ts +2 -2
  24. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  25. package/dist/pipelines/aiNewtonChat.js +12 -4
  26. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  27. package/dist/pipelines/gradeWorksheet.d.ts +3 -3
  28. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  29. package/dist/pipelines/gradeWorksheet.js +13 -9
  30. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  31. package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
  32. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  33. package/dist/pipelines/pdfToWorksheet.js +3 -2
  34. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  35. package/dist/routers/_app.d.ts +174 -82
  36. package/dist/routers/_app.d.ts.map +1 -1
  37. package/dist/routers/announcement.d.ts.map +1 -1
  38. package/dist/routers/announcement.js +2 -2
  39. package/dist/routers/announcement.js.map +1 -1
  40. package/dist/routers/assignment.d.ts.map +1 -1
  41. package/dist/routers/assignment.js +12 -2
  42. package/dist/routers/assignment.js.map +1 -1
  43. package/dist/routers/notifications.d.ts +51 -41
  44. package/dist/routers/notifications.d.ts.map +1 -1
  45. package/dist/routers/notifications.js +22 -18
  46. package/dist/routers/notifications.js.map +1 -1
  47. package/dist/routers/school.d.ts +30 -0
  48. package/dist/routers/school.d.ts.map +1 -1
  49. package/dist/routers/school.js +11 -2
  50. package/dist/routers/school.js.map +1 -1
  51. package/dist/routers/user.d.ts +12 -0
  52. package/dist/routers/user.d.ts.map +1 -1
  53. package/dist/routers/user.js +11 -2
  54. package/dist/routers/user.js.map +1 -1
  55. package/dist/seedDatabase.d.ts +4 -0
  56. package/dist/seedDatabase.d.ts.map +1 -1
  57. package/dist/services/aiChatStream.d.ts +5 -0
  58. package/dist/services/aiChatStream.d.ts.map +1 -1
  59. package/dist/services/aiChatStream.js +32 -3
  60. package/dist/services/aiChatStream.js.map +1 -1
  61. package/dist/services/aiStream.d.ts +5 -0
  62. package/dist/services/aiStream.d.ts.map +1 -1
  63. package/dist/services/aiStream.js +29 -3
  64. package/dist/services/aiStream.js.map +1 -1
  65. package/dist/services/aiUsage.d.ts +72 -0
  66. package/dist/services/aiUsage.d.ts.map +1 -0
  67. package/dist/services/aiUsage.js +150 -0
  68. package/dist/services/aiUsage.js.map +1 -0
  69. package/dist/services/announcement.d.ts.map +1 -1
  70. package/dist/services/announcement.js +7 -2
  71. package/dist/services/announcement.js.map +1 -1
  72. package/dist/services/assignment.d.ts.map +1 -1
  73. package/dist/services/assignment.js +29 -4
  74. package/dist/services/assignment.js.map +1 -1
  75. package/dist/services/attendance.js +10 -2
  76. package/dist/services/attendance.js.map +1 -1
  77. package/dist/services/class.d.ts.map +1 -1
  78. package/dist/services/class.js +6 -2
  79. package/dist/services/class.js.map +1 -1
  80. package/dist/services/discussion.d.ts.map +1 -1
  81. package/dist/services/discussion.js +41 -12
  82. package/dist/services/discussion.js.map +1 -1
  83. package/dist/services/discussionSummary.d.ts.map +1 -1
  84. package/dist/services/discussionSummary.js +3 -2
  85. package/dist/services/discussionSummary.js.map +1 -1
  86. package/dist/services/notification.d.ts +79 -16
  87. package/dist/services/notification.d.ts.map +1 -1
  88. package/dist/services/notification.js +64 -16
  89. package/dist/services/notification.js.map +1 -1
  90. package/dist/services/studentProgress.d.ts.map +1 -1
  91. package/dist/services/studentProgress.js +6 -2
  92. package/dist/services/studentProgress.js.map +1 -1
  93. package/dist/services/userPrivacy.d.ts +4 -0
  94. package/dist/services/userPrivacy.d.ts.map +1 -1
  95. package/dist/services/userPrivacy.js +6 -2
  96. package/dist/services/userPrivacy.js.map +1 -1
  97. package/dist/services/worksheet.js +4 -4
  98. package/dist/services/worksheet.js.map +1 -1
  99. package/dist/utils/inference.d.ts +27 -0
  100. package/dist/utils/inference.d.ts.map +1 -1
  101. package/dist/utils/inference.js +23 -3
  102. package/dist/utils/inference.js.map +1 -1
  103. package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
  104. package/dist/workers/processors/aiLabChat.js +4 -3
  105. package/dist/workers/processors/aiLabChat.js.map +1 -1
  106. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  107. package/dist/workers/processors/aiNewtonChat.js +5 -5
  108. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  109. package/dist/workers/processors/gradeWorksheet.js +4 -4
  110. package/dist/workers/processors/gradeWorksheet.js.map +1 -1
  111. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  112. package/dist/workers/processors/pdfToWorksheet.js +7 -4
  113. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  114. package/package.json +1 -1
  115. package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
  116. package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
  117. package/prisma/schema.prisma +29 -5
  118. package/src/index.ts +7 -0
  119. package/src/lib/pusher.ts +3 -1
  120. package/src/middleware/security.ts +2 -2
  121. package/src/models/class.ts +3 -2
  122. package/src/models/notification.ts +42 -13
  123. package/src/pipelines/aiLabChat.ts +6 -1
  124. package/src/pipelines/aiNewtonChat.ts +10 -0
  125. package/src/pipelines/gradeWorksheet.ts +25 -7
  126. package/src/pipelines/pdfToWorksheet.ts +3 -0
  127. package/src/routers/announcement.ts +0 -1
  128. package/src/routers/assignment.ts +10 -0
  129. package/src/routers/notifications.ts +26 -24
  130. package/src/routers/school.ts +12 -0
  131. package/src/routers/user.ts +12 -0
  132. package/src/services/aiChatStream.ts +35 -0
  133. package/src/services/aiStream.ts +34 -0
  134. package/src/services/aiUsage.ts +202 -0
  135. package/src/services/announcement.ts +5 -0
  136. package/src/services/assignment.ts +29 -2
  137. package/src/services/attendance.ts +12 -0
  138. package/src/services/class.ts +4 -0
  139. package/src/services/discussion.ts +50 -11
  140. package/src/services/discussionSummary.ts +1 -0
  141. package/src/services/notification.ts +83 -20
  142. package/src/services/studentProgress.ts +4 -0
  143. package/src/services/userPrivacy.ts +4 -0
  144. package/src/services/worksheet.ts +2 -2
  145. package/src/utils/inference.ts +40 -2
  146. package/src/workers/processors/aiLabChat.ts +2 -1
  147. package/src/workers/processors/aiNewtonChat.ts +3 -1
  148. package/src/workers/processors/gradeWorksheet.ts +2 -2
  149. package/src/workers/processors/pdfToWorksheet.ts +5 -2
  150. package/tests/integration/routers/notification.test.ts +68 -20
  151. package/tests/unit/services/aiUsage.test.ts +105 -0
@@ -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 },
@@ -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