@studious-lms/server 1.16.0 → 1.17.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 (157) hide show
  1. package/.github/workflows/deploy.yml +1 -1
  2. package/dist/instrument.js +3 -3
  3. package/dist/instrument.js.map +1 -1
  4. package/dist/lib/chatAttachmentPrompt.d.ts +47 -0
  5. package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
  6. package/dist/lib/chatAttachmentPrompt.js +190 -0
  7. package/dist/lib/chatAttachmentPrompt.js.map +1 -0
  8. package/dist/lib/inferDocumentFormat.js +3 -3
  9. package/dist/lib/inferDocumentFormat.js.map +1 -1
  10. package/dist/lib/legal.d.ts +3 -0
  11. package/dist/lib/legal.d.ts.map +1 -0
  12. package/dist/lib/legal.js +6 -0
  13. package/dist/lib/legal.js.map +1 -0
  14. package/dist/models/announcement.d.ts +2 -0
  15. package/dist/models/announcement.d.ts.map +1 -1
  16. package/dist/models/auth.d.ts +8 -0
  17. package/dist/models/auth.d.ts.map +1 -1
  18. package/dist/models/auth.js +4 -2
  19. package/dist/models/auth.js.map +1 -1
  20. package/dist/models/event.d.ts +2 -0
  21. package/dist/models/event.d.ts.map +1 -1
  22. package/dist/models/file.d.ts +24 -0
  23. package/dist/models/file.d.ts.map +1 -1
  24. package/dist/models/marketing.d.ts +2 -15
  25. package/dist/models/marketing.d.ts.map +1 -1
  26. package/dist/models/marketing.js +12 -4
  27. package/dist/models/marketing.js.map +1 -1
  28. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  29. package/dist/pipelines/aiLabChat.js +39 -59
  30. package/dist/pipelines/aiLabChat.js.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.d.ts +2 -2
  32. package/dist/pipelines/aiNewtonChat.d.ts +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +20 -19
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.d.ts +1 -0
  37. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  38. package/dist/pipelines/gradeWorksheet.js +84 -13
  39. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  40. package/dist/pipelines/labChatPrompt.d.ts +4 -20
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +24 -64
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +440 -26
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/assignment.d.ts +10 -0
  47. package/dist/routers/assignment.d.ts.map +1 -1
  48. package/dist/routers/auth.d.ts +2 -0
  49. package/dist/routers/auth.d.ts.map +1 -1
  50. package/dist/routers/auth.js +15 -2
  51. package/dist/routers/auth.js.map +1 -1
  52. package/dist/routers/class.d.ts +4 -0
  53. package/dist/routers/class.d.ts.map +1 -1
  54. package/dist/routers/event.d.ts +2 -0
  55. package/dist/routers/event.d.ts.map +1 -1
  56. package/dist/routers/marketing.d.ts +0 -13
  57. package/dist/routers/marketing.d.ts.map +1 -1
  58. package/dist/routers/school.d.ts +106 -0
  59. package/dist/routers/school.d.ts.map +1 -1
  60. package/dist/routers/school.js +11 -3
  61. package/dist/routers/school.js.map +1 -1
  62. package/dist/routers/user.d.ts +89 -0
  63. package/dist/routers/user.d.ts.map +1 -1
  64. package/dist/routers/user.js +19 -2
  65. package/dist/routers/user.js.map +1 -1
  66. package/dist/routers/worksheet.d.ts +7 -0
  67. package/dist/routers/worksheet.d.ts.map +1 -1
  68. package/dist/routers/worksheet.js +8 -3
  69. package/dist/routers/worksheet.js.map +1 -1
  70. package/dist/seedDatabase.d.ts +2 -0
  71. package/dist/seedDatabase.d.ts.map +1 -1
  72. package/dist/services/aiChatStream.d.ts +2 -0
  73. package/dist/services/aiChatStream.d.ts.map +1 -1
  74. package/dist/services/aiChatStream.js +31 -4
  75. package/dist/services/aiChatStream.js.map +1 -1
  76. package/dist/services/aiProposalTools.d.ts.map +1 -1
  77. package/dist/services/aiProposalTools.js +9 -9
  78. package/dist/services/aiProposalTools.js.map +1 -1
  79. package/dist/services/aiTools.js +3 -3
  80. package/dist/services/aiTools.js.map +1 -1
  81. package/dist/services/assignment.d.ts +10 -0
  82. package/dist/services/assignment.d.ts.map +1 -1
  83. package/dist/services/auth.d.ts +2 -0
  84. package/dist/services/auth.d.ts.map +1 -1
  85. package/dist/services/auth.js +13 -4
  86. package/dist/services/auth.js.map +1 -1
  87. package/dist/services/class.d.ts +4 -0
  88. package/dist/services/class.d.ts.map +1 -1
  89. package/dist/services/event.d.ts +2 -0
  90. package/dist/services/event.d.ts.map +1 -1
  91. package/dist/services/marketing.d.ts +1 -14
  92. package/dist/services/marketing.d.ts.map +1 -1
  93. package/dist/services/marketing.js +5 -5
  94. package/dist/services/marketing.js.map +1 -1
  95. package/dist/services/newtonChat.d.ts.map +1 -1
  96. package/dist/services/newtonChat.js +2 -8
  97. package/dist/services/newtonChat.js.map +1 -1
  98. package/dist/services/openaiDocumentGen.d.ts +12 -0
  99. package/dist/services/openaiDocumentGen.d.ts.map +1 -1
  100. package/dist/services/openaiDocumentGen.js +173 -65
  101. package/dist/services/openaiDocumentGen.js.map +1 -1
  102. package/dist/services/school.d.ts +116 -0
  103. package/dist/services/school.d.ts.map +1 -1
  104. package/dist/services/school.js +213 -2
  105. package/dist/services/school.js.map +1 -1
  106. package/dist/services/userPrivacy.d.ts +86 -0
  107. package/dist/services/userPrivacy.d.ts.map +1 -0
  108. package/dist/services/userPrivacy.js +184 -0
  109. package/dist/services/userPrivacy.js.map +1 -0
  110. package/dist/services/worksheet.d.ts +1 -0
  111. package/dist/services/worksheet.d.ts.map +1 -1
  112. package/dist/services/worksheet.js +13 -3
  113. package/dist/services/worksheet.js.map +1 -1
  114. package/dist/utils/email.d.ts.map +1 -1
  115. package/dist/utils/email.js +7 -3
  116. package/dist/utils/email.js.map +1 -1
  117. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  118. package/dist/workers/processors/aiNewtonChat.js +3 -3
  119. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  120. package/package.json +2 -1
  121. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  122. package/prisma/schema.prisma +3 -0
  123. package/src/instrument.ts +1 -1
  124. package/src/lib/chatAttachmentPrompt.ts +270 -0
  125. package/src/lib/inferDocumentFormat.ts +1 -1
  126. package/src/lib/legal.ts +2 -0
  127. package/src/models/auth.ts +4 -0
  128. package/src/models/marketing.ts +10 -2
  129. package/src/pipelines/aiLabChat.ts +46 -71
  130. package/src/pipelines/aiNewtonChat.ts +20 -23
  131. package/src/pipelines/gradeWorksheet.ts +87 -11
  132. package/src/pipelines/labChatPrompt.ts +29 -98
  133. package/src/routers/auth.ts +13 -0
  134. package/src/routers/school.ts +12 -0
  135. package/src/routers/user.ts +26 -0
  136. package/src/routers/worksheet.ts +11 -0
  137. package/src/services/aiChatStream.ts +30 -3
  138. package/src/services/aiProposalTools.ts +8 -7
  139. package/src/services/aiTools.ts +1 -1
  140. package/src/services/auth.ts +14 -2
  141. package/src/services/marketing.ts +3 -3
  142. package/src/services/newtonChat.ts +0 -7
  143. package/src/services/openaiDocumentGen.ts +190 -66
  144. package/src/services/school.ts +231 -0
  145. package/src/services/userPrivacy.ts +196 -0
  146. package/src/services/worksheet.ts +20 -1
  147. package/src/utils/email.ts +5 -1
  148. package/src/workers/processors/aiNewtonChat.ts +0 -1
  149. package/tests/helpers/registerInput.ts +20 -0
  150. package/tests/integration/globalSetup.ts +10 -18
  151. package/tests/integration/routers/auth.test.ts +13 -18
  152. package/tests/integration/routers/newtonChat.test.ts +9 -8
  153. package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
  154. package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
  155. package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
  156. package/tests/unit/services/aiChatStream.test.ts +92 -0
  157. package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * User privacy service — data export and account erasure (anonymization).
3
+ */
4
+ import { TRPCError } from "@trpc/server";
5
+ import { randomBytes } from "node:crypto";
6
+ import { hash } from "bcryptjs";
7
+ import { prisma } from "../lib/prisma.js";
8
+ import { deleteSessionsByUserId } from "../models/auth.js";
9
+
10
+ async function assertAccountCanBeErased(userId: string) {
11
+ const user = await prisma.user.findUnique({
12
+ where: { id: userId },
13
+ select: {
14
+ id: true,
15
+ teacherIn: {
16
+ select: {
17
+ id: true,
18
+ name: true,
19
+ _count: { select: { students: true } },
20
+ },
21
+ },
22
+ _count: {
23
+ select: {
24
+ assignments: true,
25
+ createdLabChats: true,
26
+ },
27
+ },
28
+ },
29
+ });
30
+
31
+ if (!user) {
32
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
33
+ }
34
+
35
+ const classesWithStudents = user.teacherIn.filter(
36
+ (cls) => cls._count.students > 0,
37
+ );
38
+ if (classesWithStudents.length > 0) {
39
+ throw new TRPCError({
40
+ code: "PRECONDITION_FAILED",
41
+ message:
42
+ "Delete or transfer your classes before deleting your account.",
43
+ });
44
+ }
45
+
46
+ if (user._count.assignments > 0) {
47
+ throw new TRPCError({
48
+ code: "PRECONDITION_FAILED",
49
+ message:
50
+ "Remove or reassign your assignments before deleting your account.",
51
+ });
52
+ }
53
+
54
+ if (user._count.createdLabChats > 0) {
55
+ throw new TRPCError({
56
+ code: "PRECONDITION_FAILED",
57
+ message: "Remove your AI lab chats before deleting your account.",
58
+ });
59
+ }
60
+ }
61
+
62
+ /** Export a portable snapshot of the authenticated user's personal data. */
63
+ export async function exportUserData(userId: string) {
64
+ const user = await prisma.user.findUnique({
65
+ where: { id: userId },
66
+ select: {
67
+ id: true,
68
+ username: true,
69
+ email: true,
70
+ verified: true,
71
+ termsAcceptedAt: true,
72
+ termsVersion: true,
73
+ profile: true,
74
+ teacherIn: {
75
+ select: { id: true, name: true, subject: true, section: true },
76
+ },
77
+ studentIn: {
78
+ select: { id: true, name: true, subject: true, section: true },
79
+ },
80
+ schoolMemberships: {
81
+ select: {
82
+ role: true,
83
+ status: true,
84
+ school: { select: { id: true, name: true } },
85
+ },
86
+ },
87
+ submissions: {
88
+ select: {
89
+ id: true,
90
+ createdAt: true,
91
+ submittedAt: true,
92
+ gradeReceived: true,
93
+ extendedResponse: true,
94
+ assignment: { select: { id: true, title: true, classId: true } },
95
+ },
96
+ },
97
+ events: {
98
+ select: {
99
+ id: true,
100
+ name: true,
101
+ startTime: true,
102
+ endTime: true,
103
+ location: true,
104
+ remarks: true,
105
+ },
106
+ },
107
+ sentMessages: {
108
+ select: {
109
+ id: true,
110
+ content: true,
111
+ createdAt: true,
112
+ conversationId: true,
113
+ },
114
+ take: 500,
115
+ orderBy: { createdAt: "desc" },
116
+ },
117
+ notificationsReceived: {
118
+ select: {
119
+ id: true,
120
+ title: true,
121
+ content: true,
122
+ read: true,
123
+ createdAt: true,
124
+ },
125
+ take: 200,
126
+ orderBy: { createdAt: "desc" },
127
+ },
128
+ },
129
+ });
130
+
131
+ if (!user) {
132
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
133
+ }
134
+
135
+ return {
136
+ exportedAt: new Date().toISOString(),
137
+ user,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Erase personal account data by anonymizing the user record and removing
143
+ * sessions and memberships. Educational records tied to classes (e.g.
144
+ * submissions) remain with an anonymized identity where required.
145
+ */
146
+ export async function deleteUserAccount(userId: string) {
147
+ await assertAccountCanBeErased(userId);
148
+ await deleteSessionsByUserId(userId);
149
+
150
+ const anonUsername = `deleted_${userId.slice(0, 8)}`;
151
+ const anonEmail = `deleted+${userId}@studious.invalid`;
152
+ const randomPassword = await hash(randomBytes(32).toString("hex"), 10);
153
+
154
+ await prisma.$transaction([
155
+ prisma.conversationMember.deleteMany({ where: { userId } }),
156
+ prisma.notification.deleteMany({
157
+ where: { OR: [{ senderId: userId }, { receiverId: userId }] },
158
+ }),
159
+ prisma.schoolMembership.deleteMany({ where: { userId } }),
160
+ prisma.permission.deleteMany({
161
+ where: { OR: [{ userId }, { grantedById: userId }] },
162
+ }),
163
+ prisma.user.update({
164
+ where: { id: userId },
165
+ data: {
166
+ teacherIn: { set: [] },
167
+ studentIn: { set: [] },
168
+ departments: { set: [] },
169
+ tags: { set: [] },
170
+ specificAssignments: { set: [] },
171
+ presentAttendance: { set: [] },
172
+ lateAttendance: { set: [] },
173
+ absentAttendance: { set: [] },
174
+ schoolId: null,
175
+ username: anonUsername,
176
+ email: anonEmail,
177
+ password: randomPassword,
178
+ verified: false,
179
+ termsAcceptedAt: null,
180
+ termsVersion: null,
181
+ profile: {
182
+ update: {
183
+ displayName: null,
184
+ bio: null,
185
+ location: null,
186
+ website: null,
187
+ profilePicture: null,
188
+ profilePictureThumbnail: null,
189
+ },
190
+ },
191
+ },
192
+ }),
193
+ ]);
194
+
195
+ return { success: true };
196
+ }
@@ -6,7 +6,7 @@ import { TRPCError } from "@trpc/server";
6
6
  import { JobStatus, Prisma, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { getFileUrl } from "../lib/fileUpload.js";
9
- import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
9
+ import { cancelGradePipeline, regradeWorksheetPipeline, regradeAllPipeline } from "../pipelines/gradeWorksheet.js";
10
10
  import { enqueueRunJob } from "../workers/queues.js";
11
11
  import { logger } from "../utils/logger.js";
12
12
  import {
@@ -629,6 +629,25 @@ export async function regradeQuestion(
629
629
  return regradeWorksheetPipeline(worksheetResponseId, progressId);
630
630
  }
631
631
 
632
+ export async function regradeAll(
633
+ worksheetResponseId: string,
634
+ userId: string
635
+ ) {
636
+ const worksheetResponse = await findWorksheetResponseByIdForTeacher(
637
+ worksheetResponseId,
638
+ userId
639
+ );
640
+
641
+ if (!worksheetResponse) {
642
+ throw new TRPCError({
643
+ code: "FORBIDDEN",
644
+ message: "Only class teachers can regrade all worksheet answers",
645
+ });
646
+ }
647
+
648
+ await regradeAllPipeline(worksheetResponseId);
649
+ }
650
+
632
651
  export async function gradeAnswer(
633
652
  questionId: string,
634
653
  studentWorksheetResponseId: string,
@@ -27,7 +27,11 @@ export const transport = nodemailer.createTransport({
27
27
  export const sendMail = async ({ from, to, subject, text }: sendMailProps) => {
28
28
  // Wrapper function for sending emails
29
29
  if (env.EMAIL_DRY_RUN == "true") {
30
- logger.info(`Email dry run enabled. Would have sent email to ${to} from ${from} with subject ${subject} and text ${text}`);
30
+ logger.info("Email dry run enabled. Would have sent email", {
31
+ to,
32
+ from,
33
+ subject,
34
+ });
31
35
  return;
32
36
  }
33
37
 
@@ -64,7 +64,6 @@ export const aiNewtonChatWorker = createRunJobWorker({
64
64
 
65
65
  await generateAndSendNewtonResponse(
66
66
  newtonChat.id,
67
- triggeringMessage.content,
68
67
  conversationId,
69
68
  newtonChat.submission,
70
69
  aiMessageId,
@@ -0,0 +1,20 @@
1
+ import { CURRENT_TERMS_VERSION } from "../../src/lib/legal.js";
2
+
3
+ export function registerInput(
4
+ overrides: {
5
+ username: string;
6
+ email: string;
7
+ password?: string;
8
+ confirmPassword?: string;
9
+ },
10
+ ) {
11
+ const password = overrides.password ?? "password_is_1234";
12
+ return {
13
+ username: overrides.username,
14
+ email: overrides.email,
15
+ password,
16
+ confirmPassword: overrides.confirmPassword ?? password,
17
+ agreeToTerms: true as const,
18
+ termsVersion: CURRENT_TERMS_VERSION,
19
+ };
20
+ }
@@ -10,6 +10,7 @@ import { createTRPCContext } from '../../src/trpc';
10
10
  import { clearDatabase } from '../../src/seedDatabase';
11
11
  import { execSync } from 'child_process';
12
12
  import { SessionType } from '@prisma/client';
13
+ import { registerInput } from '../helpers/registerInput.js';
13
14
 
14
15
  async function runSetup() {
15
16
  const ctx = await createTRPCContext({
@@ -20,26 +21,17 @@ async function runSetup() {
20
21
 
21
22
  execSync('npm run test:pre');
22
23
 
23
- const user1 = await caller.auth.register({
24
- username: 'testuser1',
25
- email: 'test@studious.sh',
26
- password: 'password_is_1234',
27
- confirmPassword: 'password_is_1234',
28
- });
24
+ const user1 = await caller.auth.register(
25
+ registerInput({ username: 'testuser1', email: 'test@studious.sh' }),
26
+ );
29
27
 
30
- const user2 = await caller.auth.register({
31
- username: 'testuser2',
32
- email: 'test2@studious.sh',
33
- password: 'password_is_1234',
34
- confirmPassword: 'password_is_1234',
35
- });
28
+ const user2 = await caller.auth.register(
29
+ registerInput({ username: 'testuser2', email: 'test2@studious.sh' }),
30
+ );
36
31
 
37
- const user3 = await caller.auth.register({
38
- username: 'testuser3',
39
- email: 'test3@studious.sh',
40
- password: 'password_is_1234',
41
- confirmPassword: 'password_is_1234',
42
- });
32
+ const user3 = await caller.auth.register(
33
+ registerInput({ username: 'testuser3', email: 'test3@studious.sh' }),
34
+ );
43
35
 
44
36
  const session1 = await prisma.session.findFirst({
45
37
  where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },
@@ -6,6 +6,7 @@ import { test, expect, describe } from 'vitest';
6
6
  import { appRouter } from '../../../src/routers/_app';
7
7
  import { createTRPCContext } from '../../../src/trpc';
8
8
  import { prisma } from '../../../src/lib/prisma';
9
+ import { registerInput } from '../../helpers/registerInput.js';
9
10
  import {
10
11
  caller,
11
12
  login1,
@@ -64,12 +65,9 @@ describe('Auth Router', () => {
64
65
  test('check fails when unauthenticated', () => expectUnauth((c) => c.auth.check()));
65
66
 
66
67
  test('resendVerificationEmail succeeds for existing user', async () => {
67
- await caller.auth.register({
68
- username: 'resendtest',
69
- email: 'resend@studious.sh',
70
- password: 'password_is_1234',
71
- confirmPassword: 'password_is_1234',
72
- });
68
+ await caller.auth.register(
69
+ registerInput({ username: 'resendtest', email: 'resend@studious.sh' }),
70
+ );
73
71
  const result = await caller.auth.resendVerificationEmail({
74
72
  email: 'resend@studious.sh',
75
73
  });
@@ -227,23 +225,20 @@ describe('Auth Router', () => {
227
225
 
228
226
  test('register fails when username already exists', async () => {
229
227
  await expect(
230
- caller.auth.register({
231
- username: 'testuser1',
232
- email: 'newemail@studious.sh',
233
- password: 'password_is_1234',
234
- confirmPassword: 'password_is_1234',
235
- })
228
+ caller.auth.register(
229
+ registerInput({
230
+ username: 'testuser1',
231
+ email: 'newemail@studious.sh',
232
+ }),
233
+ )
236
234
  ).rejects.toThrow();
237
235
  });
238
236
 
239
237
  test('register fails when email already exists', async () => {
240
238
  await expect(
241
- caller.auth.register({
242
- username: 'newuser',
243
- email: 'test@studious.sh',
244
- password: 'password_is_1234',
245
- confirmPassword: 'password_is_1234',
246
- })
239
+ caller.auth.register(
240
+ registerInput({ username: 'newuser', email: 'test@studious.sh' }),
241
+ )
247
242
  ).rejects.toThrow();
248
243
  });
249
244
 
@@ -50,7 +50,7 @@ describe('Newton Chat Router', () => {
50
50
  conversationId = tutorConversation.conversationId;
51
51
  });
52
52
 
53
- test('rejects attachment-only Newton chat posts', async () => {
53
+ test('allows attachment-only Newton chat posts', async () => {
54
54
  const [uploadTarget] = await user2Caller.message.getUploadUrls({
55
55
  conversationId,
56
56
  files: [
@@ -70,12 +70,13 @@ describe('Newton Chat Router', () => {
70
70
  },
71
71
  });
72
72
 
73
- await expect(
74
- user2Caller.newtonChat.postToNewtonChat({
75
- newtonChatId,
76
- content: ' ',
77
- fileIds: [uploadTarget!.id],
78
- }),
79
- ).rejects.toThrow();
73
+ const message = await user2Caller.newtonChat.postToNewtonChat({
74
+ newtonChatId,
75
+ content: ' ',
76
+ fileIds: [uploadTarget!.id],
77
+ });
78
+
79
+ expect(message.attachments).toHaveLength(1);
80
+ expect(message.attachments[0]?.name).toBe('newton-attachment-only.pdf');
80
81
  });
81
82
  });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ buildUserMessageContentWithAttachments,
4
+ loadChatAttachments,
5
+ } from '../../../src/lib/chatAttachmentPrompt.js';
6
+
7
+ const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
8
+ const docxText = 'Unit 3 lesson plan content';
9
+
10
+ vi.mock('officeparser', () => ({
11
+ OfficeParser: {
12
+ parseOffice: vi.fn(async () => ({
13
+ toText: () => docxText,
14
+ })),
15
+ },
16
+ }));
17
+
18
+ vi.mock('../../../src/lib/googleCloudStorage.js', () => ({
19
+ bucket: {
20
+ file: vi.fn((path: string) => ({
21
+ download: vi.fn(async () => {
22
+ if (path.includes('missing')) {
23
+ throw new Error('not found');
24
+ }
25
+ if (path.endsWith('.docx')) {
26
+ return [Buffer.from('fake-docx-bytes')];
27
+ }
28
+ return [Buffer.from(pdfBytes)];
29
+ }),
30
+ })),
31
+ },
32
+ }));
33
+
34
+ describe('buildUserMessageContentWithAttachments', () => {
35
+ it('returns plain text when there are no attachments', () => {
36
+ expect(buildUserMessageContentWithAttachments('Hello', [])).toBe('Hello');
37
+ });
38
+
39
+ it('includes PDFs as inline file parts with bytes', () => {
40
+ const content = buildUserMessageContentWithAttachments('Review this', [
41
+ {
42
+ id: '1',
43
+ name: 'essay.pdf',
44
+ type: 'application/pdf',
45
+ path: 'essay.pdf',
46
+ data: pdfBytes,
47
+ },
48
+ ]);
49
+
50
+ expect(content).toEqual([
51
+ { type: 'text', text: 'Review this' },
52
+ {
53
+ type: 'file',
54
+ data: pdfBytes,
55
+ mediaType: 'application/pdf',
56
+ filename: 'essay.pdf',
57
+ },
58
+ ]);
59
+ });
60
+
61
+ it('includes extracted docx text in the message body', () => {
62
+ const content = buildUserMessageContentWithAttachments('Summarize', [
63
+ {
64
+ id: '2',
65
+ name: 'plan.docx',
66
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
67
+ path: 'plan.docx',
68
+ extractedText: docxText,
69
+ },
70
+ ]);
71
+
72
+ expect(content).toBe(
73
+ `Summarize\n\n[Content of plan.docx]:\n${docxText}`,
74
+ );
75
+ });
76
+
77
+ it('lists unsupported file types when extraction failed', () => {
78
+ const content = buildUserMessageContentWithAttachments('Here', [
79
+ {
80
+ id: '3',
81
+ name: 'clip.mp4',
82
+ type: 'video/mp4',
83
+ path: 'clip.mp4',
84
+ },
85
+ ]);
86
+
87
+ expect(content).toBe(
88
+ 'Here\n[Attached files (content not extracted): clip.mp4 (video/mp4)]',
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('loadChatAttachments', () => {
94
+ it('loads bytes and extracts office document text', async () => {
95
+ const resolved = await loadChatAttachments([
96
+ {
97
+ id: '1',
98
+ name: 'plan.docx',
99
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
100
+ path: 'uploads/plan.docx',
101
+ },
102
+ ]);
103
+
104
+ expect(resolved[0]?.data).toBeDefined();
105
+ expect(resolved[0]?.extractedText).toBe(docxText);
106
+ });
107
+
108
+ it('loads pdf bytes without extracted text', async () => {
109
+ const resolved = await loadChatAttachments([
110
+ {
111
+ id: '2',
112
+ name: 'scan.pdf',
113
+ type: 'application/pdf',
114
+ path: 'uploads/scan.pdf',
115
+ },
116
+ ]);
117
+
118
+ expect(resolved[0]?.data).toEqual(pdfBytes);
119
+ expect(resolved[0]?.extractedText).toBeUndefined();
120
+ });
121
+
122
+ it('skips download for video attachments', async () => {
123
+ const resolved = await loadChatAttachments([
124
+ {
125
+ id: '3',
126
+ name: 'clip.mp4',
127
+ type: 'video/mp4',
128
+ path: 'uploads/clip.mp4',
129
+ },
130
+ ]);
131
+
132
+ expect(resolved[0]?.data).toBeUndefined();
133
+ expect(resolved[0]?.extractedText).toBeUndefined();
134
+ });
135
+ });
@@ -30,4 +30,14 @@ describe('inferDocumentFormat', () => {
30
30
  it('defaults to pdf for generic handouts', () => {
31
31
  expect(inferDocumentFormat('Reading Guide', 'Chapter summary', 'pdf')).toBe('pdf');
32
32
  });
33
+
34
+ it('overrides pdf when content mentions a flowchart', () => {
35
+ expect(
36
+ inferDocumentFormat(
37
+ 'Photosynthesis Overview',
38
+ 'Include a flowchart of the light reactions.',
39
+ 'pdf',
40
+ ),
41
+ ).toBe('png');
42
+ });
33
43
  });
@@ -17,8 +17,8 @@ describe("buildLabChatAgentSystemPrompt", () => {
17
17
  });
18
18
 
19
19
  describe("buildLabChatAgentMessages", () => {
20
- test("folds the class context into the system prompt, not the message list", () => {
21
- const { system, messages } = buildLabChatAgentMessages({
20
+ test("folds the class context into the system prompt, not the message list", async () => {
21
+ const { system, messages } = await buildLabChatAgentMessages({
22
22
  context: "Topic context",
23
23
  classContext: "CLASS: Biology",
24
24
  recentMessages: [
@@ -41,8 +41,8 @@ describe("buildLabChatAgentMessages", () => {
41
41
  ]);
42
42
  });
43
43
 
44
- test("sorts timestamped history into chronological order before prompt assembly", () => {
45
- const { messages } = buildLabChatAgentMessages({
44
+ test("sorts timestamped history into chronological order before prompt assembly", async () => {
45
+ const { messages } = await buildLabChatAgentMessages({
46
46
  context: "Topic context",
47
47
  classContext: "CLASS: Biology",
48
48
  recentMessages: [
@@ -76,8 +76,8 @@ describe("buildLabChatAgentMessages", () => {
76
76
  });
77
77
  });
78
78
 
79
- test("keeps original order among messages without timestamps", () => {
80
- const { messages } = buildLabChatAgentMessages({
79
+ test("keeps original order among messages without timestamps", async () => {
80
+ const { messages } = await buildLabChatAgentMessages({
81
81
  context: "Topic context",
82
82
  classContext: "CLASS: Biology",
83
83
  recentMessages: [
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ enrichStreamPartsWithGeneratedFiles,
4
+ finalizeDeferredToolParts,
5
+ } from '../../../src/services/aiChatStream.js';
6
+
7
+ describe('enrichStreamPartsWithGeneratedFiles', () => {
8
+ it('links generated files to generate_document tool parts by title', () => {
9
+ const parts = enrichStreamPartsWithGeneratedFiles(
10
+ [
11
+ { type: 'text', text: 'Here is your worksheet.' },
12
+ {
13
+ type: 'tool',
14
+ toolCallId: 'tc-1',
15
+ toolName: 'generate_document',
16
+ title: 'Algebra Review',
17
+ status: 'completed',
18
+ },
19
+ ],
20
+ [{ title: 'Algebra Review', fileId: 'file-1', fileName: 'Algebra_Review.pdf' }],
21
+ );
22
+
23
+ expect(parts[1]).toMatchObject({
24
+ type: 'tool',
25
+ fileId: 'file-1',
26
+ fileName: 'Algebra_Review.pdf',
27
+ });
28
+ });
29
+
30
+ it('falls back to order when titles do not match', () => {
31
+ const parts = enrichStreamPartsWithGeneratedFiles(
32
+ [
33
+ {
34
+ type: 'tool',
35
+ toolCallId: 'tc-1',
36
+ toolName: 'generate_document',
37
+ status: 'completed',
38
+ },
39
+ ],
40
+ [{ title: 'Doc A', fileId: 'file-a', fileName: 'doc_a.pdf' }],
41
+ );
42
+
43
+ expect(parts[0]).toMatchObject({ fileId: 'file-a' });
44
+ });
45
+
46
+ it('marks linked generate_document tools completed', () => {
47
+ const parts = enrichStreamPartsWithGeneratedFiles(
48
+ [
49
+ {
50
+ type: 'tool',
51
+ toolCallId: 'tc-1',
52
+ toolName: 'generate_document',
53
+ title: 'Slides',
54
+ status: 'executing',
55
+ },
56
+ ],
57
+ [{ title: 'Slides', fileId: 'file-1', fileName: 'slides.pptx' }],
58
+ );
59
+
60
+ expect(parts[0]).toMatchObject({ status: 'completed', fileId: 'file-1' });
61
+ });
62
+ });
63
+
64
+ describe('finalizeDeferredToolParts', () => {
65
+ it('marks generate_document without a file as failed', () => {
66
+ const parts = finalizeDeferredToolParts([
67
+ {
68
+ type: 'tool',
69
+ toolCallId: 'tc-1',
70
+ toolName: 'generate_document',
71
+ title: 'Missing',
72
+ status: 'executing',
73
+ },
74
+ ]);
75
+
76
+ expect(parts[0]).toMatchObject({ status: 'failed' });
77
+ });
78
+
79
+ it('leaves other tools unchanged', () => {
80
+ const parts = finalizeDeferredToolParts([
81
+ {
82
+ type: 'tool',
83
+ toolCallId: 'tc-1',
84
+ toolName: 'propose_assignment',
85
+ title: 'HW',
86
+ status: 'completed',
87
+ },
88
+ ]);
89
+
90
+ expect(parts[0]).toMatchObject({ status: 'completed' });
91
+ });
92
+ });