@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
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
|
+
});
|