@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.
Files changed (155) hide show
  1. package/Dockerfile +5 -2
  2. package/dist/index.js +8 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/pusher.d.ts +3 -1
  5. package/dist/lib/pusher.d.ts.map +1 -1
  6. package/dist/lib/pusher.js +5 -3
  7. package/dist/lib/pusher.js.map +1 -1
  8. package/dist/middleware/security.d.ts.map +1 -1
  9. package/dist/middleware/security.js +4 -4
  10. package/dist/middleware/security.js.map +1 -1
  11. package/dist/models/class.d.ts +1 -1
  12. package/dist/models/class.d.ts.map +1 -1
  13. package/dist/models/class.js +5 -4
  14. package/dist/models/class.js.map +1 -1
  15. package/dist/models/conversation.d.ts +17 -17
  16. package/dist/models/notification.d.ts +63 -11
  17. package/dist/models/notification.d.ts.map +1 -1
  18. package/dist/models/notification.js +24 -10
  19. package/dist/models/notification.js.map +1 -1
  20. package/dist/pipelines/aiLabChat.d.ts +1 -0
  21. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  22. package/dist/pipelines/aiLabChat.js +7 -2
  23. package/dist/pipelines/aiLabChat.js.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.d.ts +2 -2
  25. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  26. package/dist/pipelines/aiNewtonChat.js +12 -4
  27. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  28. package/dist/pipelines/gradeWorksheet.d.ts +3 -3
  29. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  30. package/dist/pipelines/gradeWorksheet.js +13 -9
  31. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  32. package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
  33. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  34. package/dist/pipelines/pdfToWorksheet.js +3 -2
  35. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  36. package/dist/routers/_app.d.ts +322 -82
  37. package/dist/routers/_app.d.ts.map +1 -1
  38. package/dist/routers/announcement.d.ts.map +1 -1
  39. package/dist/routers/announcement.js +2 -2
  40. package/dist/routers/announcement.js.map +1 -1
  41. package/dist/routers/assignment.d.ts +74 -0
  42. package/dist/routers/assignment.d.ts.map +1 -1
  43. package/dist/routers/assignment.js +190 -2
  44. package/dist/routers/assignment.js.map +1 -1
  45. package/dist/routers/notifications.d.ts +51 -41
  46. package/dist/routers/notifications.d.ts.map +1 -1
  47. package/dist/routers/notifications.js +22 -18
  48. package/dist/routers/notifications.js.map +1 -1
  49. package/dist/routers/school.d.ts +30 -0
  50. package/dist/routers/school.d.ts.map +1 -1
  51. package/dist/routers/school.js +11 -2
  52. package/dist/routers/school.js.map +1 -1
  53. package/dist/routers/user.d.ts +12 -0
  54. package/dist/routers/user.d.ts.map +1 -1
  55. package/dist/routers/user.js +11 -2
  56. package/dist/routers/user.js.map +1 -1
  57. package/dist/seedDatabase.d.ts +4 -0
  58. package/dist/seedDatabase.d.ts.map +1 -1
  59. package/dist/services/aiChatStream.d.ts +5 -0
  60. package/dist/services/aiChatStream.d.ts.map +1 -1
  61. package/dist/services/aiChatStream.js +32 -3
  62. package/dist/services/aiChatStream.js.map +1 -1
  63. package/dist/services/aiStream.d.ts +5 -0
  64. package/dist/services/aiStream.d.ts.map +1 -1
  65. package/dist/services/aiStream.js +29 -3
  66. package/dist/services/aiStream.js.map +1 -1
  67. package/dist/services/aiUsage.d.ts +72 -0
  68. package/dist/services/aiUsage.d.ts.map +1 -0
  69. package/dist/services/aiUsage.js +150 -0
  70. package/dist/services/aiUsage.js.map +1 -0
  71. package/dist/services/announcement.d.ts.map +1 -1
  72. package/dist/services/announcement.js +7 -2
  73. package/dist/services/announcement.js.map +1 -1
  74. package/dist/services/assignment.d.ts.map +1 -1
  75. package/dist/services/assignment.js +29 -4
  76. package/dist/services/assignment.js.map +1 -1
  77. package/dist/services/attendance.js +10 -2
  78. package/dist/services/attendance.js.map +1 -1
  79. package/dist/services/class.d.ts.map +1 -1
  80. package/dist/services/class.js +6 -2
  81. package/dist/services/class.js.map +1 -1
  82. package/dist/services/discussion.d.ts.map +1 -1
  83. package/dist/services/discussion.js +41 -12
  84. package/dist/services/discussion.js.map +1 -1
  85. package/dist/services/discussionSummary.d.ts.map +1 -1
  86. package/dist/services/discussionSummary.js +3 -2
  87. package/dist/services/discussionSummary.js.map +1 -1
  88. package/dist/services/notification.d.ts +79 -16
  89. package/dist/services/notification.d.ts.map +1 -1
  90. package/dist/services/notification.js +64 -16
  91. package/dist/services/notification.js.map +1 -1
  92. package/dist/services/studentProgress.d.ts.map +1 -1
  93. package/dist/services/studentProgress.js +6 -2
  94. package/dist/services/studentProgress.js.map +1 -1
  95. package/dist/services/userPrivacy.d.ts +4 -0
  96. package/dist/services/userPrivacy.d.ts.map +1 -1
  97. package/dist/services/userPrivacy.js +6 -2
  98. package/dist/services/userPrivacy.js.map +1 -1
  99. package/dist/services/worksheet.js +4 -4
  100. package/dist/services/worksheet.js.map +1 -1
  101. package/dist/utils/inference.d.ts +27 -0
  102. package/dist/utils/inference.d.ts.map +1 -1
  103. package/dist/utils/inference.js +23 -3
  104. package/dist/utils/inference.js.map +1 -1
  105. package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
  106. package/dist/workers/processors/aiLabChat.js +4 -3
  107. package/dist/workers/processors/aiLabChat.js.map +1 -1
  108. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  109. package/dist/workers/processors/aiNewtonChat.js +5 -5
  110. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  111. package/dist/workers/processors/gradeWorksheet.js +4 -4
  112. package/dist/workers/processors/gradeWorksheet.js.map +1 -1
  113. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  114. package/dist/workers/processors/pdfToWorksheet.js +7 -4
  115. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  116. package/docker-entrypoint.sh +7 -0
  117. package/package.json +1 -1
  118. package/prisma/migrations/20260708011456_add_file_annotations/migration.sql +35 -0
  119. package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
  120. package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
  121. package/prisma/schema.prisma +99 -43
  122. package/src/index.ts +7 -0
  123. package/src/lib/pusher.ts +3 -1
  124. package/src/middleware/security.ts +2 -2
  125. package/src/models/class.ts +3 -2
  126. package/src/models/notification.ts +42 -13
  127. package/src/pipelines/aiLabChat.ts +6 -1
  128. package/src/pipelines/aiNewtonChat.ts +10 -0
  129. package/src/pipelines/gradeWorksheet.ts +25 -7
  130. package/src/pipelines/pdfToWorksheet.ts +3 -0
  131. package/src/routers/announcement.ts +0 -1
  132. package/src/routers/assignment.ts +214 -0
  133. package/src/routers/notifications.ts +26 -24
  134. package/src/routers/school.ts +12 -0
  135. package/src/routers/user.ts +12 -0
  136. package/src/services/aiChatStream.ts +35 -0
  137. package/src/services/aiStream.ts +34 -0
  138. package/src/services/aiUsage.ts +202 -0
  139. package/src/services/announcement.ts +5 -0
  140. package/src/services/assignment.ts +29 -2
  141. package/src/services/attendance.ts +12 -0
  142. package/src/services/class.ts +4 -0
  143. package/src/services/discussion.ts +50 -11
  144. package/src/services/discussionSummary.ts +1 -0
  145. package/src/services/notification.ts +83 -20
  146. package/src/services/studentProgress.ts +4 -0
  147. package/src/services/userPrivacy.ts +4 -0
  148. package/src/services/worksheet.ts +2 -2
  149. package/src/utils/inference.ts +40 -2
  150. package/src/workers/processors/aiLabChat.ts +2 -1
  151. package/src/workers/processors/aiNewtonChat.ts +3 -1
  152. package/src/workers/processors/gradeWorksheet.ts +2 -2
  153. package/src/workers/processors/pdfToWorksheet.ts +5 -2
  154. package/tests/integration/routers/notification.test.ts +68 -20
  155. package/tests/unit/services/aiUsage.test.ts +105 -0
@@ -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
+ });