@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.
- package/.github/workflows/deploy.yml +1 -1
- package/dist/instrument.js +3 -3
- package/dist/instrument.js.map +1 -1
- package/dist/lib/chatAttachmentPrompt.d.ts +47 -0
- package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
- package/dist/lib/chatAttachmentPrompt.js +190 -0
- package/dist/lib/chatAttachmentPrompt.js.map +1 -0
- package/dist/lib/inferDocumentFormat.js +3 -3
- package/dist/lib/inferDocumentFormat.js.map +1 -1
- package/dist/lib/legal.d.ts +3 -0
- package/dist/lib/legal.d.ts.map +1 -0
- package/dist/lib/legal.js +6 -0
- package/dist/lib/legal.js.map +1 -0
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/auth.d.ts +8 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +4 -2
- package/dist/models/auth.js.map +1 -1
- package/dist/models/event.d.ts +2 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +24 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +2 -15
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/marketing.js +12 -4
- package/dist/models/marketing.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +39 -59
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +2 -2
- package/dist/pipelines/aiNewtonChat.d.ts +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +20 -19
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +1 -0
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +84 -13
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +4 -20
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +24 -64
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +440 -26
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +10 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/auth.d.ts +2 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/auth.js +15 -2
- package/dist/routers/auth.js.map +1 -1
- package/dist/routers/class.d.ts +4 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/event.d.ts +2 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/marketing.d.ts +0 -13
- package/dist/routers/marketing.d.ts.map +1 -1
- package/dist/routers/school.d.ts +106 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/user.d.ts +89 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +19 -2
- package/dist/routers/user.js.map +1 -1
- package/dist/routers/worksheet.d.ts +7 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +8 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts +2 -0
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +2 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +31 -4
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +9 -9
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiTools.js +3 -3
- package/dist/services/aiTools.js.map +1 -1
- package/dist/services/assignment.d.ts +10 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +13 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +4 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/event.d.ts +2 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/marketing.d.ts +1 -14
- package/dist/services/marketing.d.ts.map +1 -1
- package/dist/services/marketing.js +5 -5
- package/dist/services/marketing.js.map +1 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +2 -8
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/openaiDocumentGen.d.ts +12 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -1
- package/dist/services/openaiDocumentGen.js +173 -65
- package/dist/services/openaiDocumentGen.js.map +1 -1
- package/dist/services/school.d.ts +116 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +213 -2
- package/dist/services/school.js.map +1 -1
- package/dist/services/userPrivacy.d.ts +86 -0
- package/dist/services/userPrivacy.d.ts.map +1 -0
- package/dist/services/userPrivacy.js +184 -0
- package/dist/services/userPrivacy.js.map +1 -0
- package/dist/services/worksheet.d.ts +1 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +13 -3
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/email.d.ts.map +1 -1
- package/dist/utils/email.js +7 -3
- package/dist/utils/email.js.map +1 -1
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
- package/dist/workers/processors/aiNewtonChat.js +3 -3
- package/dist/workers/processors/aiNewtonChat.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
- package/prisma/schema.prisma +3 -0
- package/src/instrument.ts +1 -1
- package/src/lib/chatAttachmentPrompt.ts +270 -0
- package/src/lib/inferDocumentFormat.ts +1 -1
- package/src/lib/legal.ts +2 -0
- package/src/models/auth.ts +4 -0
- package/src/models/marketing.ts +10 -2
- package/src/pipelines/aiLabChat.ts +46 -71
- package/src/pipelines/aiNewtonChat.ts +20 -23
- package/src/pipelines/gradeWorksheet.ts +87 -11
- package/src/pipelines/labChatPrompt.ts +29 -98
- package/src/routers/auth.ts +13 -0
- package/src/routers/school.ts +12 -0
- package/src/routers/user.ts +26 -0
- package/src/routers/worksheet.ts +11 -0
- package/src/services/aiChatStream.ts +30 -3
- package/src/services/aiProposalTools.ts +8 -7
- package/src/services/aiTools.ts +1 -1
- package/src/services/auth.ts +14 -2
- package/src/services/marketing.ts +3 -3
- package/src/services/newtonChat.ts +0 -7
- package/src/services/openaiDocumentGen.ts +190 -66
- package/src/services/school.ts +231 -0
- package/src/services/userPrivacy.ts +196 -0
- package/src/services/worksheet.ts +20 -1
- package/src/utils/email.ts +5 -1
- package/src/workers/processors/aiNewtonChat.ts +0 -1
- package/tests/helpers/registerInput.ts +20 -0
- package/tests/integration/globalSetup.ts +10 -18
- package/tests/integration/routers/auth.test.ts +13 -18
- package/tests/integration/routers/newtonChat.test.ts +9 -8
- package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
- package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
- package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
- package/tests/unit/services/aiChatStream.test.ts +92 -0
- 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,
|
package/src/utils/email.ts
CHANGED
|
@@ -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(
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
).
|
|
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
|
+
});
|