@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.
- package/Dockerfile +5 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/pusher.d.ts +3 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +5 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +4 -4
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/class.d.ts +1 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +5 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/conversation.d.ts +17 -17
- package/dist/models/notification.d.ts +63 -11
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +24 -10
- package/dist/models/notification.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +1 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +7 -2
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts +2 -2
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +12 -4
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +3 -3
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +13 -9
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +3 -2
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +322 -82
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +2 -2
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +74 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +190 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/notifications.d.ts +51 -41
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +22 -18
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/school.d.ts +30 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -2
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/user.d.ts +12 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +11 -2
- package/dist/routers/user.js.map +1 -1
- package/dist/seedDatabase.d.ts +4 -0
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +5 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +32 -3
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiStream.d.ts +5 -0
- package/dist/services/aiStream.d.ts.map +1 -1
- package/dist/services/aiStream.js +29 -3
- package/dist/services/aiStream.js.map +1 -1
- package/dist/services/aiUsage.d.ts +72 -0
- package/dist/services/aiUsage.d.ts.map +1 -0
- package/dist/services/aiUsage.js +150 -0
- package/dist/services/aiUsage.js.map +1 -0
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +7 -2
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +29 -4
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.js +10 -2
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -2
- package/dist/services/class.js.map +1 -1
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +41 -12
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/discussionSummary.d.ts.map +1 -1
- package/dist/services/discussionSummary.js +3 -2
- package/dist/services/discussionSummary.js.map +1 -1
- package/dist/services/notification.d.ts +79 -16
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +64 -16
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/userPrivacy.d.ts +4 -0
- package/dist/services/userPrivacy.d.ts.map +1 -1
- package/dist/services/userPrivacy.js +6 -2
- package/dist/services/userPrivacy.js.map +1 -1
- package/dist/services/worksheet.js +4 -4
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +27 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +23 -3
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
- package/dist/workers/processors/aiLabChat.js +4 -3
- package/dist/workers/processors/aiLabChat.js.map +1 -1
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
- package/dist/workers/processors/aiNewtonChat.js +5 -5
- package/dist/workers/processors/aiNewtonChat.js.map +1 -1
- package/dist/workers/processors/gradeWorksheet.js +4 -4
- package/dist/workers/processors/gradeWorksheet.js.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.js +7 -4
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
- package/docker-entrypoint.sh +7 -0
- package/package.json +1 -1
- package/prisma/migrations/20260708011456_add_file_annotations/migration.sql +35 -0
- package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
- package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
- package/prisma/schema.prisma +99 -43
- package/src/index.ts +7 -0
- package/src/lib/pusher.ts +3 -1
- package/src/middleware/security.ts +2 -2
- package/src/models/class.ts +3 -2
- package/src/models/notification.ts +42 -13
- package/src/pipelines/aiLabChat.ts +6 -1
- package/src/pipelines/aiNewtonChat.ts +10 -0
- package/src/pipelines/gradeWorksheet.ts +25 -7
- package/src/pipelines/pdfToWorksheet.ts +3 -0
- package/src/routers/announcement.ts +0 -1
- package/src/routers/assignment.ts +214 -0
- package/src/routers/notifications.ts +26 -24
- package/src/routers/school.ts +12 -0
- package/src/routers/user.ts +12 -0
- package/src/services/aiChatStream.ts +35 -0
- package/src/services/aiStream.ts +34 -0
- package/src/services/aiUsage.ts +202 -0
- package/src/services/announcement.ts +5 -0
- package/src/services/assignment.ts +29 -2
- package/src/services/attendance.ts +12 -0
- package/src/services/class.ts +4 -0
- package/src/services/discussion.ts +50 -11
- package/src/services/discussionSummary.ts +1 -0
- package/src/services/notification.ts +83 -20
- package/src/services/studentProgress.ts +4 -0
- package/src/services/userPrivacy.ts +4 -0
- package/src/services/worksheet.ts +2 -2
- package/src/utils/inference.ts +40 -2
- package/src/workers/processors/aiLabChat.ts +2 -1
- package/src/workers/processors/aiNewtonChat.ts +3 -1
- package/src/workers/processors/gradeWorksheet.ts +2 -2
- package/src/workers/processors/pdfToWorksheet.ts +5 -2
- package/tests/integration/routers/notification.test.ts +68 -20
- 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
|
-
|
|
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;
|
package/src/services/class.ts
CHANGED
|
@@ -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(
|
|
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({
|
|
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(
|
|
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
|
-
|
|
535
|
-
|
|
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
|
}
|
|
@@ -1,18 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Notification service – list,
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -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(
|