@studious-lms/server 1.18.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.
- 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/models/worksheet.d.ts +1 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +3 -3
- package/dist/models/worksheet.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 +176 -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.map +1 -1
- package/dist/routers/assignment.js +12 -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/routers/worksheet.d.ts +1 -0
- package/dist/routers/worksheet.d.ts.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.d.ts +1 -0
- package/dist/services/worksheet.d.ts.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/package.json +1 -1
- 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 +29 -5
- 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/models/worksheet.ts +1 -1
- 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 +10 -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
|
@@ -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(
|
package/src/utils/inference.ts
CHANGED
|
@@ -26,12 +26,23 @@ type InferenceMessage = {
|
|
|
26
26
|
content: string;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Attribution context for AI usage logging. `schoolId` is resolved from
|
|
31
|
+
* `classId` at log time when not supplied by the caller.
|
|
32
|
+
*/
|
|
33
|
+
export interface AiUsageAttribution {
|
|
34
|
+
userId?: string | null;
|
|
35
|
+
schoolId?: string | null;
|
|
36
|
+
classId?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
export interface InferenceOptions {
|
|
30
40
|
reason?: string;
|
|
31
41
|
metadata?: Prisma.InputJsonValue;
|
|
32
42
|
model?: string;
|
|
33
43
|
temperature?: number;
|
|
34
44
|
maxTokens?: number;
|
|
45
|
+
attribution?: AiUsageAttribution;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
interface InferenceUsage {
|
|
@@ -42,13 +53,14 @@ interface InferenceUsage {
|
|
|
42
53
|
|
|
43
54
|
interface InferenceAuditLog {
|
|
44
55
|
model: string;
|
|
45
|
-
operation: 'chat.completions' | 'responses.parse';
|
|
56
|
+
operation: 'chat.completions' | 'responses.parse' | 'stream.text';
|
|
46
57
|
success: boolean;
|
|
47
58
|
estimatedInputTokens: number;
|
|
48
59
|
reason: string;
|
|
49
60
|
metadata?: Prisma.InputJsonValue;
|
|
50
61
|
usage?: InferenceUsage;
|
|
51
62
|
error?: unknown;
|
|
63
|
+
attribution?: AiUsageAttribution;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
// Types for lab chat context
|
|
@@ -104,7 +116,25 @@ function serializeInferenceError(error: unknown) {
|
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
|
|
107
|
-
async function
|
|
119
|
+
async function resolveAttribution(
|
|
120
|
+
attribution?: AiUsageAttribution,
|
|
121
|
+
): Promise<{ userId: string | null; schoolId: string | null; classId: string | null }> {
|
|
122
|
+
const userId = attribution?.userId ?? null;
|
|
123
|
+
const classId = attribution?.classId ?? null;
|
|
124
|
+
let schoolId = attribution?.schoolId ?? null;
|
|
125
|
+
|
|
126
|
+
if (!schoolId && classId) {
|
|
127
|
+
const classRow = await prisma.class.findUnique({
|
|
128
|
+
where: { id: classId },
|
|
129
|
+
select: { schoolId: true },
|
|
130
|
+
});
|
|
131
|
+
schoolId = classRow?.schoolId ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { userId, schoolId, classId };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function logInferenceUsage({
|
|
108
138
|
model,
|
|
109
139
|
operation,
|
|
110
140
|
success,
|
|
@@ -113,6 +143,7 @@ async function logInferenceUsage({
|
|
|
113
143
|
metadata,
|
|
114
144
|
usage,
|
|
115
145
|
error,
|
|
146
|
+
attribution,
|
|
116
147
|
}: InferenceAuditLog) {
|
|
117
148
|
const context = {
|
|
118
149
|
reason,
|
|
@@ -128,6 +159,7 @@ async function logInferenceUsage({
|
|
|
128
159
|
};
|
|
129
160
|
|
|
130
161
|
try {
|
|
162
|
+
const resolved = await resolveAttribution(attribution);
|
|
131
163
|
await prisma.aiUsageLog.create({
|
|
132
164
|
data: {
|
|
133
165
|
reason,
|
|
@@ -138,6 +170,9 @@ async function logInferenceUsage({
|
|
|
138
170
|
promptTokens: usage?.promptTokens,
|
|
139
171
|
completionTokens: usage?.completionTokens,
|
|
140
172
|
totalTokens: usage?.totalTokens,
|
|
173
|
+
userId: resolved.userId,
|
|
174
|
+
schoolId: resolved.schoolId,
|
|
175
|
+
classId: resolved.classId,
|
|
141
176
|
...(metadata !== undefined ? { metadata } : {}),
|
|
142
177
|
...(error ? { error: serializeInferenceError(error) } : {}),
|
|
143
178
|
},
|
|
@@ -587,6 +622,7 @@ export async function inference<T>(
|
|
|
587
622
|
reason,
|
|
588
623
|
metadata: options?.metadata,
|
|
589
624
|
usage: getChatCompletionUsage(completion),
|
|
625
|
+
attribution: options?.attribution,
|
|
590
626
|
});
|
|
591
627
|
|
|
592
628
|
return completion.choices[0]?.message?.content as T;
|
|
@@ -613,6 +649,7 @@ export async function inference<T>(
|
|
|
613
649
|
reason,
|
|
614
650
|
metadata: options?.metadata,
|
|
615
651
|
usage: getResponseUsage(completion),
|
|
652
|
+
attribution: options?.attribution,
|
|
616
653
|
});
|
|
617
654
|
|
|
618
655
|
return completion.output_parsed as T;
|
|
@@ -625,6 +662,7 @@ export async function inference<T>(
|
|
|
625
662
|
reason,
|
|
626
663
|
metadata: options?.metadata,
|
|
627
664
|
error,
|
|
665
|
+
attribution: options?.attribution,
|
|
628
666
|
});
|
|
629
667
|
|
|
630
668
|
logger.error('Failed to generate inference response', {
|
|
@@ -14,7 +14,7 @@ export const aiLabChatWorker = createRunJobWorker({
|
|
|
14
14
|
concurrency: 1,
|
|
15
15
|
limiter: { max: 5, duration: 60_000 },
|
|
16
16
|
},
|
|
17
|
-
run: async ({ conversationId, aiMessageId }) => {
|
|
17
|
+
run: async ({ conversationId, aiMessageId }, ctx) => {
|
|
18
18
|
const labChat = await prisma.labChat.findFirst({
|
|
19
19
|
where: { conversationId },
|
|
20
20
|
select: {
|
|
@@ -58,6 +58,7 @@ export const aiLabChatWorker = createRunJobWorker({
|
|
|
58
58
|
await generateAndSendLabResponse(labChat.id, triggeringMessage.content, {
|
|
59
59
|
classId: labChat.classId,
|
|
60
60
|
aiMessageId,
|
|
61
|
+
userId: ctx.userId ?? triggeringMessage.senderId,
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
return { labChatId: labChat.id, aiMessageId };
|
|
@@ -12,7 +12,7 @@ export const aiNewtonChatWorker = createRunJobWorker({
|
|
|
12
12
|
concurrency: 2,
|
|
13
13
|
limiter: { max: 10, duration: 60_000 },
|
|
14
14
|
},
|
|
15
|
-
run: async ({ conversationId, aiMessageId }) => {
|
|
15
|
+
run: async ({ conversationId, aiMessageId }, ctx) => {
|
|
16
16
|
const newtonChat = await prisma.newtonChat.findFirst({
|
|
17
17
|
where: { conversationId },
|
|
18
18
|
select: {
|
|
@@ -58,6 +58,7 @@ export const aiNewtonChatWorker = createRunJobWorker({
|
|
|
58
58
|
conversationId,
|
|
59
59
|
newtonChat.submission.id,
|
|
60
60
|
aiMessageId,
|
|
61
|
+
ctx.userId,
|
|
61
62
|
);
|
|
62
63
|
return { newtonChatId: newtonChat.id, kind: 'introduction', aiMessageId };
|
|
63
64
|
}
|
|
@@ -67,6 +68,7 @@ export const aiNewtonChatWorker = createRunJobWorker({
|
|
|
67
68
|
conversationId,
|
|
68
69
|
newtonChat.submission,
|
|
69
70
|
aiMessageId,
|
|
71
|
+
ctx.userId ?? triggeringMessage.senderId,
|
|
70
72
|
);
|
|
71
73
|
|
|
72
74
|
return { newtonChatId: newtonChat.id, aiMessageId };
|
|
@@ -9,8 +9,8 @@ export const gradeWorksheetWorker = createRunJobWorker({
|
|
|
9
9
|
// throughput modest until we measure real-world usage.
|
|
10
10
|
limiter: { max: 5, duration: 60_000 },
|
|
11
11
|
},
|
|
12
|
-
run: async ({ worksheetResponseId }) => {
|
|
13
|
-
await gradeWorksheetPipeline(worksheetResponseId);
|
|
12
|
+
run: async ({ worksheetResponseId }, ctx) => {
|
|
13
|
+
await gradeWorksheetPipeline(worksheetResponseId, ctx.userId);
|
|
14
14
|
return { worksheetResponseId };
|
|
15
15
|
},
|
|
16
16
|
});
|
|
@@ -8,8 +8,11 @@ export const pdfToWorksheetWorker = createRunJobWorker({
|
|
|
8
8
|
// 5 jobs / minute, e.g. to stay under OpenAI rate limits.
|
|
9
9
|
limiter: { max: 5, duration: 60_000 },
|
|
10
10
|
},
|
|
11
|
-
run: async ({ gcsUrl, classId, worksheetId }) => {
|
|
12
|
-
const worksheet = await pdfToWorksheet(gcsUrl, classId, {
|
|
11
|
+
run: async ({ gcsUrl, classId, worksheetId }, ctx) => {
|
|
12
|
+
const worksheet = await pdfToWorksheet(gcsUrl, classId, {
|
|
13
|
+
worksheetId,
|
|
14
|
+
userId: ctx.userId,
|
|
15
|
+
});
|
|
13
16
|
return worksheet
|
|
14
17
|
? { worksheetId: worksheet.id, name: worksheet.name }
|
|
15
18
|
: null;
|
|
@@ -1,37 +1,86 @@
|
|
|
1
|
-
import { test, expect, describe } from 'vitest';
|
|
1
|
+
import { test, expect, describe, vi } from 'vitest';
|
|
2
2
|
import { user1Caller, user2Caller } from '../setup';
|
|
3
3
|
import { expectUnauth } from '../helpers';
|
|
4
|
+
import { sendNotification, sendToMultiple } from '../../../src/services/notification';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../../src/lib/pusher.js', async (importOriginal) => {
|
|
7
|
+
const original = await importOriginal<typeof import('../../../src/lib/pusher.js')>();
|
|
8
|
+
return {
|
|
9
|
+
...original,
|
|
10
|
+
pusher: {
|
|
11
|
+
...original.pusher,
|
|
12
|
+
trigger: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
triggerBatch: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
4
17
|
|
|
5
18
|
describe('Notification Router', () => {
|
|
6
19
|
let notificationId: string;
|
|
7
20
|
let user2Id: string;
|
|
8
21
|
|
|
9
|
-
test('
|
|
22
|
+
test('server-side emit creates a typed notification', async () => {
|
|
10
23
|
const user2Profile = await user2Caller.user.getProfile();
|
|
11
24
|
user2Id = user2Profile.id;
|
|
12
25
|
|
|
13
|
-
const notification = await
|
|
26
|
+
const notification = await sendNotification({
|
|
14
27
|
receiverId: user2Id,
|
|
15
28
|
title: 'Test Notification',
|
|
16
29
|
content: 'This is a test notification',
|
|
30
|
+
type: 'ANNOUNCEMENT',
|
|
31
|
+
entityType: 'ANNOUNCEMENT',
|
|
32
|
+
entityId: 'announcement-1',
|
|
33
|
+
classId: 'class-1',
|
|
17
34
|
});
|
|
18
35
|
|
|
19
36
|
expect(notification).toBeDefined();
|
|
20
37
|
expect(notification.title).toBe('Test Notification');
|
|
21
|
-
expect(notification.content).toBe('This is a test notification');
|
|
22
38
|
expect(notification.receiverId).toBe(user2Id);
|
|
39
|
+
expect(notification.type).toBe('ANNOUNCEMENT');
|
|
40
|
+
expect(notification.entityType).toBe('ANNOUNCEMENT');
|
|
41
|
+
expect(notification.entityId).toBe('announcement-1');
|
|
42
|
+
expect(notification.classId).toBe('class-1');
|
|
23
43
|
notificationId = notification.id;
|
|
24
44
|
});
|
|
25
45
|
|
|
26
|
-
test('
|
|
27
|
-
|
|
46
|
+
test('there is no public send endpoint', () => {
|
|
47
|
+
expect((user1Caller.notification as Record<string, unknown>).sendTo).toBeUndefined();
|
|
48
|
+
expect(
|
|
49
|
+
(user1Caller.notification as Record<string, unknown>).sendToMultiple
|
|
50
|
+
).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should list notifications for receiver with pagination shape', async () => {
|
|
54
|
+
const page = await user2Caller.notification.list({ limit: 50 });
|
|
28
55
|
|
|
29
|
-
expect(Array.isArray(
|
|
30
|
-
const found =
|
|
56
|
+
expect(Array.isArray(page.items)).toBe(true);
|
|
57
|
+
const found = page.items.find((n) => n.id === notificationId);
|
|
31
58
|
expect(found).toBeDefined();
|
|
32
59
|
expect(found!.title).toBe('Test Notification');
|
|
33
60
|
});
|
|
34
61
|
|
|
62
|
+
test('should paginate with cursor', async () => {
|
|
63
|
+
await sendToMultiple({
|
|
64
|
+
receiverIds: [user2Id, user2Id, user2Id],
|
|
65
|
+
title: 'Bulk Notification',
|
|
66
|
+
content: 'Sent in bulk',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const firstPage = await user2Caller.notification.list({ limit: 2 });
|
|
70
|
+
expect(firstPage.items.length).toBe(2);
|
|
71
|
+
expect(firstPage.nextCursor).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
const secondPage = await user2Caller.notification.list({
|
|
74
|
+
limit: 2,
|
|
75
|
+
cursor: firstPage.nextCursor,
|
|
76
|
+
});
|
|
77
|
+
expect(secondPage.items.length).toBeGreaterThan(0);
|
|
78
|
+
const firstIds = new Set(firstPage.items.map((n) => n.id));
|
|
79
|
+
for (const item of secondPage.items) {
|
|
80
|
+
expect(firstIds.has(item.id)).toBe(false);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
35
84
|
test('should get a single notification', async () => {
|
|
36
85
|
const notification = await user2Caller.notification.get({ id: notificationId });
|
|
37
86
|
|
|
@@ -40,13 +89,13 @@ describe('Notification Router', () => {
|
|
|
40
89
|
expect(notification!.title).toBe('Test Notification');
|
|
41
90
|
});
|
|
42
91
|
|
|
43
|
-
test(
|
|
92
|
+
test("should not get another user's notification", async () => {
|
|
44
93
|
await expect(
|
|
45
94
|
user1Caller.notification.get({ id: notificationId })
|
|
46
95
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
47
96
|
});
|
|
48
97
|
|
|
49
|
-
test(
|
|
98
|
+
test("should not mark another user's notification as read", async () => {
|
|
50
99
|
await expect(
|
|
51
100
|
user1Caller.notification.markAsRead({ id: notificationId })
|
|
52
101
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
@@ -58,21 +107,20 @@ describe('Notification Router', () => {
|
|
|
58
107
|
expect(notification.read).toBe(true);
|
|
59
108
|
});
|
|
60
109
|
|
|
61
|
-
test('should
|
|
62
|
-
const
|
|
110
|
+
test('should report unread count and mark all as read', async () => {
|
|
111
|
+
const before = await user2Caller.notification.unreadCount();
|
|
112
|
+
expect(before).toBeGreaterThan(0);
|
|
63
113
|
|
|
64
|
-
const result = await
|
|
65
|
-
|
|
66
|
-
title: 'Bulk Notification',
|
|
67
|
-
content: 'Sent to multiple users',
|
|
68
|
-
});
|
|
114
|
+
const result = await user2Caller.notification.markAllAsRead();
|
|
115
|
+
expect(result.count).toBe(before);
|
|
69
116
|
|
|
70
|
-
|
|
117
|
+
const after = await user2Caller.notification.unreadCount();
|
|
118
|
+
expect(after).toBe(0);
|
|
71
119
|
});
|
|
72
120
|
|
|
73
121
|
test('should not list notifications meant for other users', async () => {
|
|
74
|
-
const
|
|
75
|
-
const foreignNotif =
|
|
122
|
+
const page = await user1Caller.notification.list();
|
|
123
|
+
const foreignNotif = page.items.find((n) => n.id === notificationId);
|
|
76
124
|
expect(foreignNotif).toBeUndefined();
|
|
77
125
|
});
|
|
78
126
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
aggregateTopUsers,
|
|
4
|
+
aggregateUsage,
|
|
5
|
+
usageDayKey,
|
|
6
|
+
type UsageLogRow,
|
|
7
|
+
} from '../../../src/services/aiUsage.js';
|
|
8
|
+
|
|
9
|
+
function row(overrides: Partial<UsageLogRow> = {}): UsageLogRow {
|
|
10
|
+
return {
|
|
11
|
+
userId: 'user-1',
|
|
12
|
+
model: 'gpt-4o-mini',
|
|
13
|
+
operation: 'chat.completions',
|
|
14
|
+
promptTokens: 100,
|
|
15
|
+
completionTokens: 50,
|
|
16
|
+
totalTokens: 150,
|
|
17
|
+
createdAt: new Date('2026-07-01T10:00:00Z'),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('usageDayKey', () => {
|
|
23
|
+
it('returns the UTC calendar day', () => {
|
|
24
|
+
expect(usageDayKey(new Date('2026-07-01T23:59:59Z'))).toBe('2026-07-01');
|
|
25
|
+
expect(usageDayKey(new Date('2026-07-02T00:00:01Z'))).toBe('2026-07-02');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('aggregateUsage', () => {
|
|
30
|
+
it('returns empty aggregates for no rows', () => {
|
|
31
|
+
const result = aggregateUsage([]);
|
|
32
|
+
expect(result.totals).toEqual({
|
|
33
|
+
requests: 0,
|
|
34
|
+
promptTokens: 0,
|
|
35
|
+
completionTokens: 0,
|
|
36
|
+
totalTokens: 0,
|
|
37
|
+
});
|
|
38
|
+
expect(result.byDay).toEqual([]);
|
|
39
|
+
expect(result.byModel).toEqual([]);
|
|
40
|
+
expect(result.byOperation).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('sums totals across rows and treats null token counts as zero', () => {
|
|
44
|
+
const result = aggregateUsage([
|
|
45
|
+
row(),
|
|
46
|
+
row({ promptTokens: null, completionTokens: null, totalTokens: null }),
|
|
47
|
+
]);
|
|
48
|
+
expect(result.totals).toEqual({
|
|
49
|
+
requests: 2,
|
|
50
|
+
promptTokens: 100,
|
|
51
|
+
completionTokens: 50,
|
|
52
|
+
totalTokens: 150,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('buckets by UTC day in ascending order', () => {
|
|
57
|
+
const result = aggregateUsage([
|
|
58
|
+
row({ createdAt: new Date('2026-07-02T01:00:00Z'), totalTokens: 20 }),
|
|
59
|
+
row({ createdAt: new Date('2026-07-01T23:00:00Z'), totalTokens: 10 }),
|
|
60
|
+
row({ createdAt: new Date('2026-07-02T12:00:00Z'), totalTokens: 30 }),
|
|
61
|
+
]);
|
|
62
|
+
expect(result.byDay.map((d) => d.day)).toEqual([
|
|
63
|
+
'2026-07-01',
|
|
64
|
+
'2026-07-02',
|
|
65
|
+
]);
|
|
66
|
+
expect(result.byDay[0]).toMatchObject({ requests: 1, totalTokens: 10 });
|
|
67
|
+
expect(result.byDay[1]).toMatchObject({ requests: 2, totalTokens: 50 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('buckets by model and operation sorted by total tokens descending', () => {
|
|
71
|
+
const result = aggregateUsage([
|
|
72
|
+
row({ model: 'gpt-4o', operation: 'responses.parse', totalTokens: 500 }),
|
|
73
|
+
row({ model: 'gpt-4o-mini', operation: 'stream.text', totalTokens: 100 }),
|
|
74
|
+
row({ model: 'gpt-4o-mini', operation: 'stream.text', totalTokens: 100 }),
|
|
75
|
+
]);
|
|
76
|
+
expect(result.byModel.map((m) => m.model)).toEqual([
|
|
77
|
+
'gpt-4o',
|
|
78
|
+
'gpt-4o-mini',
|
|
79
|
+
]);
|
|
80
|
+
expect(result.byModel[1]).toMatchObject({ requests: 2, totalTokens: 200 });
|
|
81
|
+
expect(result.byOperation.map((o) => o.operation)).toEqual([
|
|
82
|
+
'responses.parse',
|
|
83
|
+
'stream.text',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('aggregateTopUsers', () => {
|
|
89
|
+
it('ranks users by total tokens descending and applies the limit', () => {
|
|
90
|
+
const rows = [
|
|
91
|
+
row({ userId: 'a', totalTokens: 100 }),
|
|
92
|
+
row({ userId: 'b', totalTokens: 300 }),
|
|
93
|
+
row({ userId: 'a', totalTokens: 250 }),
|
|
94
|
+
row({ userId: 'c', totalTokens: 50 }),
|
|
95
|
+
];
|
|
96
|
+
const top = aggregateTopUsers(rows, 2);
|
|
97
|
+
expect(top.map((u) => u.userId)).toEqual(['a', 'b']);
|
|
98
|
+
expect(top[0]).toMatchObject({ requests: 2, totalTokens: 350 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('skips rows without a userId', () => {
|
|
102
|
+
const top = aggregateTopUsers([row({ userId: null })], 10);
|
|
103
|
+
expect(top).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|