@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
|
@@ -16,6 +16,7 @@ import { openai } from '@ai-sdk/openai';
|
|
|
16
16
|
import { getRedis } from '../lib/redis.js';
|
|
17
17
|
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
18
18
|
import { logger } from '../utils/logger.js';
|
|
19
|
+
import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
|
|
19
20
|
|
|
20
21
|
const STREAM_REDIS_TTL = 600; // 10 minutes
|
|
21
22
|
const STREAM_KEY_PREFIX = 'stream:';
|
|
@@ -179,6 +180,10 @@ export interface StreamChatOptions {
|
|
|
179
180
|
maxOutputTokens?: number;
|
|
180
181
|
/** Max agent steps (tool round-trips). Defaults to 6. */
|
|
181
182
|
maxSteps?: number;
|
|
183
|
+
/** Reason recorded on the AiUsageLog row. Defaults to 'chat_stream'. */
|
|
184
|
+
usageReason?: string;
|
|
185
|
+
/** Who/what this usage is attributed to (user / school / class). */
|
|
186
|
+
attribution?: AiUsageAttribution;
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
export interface StreamChatResult {
|
|
@@ -207,6 +212,8 @@ export async function streamChatResponse(
|
|
|
207
212
|
temperature = 0.7,
|
|
208
213
|
maxOutputTokens = 4096,
|
|
209
214
|
maxSteps = 6,
|
|
215
|
+
usageReason = 'chat_stream',
|
|
216
|
+
attribution,
|
|
210
217
|
} = options;
|
|
211
218
|
|
|
212
219
|
const redis = getRedis();
|
|
@@ -332,7 +339,35 @@ export async function streamChatResponse(
|
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
|
|
342
|
+
const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
|
|
343
|
+
await logInferenceUsage({
|
|
344
|
+
model,
|
|
345
|
+
operation: 'stream.text',
|
|
346
|
+
success: true,
|
|
347
|
+
estimatedInputTokens: 0,
|
|
348
|
+
reason: usageReason,
|
|
349
|
+
usage: usage
|
|
350
|
+
? {
|
|
351
|
+
promptTokens: usage.inputTokens,
|
|
352
|
+
completionTokens: usage.outputTokens,
|
|
353
|
+
totalTokens: usage.totalTokens,
|
|
354
|
+
}
|
|
355
|
+
: undefined,
|
|
356
|
+
attribution,
|
|
357
|
+
});
|
|
358
|
+
|
|
335
359
|
return { text: fullText, toolCalls, parts };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
await logInferenceUsage({
|
|
362
|
+
model,
|
|
363
|
+
operation: 'stream.text',
|
|
364
|
+
success: false,
|
|
365
|
+
estimatedInputTokens: 0,
|
|
366
|
+
reason: usageReason,
|
|
367
|
+
error,
|
|
368
|
+
attribution,
|
|
369
|
+
});
|
|
370
|
+
throw error;
|
|
336
371
|
} finally {
|
|
337
372
|
if (redis) {
|
|
338
373
|
await redis.del(streamKey).catch(() => {});
|
package/src/services/aiStream.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getRedis } from '../lib/redis.js';
|
|
|
8
8
|
import { prisma } from '../lib/prisma.js';
|
|
9
9
|
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
10
10
|
import { getAIUserId, ensureAIUserExists } from '../utils/aiUser.js';
|
|
11
|
+
import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
|
|
11
12
|
import { logger } from '../utils/logger.js';
|
|
12
13
|
import { JobStatus } from '@prisma/client';
|
|
13
14
|
import { aiTools, type ToolCallResult } from './aiTools.js';
|
|
@@ -25,6 +26,10 @@ export interface StreamOptions {
|
|
|
25
26
|
model?: string;
|
|
26
27
|
temperature?: number;
|
|
27
28
|
maxOutputTokens?: number;
|
|
29
|
+
/** Reason recorded on the AiUsageLog row. Defaults to 'ai_stream'. */
|
|
30
|
+
usageReason?: string;
|
|
31
|
+
/** Who/what this usage is attributed to (user / school / class). */
|
|
32
|
+
attribution?: AiUsageAttribution;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -42,6 +47,8 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
42
47
|
model = 'gpt-4o-mini',
|
|
43
48
|
temperature = 0.7,
|
|
44
49
|
maxOutputTokens = 4096,
|
|
50
|
+
usageReason = 'ai_stream',
|
|
51
|
+
attribution,
|
|
45
52
|
} = options;
|
|
46
53
|
|
|
47
54
|
await ensureAIUserExists();
|
|
@@ -146,6 +153,23 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
146
153
|
attachments: [],
|
|
147
154
|
});
|
|
148
155
|
|
|
156
|
+
const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
|
|
157
|
+
await logInferenceUsage({
|
|
158
|
+
model,
|
|
159
|
+
operation: 'stream.text',
|
|
160
|
+
success: true,
|
|
161
|
+
estimatedInputTokens: 0,
|
|
162
|
+
reason: usageReason,
|
|
163
|
+
usage: usage
|
|
164
|
+
? {
|
|
165
|
+
promptTokens: usage.inputTokens,
|
|
166
|
+
completionTokens: usage.outputTokens,
|
|
167
|
+
totalTokens: usage.totalTokens,
|
|
168
|
+
}
|
|
169
|
+
: undefined,
|
|
170
|
+
attribution: attribution ?? { classId },
|
|
171
|
+
});
|
|
172
|
+
|
|
149
173
|
logger.info('AI stream completed', {
|
|
150
174
|
aiMessageId,
|
|
151
175
|
conversationId,
|
|
@@ -155,6 +179,16 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
155
179
|
} catch (error) {
|
|
156
180
|
logger.error('AI stream failed', { error, aiMessageId, conversationId });
|
|
157
181
|
|
|
182
|
+
await logInferenceUsage({
|
|
183
|
+
model,
|
|
184
|
+
operation: 'stream.text',
|
|
185
|
+
success: false,
|
|
186
|
+
estimatedInputTokens: 0,
|
|
187
|
+
reason: usageReason,
|
|
188
|
+
error,
|
|
189
|
+
attribution: attribution ?? { classId },
|
|
190
|
+
});
|
|
191
|
+
|
|
158
192
|
await prisma.message.update({
|
|
159
193
|
where: { id: aiMessageId },
|
|
160
194
|
data: { status: JobStatus.FAILED },
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { prisma } from "../lib/prisma.js";
|
|
3
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
4
|
+
|
|
5
|
+
export interface UsageLogRow {
|
|
6
|
+
userId: string | null;
|
|
7
|
+
model: string;
|
|
8
|
+
operation: string;
|
|
9
|
+
totalTokens: number | null;
|
|
10
|
+
promptTokens: number | null;
|
|
11
|
+
completionTokens: number | null;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UsageBucket {
|
|
16
|
+
requests: number;
|
|
17
|
+
promptTokens: number;
|
|
18
|
+
completionTokens: number;
|
|
19
|
+
totalTokens: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UsageAggregation {
|
|
23
|
+
totals: UsageBucket;
|
|
24
|
+
byDay: Array<{ day: string } & UsageBucket>;
|
|
25
|
+
byModel: Array<{ model: string } & UsageBucket>;
|
|
26
|
+
byOperation: Array<{ operation: string } & UsageBucket>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const emptyBucket = (): UsageBucket => ({
|
|
30
|
+
requests: 0,
|
|
31
|
+
promptTokens: 0,
|
|
32
|
+
completionTokens: 0,
|
|
33
|
+
totalTokens: 0,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function addToBucket(bucket: UsageBucket, row: UsageLogRow) {
|
|
37
|
+
bucket.requests += 1;
|
|
38
|
+
bucket.promptTokens += row.promptTokens ?? 0;
|
|
39
|
+
bucket.completionTokens += row.completionTokens ?? 0;
|
|
40
|
+
bucket.totalTokens += row.totalTokens ?? 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** UTC calendar day (YYYY-MM-DD) for a timestamp. */
|
|
44
|
+
export function usageDayKey(date: Date): string {
|
|
45
|
+
return date.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Aggregate raw usage rows into totals and by-day / by-model / by-operation buckets. */
|
|
49
|
+
export function aggregateUsage(rows: UsageLogRow[]): UsageAggregation {
|
|
50
|
+
const totals = emptyBucket();
|
|
51
|
+
const byDay = new Map<string, UsageBucket>();
|
|
52
|
+
const byModel = new Map<string, UsageBucket>();
|
|
53
|
+
const byOperation = new Map<string, UsageBucket>();
|
|
54
|
+
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
addToBucket(totals, row);
|
|
57
|
+
|
|
58
|
+
const dayKey = usageDayKey(row.createdAt);
|
|
59
|
+
for (const [map, key] of [
|
|
60
|
+
[byDay, dayKey],
|
|
61
|
+
[byModel, row.model],
|
|
62
|
+
[byOperation, row.operation],
|
|
63
|
+
] as const) {
|
|
64
|
+
let bucket = map.get(key);
|
|
65
|
+
if (!bucket) {
|
|
66
|
+
bucket = emptyBucket();
|
|
67
|
+
map.set(key, bucket);
|
|
68
|
+
}
|
|
69
|
+
addToBucket(bucket, row);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
totals,
|
|
75
|
+
byDay: [...byDay.entries()]
|
|
76
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
77
|
+
.map(([day, bucket]) => ({ day, ...bucket })),
|
|
78
|
+
byModel: [...byModel.entries()]
|
|
79
|
+
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
|
80
|
+
.map(([model, bucket]) => ({ model, ...bucket })),
|
|
81
|
+
byOperation: [...byOperation.entries()]
|
|
82
|
+
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
|
83
|
+
.map(([operation, bucket]) => ({ operation, ...bucket })),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rank users by total token usage, descending. Rows without a userId are skipped. */
|
|
88
|
+
export function aggregateTopUsers(
|
|
89
|
+
rows: UsageLogRow[],
|
|
90
|
+
limit: number,
|
|
91
|
+
): Array<{ userId: string } & UsageBucket> {
|
|
92
|
+
const byUser = new Map<string, UsageBucket>();
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
if (!row.userId) continue;
|
|
95
|
+
let bucket = byUser.get(row.userId);
|
|
96
|
+
if (!bucket) {
|
|
97
|
+
bucket = emptyBucket();
|
|
98
|
+
byUser.set(row.userId, bucket);
|
|
99
|
+
}
|
|
100
|
+
addToBucket(bucket, row);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [...byUser.entries()]
|
|
104
|
+
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
|
105
|
+
.slice(0, limit)
|
|
106
|
+
.map(([userId, bucket]) => ({ userId, ...bucket }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const USAGE_ROW_SELECT = {
|
|
110
|
+
userId: true,
|
|
111
|
+
model: true,
|
|
112
|
+
operation: true,
|
|
113
|
+
totalTokens: true,
|
|
114
|
+
promptTokens: true,
|
|
115
|
+
completionTokens: true,
|
|
116
|
+
createdAt: true,
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
function createdAtRange(startDate?: Date, endDate?: Date) {
|
|
120
|
+
if (!startDate && !endDate) return undefined;
|
|
121
|
+
return {
|
|
122
|
+
...(startDate ? { gte: startDate } : {}),
|
|
123
|
+
...(endDate ? { lte: endDate } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-school aggregate AI usage over a date range. School admins only.
|
|
129
|
+
*/
|
|
130
|
+
export async function getSchoolAiUsage(
|
|
131
|
+
userId: string,
|
|
132
|
+
input: {
|
|
133
|
+
schoolId: string;
|
|
134
|
+
startDate?: Date;
|
|
135
|
+
endDate?: Date;
|
|
136
|
+
topUsersLimit?: number;
|
|
137
|
+
},
|
|
138
|
+
) {
|
|
139
|
+
const membership = await findActiveSchoolAdminMembership(
|
|
140
|
+
input.schoolId,
|
|
141
|
+
userId,
|
|
142
|
+
);
|
|
143
|
+
if (!membership) {
|
|
144
|
+
throw new TRPCError({
|
|
145
|
+
code: "FORBIDDEN",
|
|
146
|
+
message: "Only school admins can view school AI usage",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rows = await prisma.aiUsageLog.findMany({
|
|
151
|
+
where: {
|
|
152
|
+
schoolId: input.schoolId,
|
|
153
|
+
...(createdAtRange(input.startDate, input.endDate)
|
|
154
|
+
? { createdAt: createdAtRange(input.startDate, input.endDate) }
|
|
155
|
+
: {}),
|
|
156
|
+
},
|
|
157
|
+
select: USAGE_ROW_SELECT,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const topUsers = aggregateTopUsers(rows, input.topUsersLimit ?? 10);
|
|
161
|
+
const userIds = topUsers.map((entry) => entry.userId);
|
|
162
|
+
const users = userIds.length
|
|
163
|
+
? await prisma.user.findMany({
|
|
164
|
+
where: { id: { in: userIds } },
|
|
165
|
+
select: {
|
|
166
|
+
id: true,
|
|
167
|
+
username: true,
|
|
168
|
+
profile: { select: { displayName: true } },
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
: [];
|
|
172
|
+
const userById = new Map(users.map((user) => [user.id, user]));
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
...aggregateUsage(rows),
|
|
176
|
+
topUsers: topUsers.map((entry) => ({
|
|
177
|
+
...entry,
|
|
178
|
+
username: userById.get(entry.userId)?.username ?? null,
|
|
179
|
+
displayName: userById.get(entry.userId)?.profile?.displayName ?? null,
|
|
180
|
+
})),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The calling user's own AI usage over a date range.
|
|
186
|
+
*/
|
|
187
|
+
export async function getMyAiUsage(
|
|
188
|
+
userId: string,
|
|
189
|
+
input: { startDate?: Date; endDate?: Date } = {},
|
|
190
|
+
) {
|
|
191
|
+
const rows = await prisma.aiUsageLog.findMany({
|
|
192
|
+
where: {
|
|
193
|
+
userId,
|
|
194
|
+
...(createdAtRange(input.startDate, input.endDate)
|
|
195
|
+
? { createdAt: createdAtRange(input.startDate, input.endDate) }
|
|
196
|
+
: {}),
|
|
197
|
+
},
|
|
198
|
+
select: USAGE_ROW_SELECT,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return aggregateUsage(rows);
|
|
202
|
+
}
|
|
@@ -124,6 +124,11 @@ export async function createAnnouncementRecord(
|
|
|
124
124
|
receiverIds: classData.students.map((s) => s.id),
|
|
125
125
|
title: `New announcement in ${classData.name}`,
|
|
126
126
|
content: `${remarks}\n\n[View announcement](/class/${classId})`,
|
|
127
|
+
senderId: userId,
|
|
128
|
+
type: "ANNOUNCEMENT",
|
|
129
|
+
entityType: "ANNOUNCEMENT",
|
|
130
|
+
entityId: announcement.id,
|
|
131
|
+
classId,
|
|
127
132
|
}).catch((error) => {
|
|
128
133
|
logger.error("Failed to send announcement notifications:", error);
|
|
129
134
|
});
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "../models/assignment.js";
|
|
24
24
|
import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
|
|
25
25
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
26
|
-
import { sendToMultiple } from "./notification.js";
|
|
26
|
+
import { sendNotification, sendToMultiple } from "./notification.js";
|
|
27
27
|
import { enqueueRunJob } from "../workers/queues.js";
|
|
28
28
|
import { logger } from "../utils/logger.js";
|
|
29
29
|
import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
|
|
@@ -317,6 +317,11 @@ export async function createAssignmentRecord(
|
|
|
317
317
|
receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
|
|
318
318
|
title: `New assignment in ${classData.name}`,
|
|
319
319
|
content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
|
|
320
|
+
senderId: userId,
|
|
321
|
+
type: "ASSIGNMENT_POSTED",
|
|
322
|
+
entityType: "ASSIGNMENT",
|
|
323
|
+
entityId: assignment.id,
|
|
324
|
+
classId,
|
|
320
325
|
}).catch((error) => {
|
|
321
326
|
logger.error("Failed to send assignment notifications:", error);
|
|
322
327
|
});
|
|
@@ -607,6 +612,11 @@ export async function updateAssignmentRecord(
|
|
|
607
612
|
receiverIds,
|
|
608
613
|
title: `New assignment in ${classData.name}`,
|
|
609
614
|
content: `"${updatedAssignment.title}" is due ${new Date(updatedAssignment.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classData.id}/assignments/${updatedAssignment.id})`,
|
|
615
|
+
senderId: userId,
|
|
616
|
+
type: "ASSIGNMENT_POSTED",
|
|
617
|
+
entityType: "ASSIGNMENT",
|
|
618
|
+
entityId: updatedAssignment.id,
|
|
619
|
+
classId: classData.id,
|
|
610
620
|
}).catch((error) => {
|
|
611
621
|
logger.error("Failed to send assignment publish notifications:", error);
|
|
612
622
|
});
|
|
@@ -877,11 +887,28 @@ export async function updateSubmissionAsTeacherRecord(
|
|
|
877
887
|
}
|
|
878
888
|
|
|
879
889
|
if (returnSubmission !== undefined) {
|
|
880
|
-
|
|
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
|
}
|