@studious-lms/server 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/index.js +8 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/pusher.d.ts +3 -1
  4. package/dist/lib/pusher.d.ts.map +1 -1
  5. package/dist/lib/pusher.js +5 -3
  6. package/dist/lib/pusher.js.map +1 -1
  7. package/dist/middleware/security.d.ts.map +1 -1
  8. package/dist/middleware/security.js +4 -4
  9. package/dist/middleware/security.js.map +1 -1
  10. package/dist/models/class.d.ts +1 -1
  11. package/dist/models/class.d.ts.map +1 -1
  12. package/dist/models/class.js +5 -4
  13. package/dist/models/class.js.map +1 -1
  14. package/dist/models/conversation.d.ts +17 -17
  15. package/dist/models/notification.d.ts +63 -11
  16. package/dist/models/notification.d.ts.map +1 -1
  17. package/dist/models/notification.js +24 -10
  18. package/dist/models/notification.js.map +1 -1
  19. package/dist/pipelines/aiLabChat.d.ts +1 -0
  20. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  21. package/dist/pipelines/aiLabChat.js +7 -2
  22. package/dist/pipelines/aiLabChat.js.map +1 -1
  23. package/dist/pipelines/aiNewtonChat.d.ts +2 -2
  24. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  25. package/dist/pipelines/aiNewtonChat.js +12 -4
  26. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  27. package/dist/pipelines/gradeWorksheet.d.ts +3 -3
  28. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  29. package/dist/pipelines/gradeWorksheet.js +13 -9
  30. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  31. package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
  32. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  33. package/dist/pipelines/pdfToWorksheet.js +3 -2
  34. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  35. package/dist/routers/_app.d.ts +174 -82
  36. package/dist/routers/_app.d.ts.map +1 -1
  37. package/dist/routers/announcement.d.ts.map +1 -1
  38. package/dist/routers/announcement.js +2 -2
  39. package/dist/routers/announcement.js.map +1 -1
  40. package/dist/routers/assignment.d.ts.map +1 -1
  41. package/dist/routers/assignment.js +12 -2
  42. package/dist/routers/assignment.js.map +1 -1
  43. package/dist/routers/notifications.d.ts +51 -41
  44. package/dist/routers/notifications.d.ts.map +1 -1
  45. package/dist/routers/notifications.js +22 -18
  46. package/dist/routers/notifications.js.map +1 -1
  47. package/dist/routers/school.d.ts +30 -0
  48. package/dist/routers/school.d.ts.map +1 -1
  49. package/dist/routers/school.js +11 -2
  50. package/dist/routers/school.js.map +1 -1
  51. package/dist/routers/user.d.ts +12 -0
  52. package/dist/routers/user.d.ts.map +1 -1
  53. package/dist/routers/user.js +11 -2
  54. package/dist/routers/user.js.map +1 -1
  55. package/dist/seedDatabase.d.ts +4 -0
  56. package/dist/seedDatabase.d.ts.map +1 -1
  57. package/dist/services/aiChatStream.d.ts +5 -0
  58. package/dist/services/aiChatStream.d.ts.map +1 -1
  59. package/dist/services/aiChatStream.js +32 -3
  60. package/dist/services/aiChatStream.js.map +1 -1
  61. package/dist/services/aiStream.d.ts +5 -0
  62. package/dist/services/aiStream.d.ts.map +1 -1
  63. package/dist/services/aiStream.js +29 -3
  64. package/dist/services/aiStream.js.map +1 -1
  65. package/dist/services/aiUsage.d.ts +72 -0
  66. package/dist/services/aiUsage.d.ts.map +1 -0
  67. package/dist/services/aiUsage.js +150 -0
  68. package/dist/services/aiUsage.js.map +1 -0
  69. package/dist/services/announcement.d.ts.map +1 -1
  70. package/dist/services/announcement.js +7 -2
  71. package/dist/services/announcement.js.map +1 -1
  72. package/dist/services/assignment.d.ts.map +1 -1
  73. package/dist/services/assignment.js +29 -4
  74. package/dist/services/assignment.js.map +1 -1
  75. package/dist/services/attendance.js +10 -2
  76. package/dist/services/attendance.js.map +1 -1
  77. package/dist/services/class.d.ts.map +1 -1
  78. package/dist/services/class.js +6 -2
  79. package/dist/services/class.js.map +1 -1
  80. package/dist/services/discussion.d.ts.map +1 -1
  81. package/dist/services/discussion.js +41 -12
  82. package/dist/services/discussion.js.map +1 -1
  83. package/dist/services/discussionSummary.d.ts.map +1 -1
  84. package/dist/services/discussionSummary.js +3 -2
  85. package/dist/services/discussionSummary.js.map +1 -1
  86. package/dist/services/notification.d.ts +79 -16
  87. package/dist/services/notification.d.ts.map +1 -1
  88. package/dist/services/notification.js +64 -16
  89. package/dist/services/notification.js.map +1 -1
  90. package/dist/services/studentProgress.d.ts.map +1 -1
  91. package/dist/services/studentProgress.js +6 -2
  92. package/dist/services/studentProgress.js.map +1 -1
  93. package/dist/services/userPrivacy.d.ts +4 -0
  94. package/dist/services/userPrivacy.d.ts.map +1 -1
  95. package/dist/services/userPrivacy.js +6 -2
  96. package/dist/services/userPrivacy.js.map +1 -1
  97. package/dist/services/worksheet.js +4 -4
  98. package/dist/services/worksheet.js.map +1 -1
  99. package/dist/utils/inference.d.ts +27 -0
  100. package/dist/utils/inference.d.ts.map +1 -1
  101. package/dist/utils/inference.js +23 -3
  102. package/dist/utils/inference.js.map +1 -1
  103. package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
  104. package/dist/workers/processors/aiLabChat.js +4 -3
  105. package/dist/workers/processors/aiLabChat.js.map +1 -1
  106. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  107. package/dist/workers/processors/aiNewtonChat.js +5 -5
  108. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  109. package/dist/workers/processors/gradeWorksheet.js +4 -4
  110. package/dist/workers/processors/gradeWorksheet.js.map +1 -1
  111. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  112. package/dist/workers/processors/pdfToWorksheet.js +7 -4
  113. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  114. package/package.json +1 -1
  115. package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
  116. package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
  117. package/prisma/schema.prisma +29 -5
  118. package/src/index.ts +7 -0
  119. package/src/lib/pusher.ts +3 -1
  120. package/src/middleware/security.ts +2 -2
  121. package/src/models/class.ts +3 -2
  122. package/src/models/notification.ts +42 -13
  123. package/src/pipelines/aiLabChat.ts +6 -1
  124. package/src/pipelines/aiNewtonChat.ts +10 -0
  125. package/src/pipelines/gradeWorksheet.ts +25 -7
  126. package/src/pipelines/pdfToWorksheet.ts +3 -0
  127. package/src/routers/announcement.ts +0 -1
  128. package/src/routers/assignment.ts +10 -0
  129. package/src/routers/notifications.ts +26 -24
  130. package/src/routers/school.ts +12 -0
  131. package/src/routers/user.ts +12 -0
  132. package/src/services/aiChatStream.ts +35 -0
  133. package/src/services/aiStream.ts +34 -0
  134. package/src/services/aiUsage.ts +202 -0
  135. package/src/services/announcement.ts +5 -0
  136. package/src/services/assignment.ts +29 -2
  137. package/src/services/attendance.ts +12 -0
  138. package/src/services/class.ts +4 -0
  139. package/src/services/discussion.ts +50 -11
  140. package/src/services/discussionSummary.ts +1 -0
  141. package/src/services/notification.ts +83 -20
  142. package/src/services/studentProgress.ts +4 -0
  143. package/src/services/userPrivacy.ts +4 -0
  144. package/src/services/worksheet.ts +2 -2
  145. package/src/utils/inference.ts +40 -2
  146. package/src/workers/processors/aiLabChat.ts +2 -1
  147. package/src/workers/processors/aiNewtonChat.ts +3 -1
  148. package/src/workers/processors/gradeWorksheet.ts +2 -2
  149. package/src/workers/processors/pdfToWorksheet.ts +5 -2
  150. package/tests/integration/routers/notification.test.ts +68 -20
  151. package/tests/unit/services/aiUsage.test.ts +105 -0
@@ -1,18 +1,63 @@
1
1
  /**
2
- * Notification service – list, send, and mark notifications as read.
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
- findNotificationsByReceiverId,
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
- /** List notifications for a user. */
14
- export async function listNotifications(userId: string) {
15
- return findNotificationsByReceiverId(userId);
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
- /** Send a notification to one user. */
33
- export async function sendNotification(input: {
34
- receiverId: string;
35
- title: string;
36
- content: string;
37
- }) {
38
- return createNotification(input);
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
- /** Send the same notification to multiple users. */
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 result = await createManyNotifications(
48
- input.receiverIds.map((receiverId) => ({
49
- receiverId,
50
- title: input.title,
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
- return result;
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");
@@ -121,6 +121,10 @@ export async function exportUserData(userId: string) {
121
121
  content: true,
122
122
  read: true,
123
123
  createdAt: true,
124
+ type: true,
125
+ entityType: true,
126
+ entityId: true,
127
+ classId: true,
124
128
  },
125
129
  take: 200,
126
130
  orderBy: { createdAt: "desc" },
@@ -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(
@@ -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 logInferenceUsage({
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, { worksheetId });
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('should send notification to another user', async () => {
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 user1Caller.notification.sendTo({
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('should list notifications for receiver', async () => {
27
- const notifications = await user2Caller.notification.list();
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(notifications)).toBe(true);
30
- const found = notifications.find((n) => n.id === notificationId);
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('should not get another user\'s notification', async () => {
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('should not mark another user\'s notification as read', async () => {
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 send to multiple users', async () => {
62
- const user1Profile = await user1Caller.user.getProfile();
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 user1Caller.notification.sendToMultiple({
65
- receiverIds: [user2Id, user1Profile.id],
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
- expect(result.count).toBe(2);
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 notifications = await user1Caller.notification.list();
75
- const foreignNotif = notifications.find((n) => n.id === notificationId);
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
+ });