@studious-lms/server 1.4.2 → 1.4.3
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/.coderabbit.yaml +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -28
- package/dist/index.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +3 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/notification.d.ts +11 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/worksheet.d.ts +378 -7
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +124 -18
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/labChat.d.ts +6 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +31 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +6 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +9 -2
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/worksheet.d.ts +8 -6
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +18 -16
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +12 -5
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +15 -15
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +138 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +9 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +138 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +80 -28
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +202 -0
- package/src/pipelines/aiLabChat.ts +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/worksheet.ts +31 -19
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +4 -1
- package/src/services/worksheet.ts +191 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +209 -55
- package/tests/globalSetup.ts +13 -4
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/inference.test.ts +218 -0
package/src/services/auth.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Auth service – user registration, login, logout, verification, and password reset.
|
|
3
3
|
* Handles session creation, Redis caching, and email notifications.
|
|
4
4
|
*/
|
|
5
|
+
import { SessionType } from "@prisma/client";
|
|
5
6
|
import { TRPCError } from "@trpc/server";
|
|
6
7
|
import { v4 as uuidv4 } from "uuid";
|
|
7
8
|
import { compare, hash } from "bcryptjs";
|
|
@@ -19,13 +20,12 @@ import {
|
|
|
19
20
|
deleteSessionsByUserId,
|
|
20
21
|
deleteShortLivedSessionsByUserId,
|
|
21
22
|
deleteUserById,
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
findSessionByIdAndType,
|
|
24
|
+
findSessionByIdWithUserAndType,
|
|
24
25
|
updateUserVerified,
|
|
25
26
|
updateUserPassword,
|
|
26
27
|
deleteSessionById,
|
|
27
28
|
} from "../models/auth.js";
|
|
28
|
-
import { getRedis } from "../lib/redis.js";
|
|
29
29
|
|
|
30
30
|
/** Register a new user, create verification session, and send verification email. */
|
|
31
31
|
export async function register(input: {
|
|
@@ -79,6 +79,7 @@ export async function register(input: {
|
|
|
79
79
|
() =>
|
|
80
80
|
createSession({
|
|
81
81
|
id: uuidv4(),
|
|
82
|
+
type: SessionType.EMAIL_VERIFICATION,
|
|
82
83
|
userId: user.id,
|
|
83
84
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
|
84
85
|
}),
|
|
@@ -102,7 +103,7 @@ export async function register(input: {
|
|
|
102
103
|
return { user: { id: user.id, username: user.username } };
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
/** Authenticate user by username/password, create session
|
|
106
|
+
/** Authenticate user by username/password, create session. */
|
|
106
107
|
export async function login(input: { username: string; password: string }) {
|
|
107
108
|
const { username, password } = input;
|
|
108
109
|
|
|
@@ -130,15 +131,11 @@ export async function login(input: { username: string; password: string }) {
|
|
|
130
131
|
|
|
131
132
|
const session = await createSession({
|
|
132
133
|
id: uuidv4(),
|
|
134
|
+
type: SessionType.AUTH,
|
|
133
135
|
userId: user.id,
|
|
134
136
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
|
135
137
|
});
|
|
136
138
|
|
|
137
|
-
const cachePayload = JSON.stringify({ id: user.id, username: user.username });
|
|
138
|
-
const ttlSeconds = 30 * 24 * 60 * 60; // 30 days, match session expiry
|
|
139
|
-
const r = getRedis();
|
|
140
|
-
if (r) await r.setex(`session:${session.id}`, ttlSeconds, cachePayload);
|
|
141
|
-
|
|
142
139
|
return {
|
|
143
140
|
token: session.id,
|
|
144
141
|
user: { id: user.id, username: user.username },
|
|
@@ -177,6 +174,7 @@ export async function resendVerificationEmail(email: string) {
|
|
|
177
174
|
|
|
178
175
|
const verificationToken = await createSession({
|
|
179
176
|
id: uuidv4(),
|
|
177
|
+
type: SessionType.EMAIL_VERIFICATION,
|
|
180
178
|
userId: user.id,
|
|
181
179
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
|
182
180
|
});
|
|
@@ -200,7 +198,10 @@ export async function resendVerificationEmail(email: string) {
|
|
|
200
198
|
|
|
201
199
|
/** Verify email via token. Marks user as verified and deletes the session. */
|
|
202
200
|
export async function verify(token: string) {
|
|
203
|
-
const session = await
|
|
201
|
+
const session = await findSessionByIdAndType(
|
|
202
|
+
token,
|
|
203
|
+
SessionType.EMAIL_VERIFICATION
|
|
204
|
+
);
|
|
204
205
|
if (!session) {
|
|
205
206
|
throw new TRPCError({
|
|
206
207
|
code: "NOT_FOUND",
|
|
@@ -209,13 +210,22 @@ export async function verify(token: string) {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
if (session.expiresAt && session.expiresAt < new Date()) {
|
|
213
|
+
await deleteSessionById(token);
|
|
212
214
|
throw new TRPCError({
|
|
213
215
|
code: "UNAUTHORIZED",
|
|
214
216
|
message: "Session expired",
|
|
215
217
|
});
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
|
|
220
|
+
if (!session.userId) {
|
|
221
|
+
await deleteSessionById(token);
|
|
222
|
+
throw new TRPCError({
|
|
223
|
+
code: "BAD_REQUEST",
|
|
224
|
+
message: "Invalid session state",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await updateUserVerified(session.userId, true);
|
|
219
229
|
await deleteSessionById(token);
|
|
220
230
|
return { success: true };
|
|
221
231
|
}
|
|
@@ -232,6 +242,7 @@ export async function requestPasswordReset(email: string) {
|
|
|
232
242
|
|
|
233
243
|
const resetToken = await createSession({
|
|
234
244
|
id: uuidv4(),
|
|
245
|
+
type: SessionType.PASSWORD_RESET,
|
|
235
246
|
userId: user.id,
|
|
236
247
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
|
237
248
|
});
|
|
@@ -255,7 +266,10 @@ export async function requestPasswordReset(email: string) {
|
|
|
255
266
|
|
|
256
267
|
/** Reset password using token from email. Validates token and updates password. */
|
|
257
268
|
export async function resetPassword(token: string, password: string) {
|
|
258
|
-
const session = await
|
|
269
|
+
const session = await findSessionByIdWithUserAndType(
|
|
270
|
+
token,
|
|
271
|
+
SessionType.PASSWORD_RESET
|
|
272
|
+
);
|
|
259
273
|
if (!session || !session.userId) {
|
|
260
274
|
throw new TRPCError({
|
|
261
275
|
code: "NOT_FOUND",
|
package/src/services/class.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Class service – CRUD for classes, members, grades, mark schemes, grading boundaries,
|
|
3
3
|
* syllabus, lab drafts, and invite codes.
|
|
4
4
|
*/
|
|
5
|
+
import { SessionType } from "@prisma/client";
|
|
5
6
|
import { TRPCError } from "@trpc/server";
|
|
6
7
|
import { prisma } from "../lib/prisma.js";
|
|
7
8
|
import { generateInviteCode } from "../utils/generateInviteCode.js";
|
|
@@ -286,6 +287,7 @@ export async function getInviteCode(classId: string) {
|
|
|
286
287
|
data: {
|
|
287
288
|
id: generateInviteCode(),
|
|
288
289
|
classId,
|
|
290
|
+
type: SessionType.CLASS_INVITE,
|
|
289
291
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
290
292
|
},
|
|
291
293
|
});
|
|
@@ -297,13 +299,14 @@ export async function getInviteCode(classId: string) {
|
|
|
297
299
|
|
|
298
300
|
export async function createInviteCode(classId: string) {
|
|
299
301
|
await prisma.session.deleteMany({
|
|
300
|
-
where: { classId },
|
|
302
|
+
where: { classId, type: SessionType.CLASS_INVITE },
|
|
301
303
|
});
|
|
302
304
|
|
|
303
305
|
const session = await prisma.session.create({
|
|
304
306
|
data: {
|
|
305
307
|
id: generateInviteCode(),
|
|
306
308
|
classId,
|
|
309
|
+
type: SessionType.CLASS_INVITE,
|
|
307
310
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
308
311
|
},
|
|
309
312
|
});
|
package/src/services/file.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
updateFileName,
|
|
15
15
|
deleteFileById,
|
|
16
16
|
} from "../models/file.js";
|
|
17
|
+
import { findConversationMembership } from "../models/message.js";
|
|
17
18
|
|
|
18
19
|
/** Generate a signed URL for file download. Validates user has access. */
|
|
19
20
|
export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
@@ -65,6 +66,11 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
65
66
|
hasAccess = true;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (file.conversationId) {
|
|
70
|
+
const membership = await findConversationMembership(file.conversationId, userId);
|
|
71
|
+
hasAccess = hasAccess || Boolean(membership);
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
if (file.folder?.class) {
|
|
69
75
|
const isTeacher = file.folder.class.teachers.some((t) => t.id === userId);
|
|
70
76
|
const isStudent = file.folder.class.students.some((s) => s.id === userId);
|
package/src/services/labChat.ts
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
import { generateAndSendLabIntroduction, generateAndSendLabResponse } from "../pipelines/aiLabChat.js";
|
|
21
21
|
import { isAIUser } from "../utils/aiUser.js";
|
|
22
22
|
import { logger } from "../utils/logger.js";
|
|
23
|
+
import {
|
|
24
|
+
claimMessageAttachments,
|
|
25
|
+
ensureMessageHasContentOrAttachments,
|
|
26
|
+
} from "./message.js";
|
|
23
27
|
|
|
24
28
|
/** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
|
|
25
29
|
export async function createLabChat(
|
|
@@ -213,9 +217,10 @@ export async function postToLabChat(
|
|
|
213
217
|
labChatId: string;
|
|
214
218
|
content: string;
|
|
215
219
|
mentionedUserIds?: string[];
|
|
220
|
+
fileIds?: string[];
|
|
216
221
|
}
|
|
217
222
|
) {
|
|
218
|
-
const { labChatId, content, mentionedUserIds = [] } = input;
|
|
223
|
+
const { labChatId, content, mentionedUserIds = [], fileIds = [] } = input;
|
|
219
224
|
|
|
220
225
|
const labChat = await findLabChatForPost(labChatId, userId);
|
|
221
226
|
if (!labChat) {
|
|
@@ -238,30 +243,27 @@ export async function postToLabChat(
|
|
|
238
243
|
}
|
|
239
244
|
}
|
|
240
245
|
|
|
246
|
+
ensureMessageHasContentOrAttachments(content, fileIds);
|
|
247
|
+
const normalizedContent = content.trim();
|
|
248
|
+
|
|
241
249
|
const result = await prisma.$transaction(async (tx) => {
|
|
242
250
|
const message = await tx.message.create({
|
|
243
251
|
data: {
|
|
244
|
-
content,
|
|
252
|
+
content: normalizedContent,
|
|
245
253
|
senderId: userId,
|
|
246
254
|
conversationId: labChat.conversationId,
|
|
247
255
|
...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
|
|
248
256
|
},
|
|
249
|
-
include: {
|
|
250
|
-
sender: {
|
|
251
|
-
select: {
|
|
252
|
-
id: true,
|
|
253
|
-
username: true,
|
|
254
|
-
profile: {
|
|
255
|
-
select: {
|
|
256
|
-
displayName: true,
|
|
257
|
-
profilePicture: true,
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
257
|
});
|
|
264
258
|
|
|
259
|
+
await claimMessageAttachments(
|
|
260
|
+
tx,
|
|
261
|
+
userId,
|
|
262
|
+
labChat.conversationId,
|
|
263
|
+
message.id,
|
|
264
|
+
fileIds
|
|
265
|
+
);
|
|
266
|
+
|
|
265
267
|
if (mentionedUserIds.length > 0) {
|
|
266
268
|
await tx.mention.createMany({
|
|
267
269
|
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
@@ -276,7 +278,26 @@ export async function postToLabChat(
|
|
|
276
278
|
data: { updatedAt: new Date() },
|
|
277
279
|
});
|
|
278
280
|
|
|
279
|
-
return message
|
|
281
|
+
return tx.message.findUniqueOrThrow({
|
|
282
|
+
where: { id: message.id },
|
|
283
|
+
include: {
|
|
284
|
+
sender: {
|
|
285
|
+
select: {
|
|
286
|
+
id: true,
|
|
287
|
+
username: true,
|
|
288
|
+
profile: {
|
|
289
|
+
select: {
|
|
290
|
+
displayName: true,
|
|
291
|
+
profilePicture: true,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
attachments: {
|
|
297
|
+
select: { id: true, name: true, type: true },
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
280
301
|
});
|
|
281
302
|
|
|
282
303
|
try {
|
|
@@ -290,7 +311,9 @@ export async function postToLabChat(
|
|
|
290
311
|
conversationId: result.conversationId,
|
|
291
312
|
createdAt: result.createdAt,
|
|
292
313
|
sender: result.sender,
|
|
314
|
+
attachments: result.attachments ?? [],
|
|
293
315
|
mentionedUserIds,
|
|
316
|
+
meta: {},
|
|
294
317
|
}
|
|
295
318
|
);
|
|
296
319
|
} catch (error) {
|
|
@@ -307,7 +330,7 @@ export async function postToLabChat(
|
|
|
307
330
|
} catch (error) {
|
|
308
331
|
console.error("Failed to broadcast lab response pending:", error);
|
|
309
332
|
}
|
|
310
|
-
generateAndSendLabResponse(labChatId,
|
|
333
|
+
generateAndSendLabResponse(labChatId, normalizedContent, {
|
|
311
334
|
classId: labChat.classId,
|
|
312
335
|
messageId: result.id,
|
|
313
336
|
}).catch((error) =>
|
|
@@ -327,6 +350,7 @@ export async function postToLabChat(
|
|
|
327
350
|
conversationId: result.conversationId,
|
|
328
351
|
createdAt: result.createdAt,
|
|
329
352
|
sender: result.sender,
|
|
353
|
+
attachments: result.attachments ?? [],
|
|
330
354
|
mentionedUserIds,
|
|
331
355
|
};
|
|
332
356
|
}
|
package/src/services/message.ts
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* Message service – send, update, delete messages; mark as read; track mentions.
|
|
3
3
|
* Broadcasts real-time updates via Pusher.
|
|
4
4
|
*/
|
|
5
|
+
import type { Prisma } from "@prisma/client";
|
|
5
6
|
import { TRPCError } from "@trpc/server";
|
|
6
7
|
import { prisma } from "../lib/prisma.js";
|
|
7
8
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
9
|
+
import {
|
|
10
|
+
confirmDirectUpload,
|
|
11
|
+
createDirectUploadFiles,
|
|
12
|
+
} from "../lib/fileUpload.js";
|
|
8
13
|
import { logger } from "../utils/logger.js";
|
|
9
14
|
import {
|
|
10
15
|
findConversationMembership,
|
|
@@ -16,6 +21,126 @@ import {
|
|
|
16
21
|
countUnreadMentions,
|
|
17
22
|
} from "../models/message.js";
|
|
18
23
|
|
|
24
|
+
export function ensureMessageHasContentOrAttachments(
|
|
25
|
+
content: string,
|
|
26
|
+
fileIds: string[] = []
|
|
27
|
+
) {
|
|
28
|
+
if (!content.trim() && fileIds.length === 0) {
|
|
29
|
+
throw new TRPCError({
|
|
30
|
+
code: "BAD_REQUEST",
|
|
31
|
+
message: "Message must include content or attachments",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function claimMessageAttachments(
|
|
37
|
+
tx: Prisma.TransactionClient,
|
|
38
|
+
userId: string,
|
|
39
|
+
conversationId: string,
|
|
40
|
+
messageId: string,
|
|
41
|
+
fileIds: string[] = []
|
|
42
|
+
) {
|
|
43
|
+
const uniqueFileIds = [...new Set(fileIds)];
|
|
44
|
+
|
|
45
|
+
if (uniqueFileIds.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { count } = await tx.file.updateMany({
|
|
50
|
+
where: {
|
|
51
|
+
id: { in: uniqueFileIds },
|
|
52
|
+
userId,
|
|
53
|
+
conversationId,
|
|
54
|
+
uploadStatus: "COMPLETED",
|
|
55
|
+
messageId: null,
|
|
56
|
+
},
|
|
57
|
+
data: {
|
|
58
|
+
messageId,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (count !== uniqueFileIds.length) {
|
|
63
|
+
throw new TRPCError({
|
|
64
|
+
code: "BAD_REQUEST",
|
|
65
|
+
message: "Some attachments are missing, unfinished, or unavailable",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getMessageUploadUrls(
|
|
71
|
+
userId: string,
|
|
72
|
+
input: {
|
|
73
|
+
conversationId: string;
|
|
74
|
+
files: { name: string; type: string; size: number }[];
|
|
75
|
+
}
|
|
76
|
+
) {
|
|
77
|
+
const membership = await findConversationMembership(input.conversationId, userId);
|
|
78
|
+
if (!membership) {
|
|
79
|
+
throw new TRPCError({
|
|
80
|
+
code: "FORBIDDEN",
|
|
81
|
+
message: "Not a member of this conversation",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return createDirectUploadFiles(
|
|
86
|
+
input.files,
|
|
87
|
+
userId,
|
|
88
|
+
undefined,
|
|
89
|
+
undefined,
|
|
90
|
+
undefined,
|
|
91
|
+
undefined,
|
|
92
|
+
input.conversationId
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function confirmMessageUpload(
|
|
97
|
+
userId: string,
|
|
98
|
+
input: {
|
|
99
|
+
conversationId: string;
|
|
100
|
+
fileId: string;
|
|
101
|
+
uploadSuccess: boolean;
|
|
102
|
+
errorMessage?: string;
|
|
103
|
+
}
|
|
104
|
+
) {
|
|
105
|
+
const membership = await findConversationMembership(input.conversationId, userId);
|
|
106
|
+
if (!membership) {
|
|
107
|
+
throw new TRPCError({
|
|
108
|
+
code: "FORBIDDEN",
|
|
109
|
+
message: "Not a member of this conversation",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const file = await prisma.file.findFirst({
|
|
114
|
+
where: {
|
|
115
|
+
id: input.fileId,
|
|
116
|
+
userId,
|
|
117
|
+
conversationId: input.conversationId,
|
|
118
|
+
messageId: null,
|
|
119
|
+
},
|
|
120
|
+
select: { id: true },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!file) {
|
|
124
|
+
throw new TRPCError({
|
|
125
|
+
code: "NOT_FOUND",
|
|
126
|
+
message: "File not found or you do not have permission",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await confirmDirectUpload(
|
|
131
|
+
input.fileId,
|
|
132
|
+
input.uploadSuccess,
|
|
133
|
+
input.errorMessage
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
success: true,
|
|
138
|
+
message: input.uploadSuccess
|
|
139
|
+
? "Upload confirmed successfully"
|
|
140
|
+
: "Upload failed",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
19
144
|
/** List messages in a conversation with cursor-based pagination. */
|
|
20
145
|
export async function listMessages(
|
|
21
146
|
userId: string,
|
|
@@ -74,9 +199,18 @@ export async function sendMessage(
|
|
|
74
199
|
conversationId: string;
|
|
75
200
|
content: string;
|
|
76
201
|
mentionedUserIds?: string[];
|
|
202
|
+
fileIds?: string[];
|
|
77
203
|
}
|
|
78
204
|
) {
|
|
79
|
-
const {
|
|
205
|
+
const {
|
|
206
|
+
conversationId,
|
|
207
|
+
content,
|
|
208
|
+
mentionedUserIds = [],
|
|
209
|
+
fileIds = [],
|
|
210
|
+
} = input;
|
|
211
|
+
|
|
212
|
+
ensureMessageHasContentOrAttachments(content, fileIds);
|
|
213
|
+
const normalizedContent = content.trim();
|
|
80
214
|
|
|
81
215
|
const membership = await findConversationMembership(conversationId, userId);
|
|
82
216
|
if (!membership) {
|
|
@@ -102,10 +236,36 @@ export async function sendMessage(
|
|
|
102
236
|
const result = await prisma.$transaction(async (tx) => {
|
|
103
237
|
const message = await tx.message.create({
|
|
104
238
|
data: {
|
|
105
|
-
content,
|
|
239
|
+
content: normalizedContent,
|
|
106
240
|
senderId: userId,
|
|
107
241
|
conversationId,
|
|
108
242
|
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await claimMessageAttachments(
|
|
246
|
+
tx,
|
|
247
|
+
userId,
|
|
248
|
+
conversationId,
|
|
249
|
+
message.id,
|
|
250
|
+
fileIds
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (mentionedUserIds.length > 0) {
|
|
254
|
+
await tx.mention.createMany({
|
|
255
|
+
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
256
|
+
messageId: message.id,
|
|
257
|
+
userId: mentionedUserId,
|
|
258
|
+
})),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await tx.conversation.update({
|
|
263
|
+
where: { id: conversationId },
|
|
264
|
+
data: { updatedAt: new Date() },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return tx.message.findUniqueOrThrow({
|
|
268
|
+
where: { id: message.id },
|
|
109
269
|
include: {
|
|
110
270
|
sender: {
|
|
111
271
|
select: {
|
|
@@ -124,22 +284,6 @@ export async function sendMessage(
|
|
|
124
284
|
},
|
|
125
285
|
},
|
|
126
286
|
});
|
|
127
|
-
|
|
128
|
-
if (mentionedUserIds.length > 0) {
|
|
129
|
-
await tx.mention.createMany({
|
|
130
|
-
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
131
|
-
messageId: message.id,
|
|
132
|
-
userId: mentionedUserId,
|
|
133
|
-
})),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
await tx.conversation.update({
|
|
138
|
-
where: { id: conversationId },
|
|
139
|
-
data: { updatedAt: new Date() },
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
return message;
|
|
143
287
|
});
|
|
144
288
|
|
|
145
289
|
try {
|
|
@@ -173,6 +317,7 @@ export async function sendMessage(
|
|
|
173
317
|
conversationId: result.conversationId,
|
|
174
318
|
createdAt: result.createdAt,
|
|
175
319
|
sender: result.sender,
|
|
320
|
+
attachments: result.attachments ?? [],
|
|
176
321
|
mentionedUserIds,
|
|
177
322
|
};
|
|
178
323
|
}
|
|
@@ -7,6 +7,10 @@ import { logger } from "../utils/logger.js";
|
|
|
7
7
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
8
8
|
import { isAIUser } from "../utils/aiUser.js";
|
|
9
9
|
import { generateAndSendNewtonIntroduction, generateAndSendNewtonResponse } from "../pipelines/aiNewtonChat.js";
|
|
10
|
+
import {
|
|
11
|
+
claimMessageAttachments,
|
|
12
|
+
ensureMessageHasContentOrAttachments,
|
|
13
|
+
} from "./message.js";
|
|
10
14
|
import {
|
|
11
15
|
findClassWithStudent,
|
|
12
16
|
findSubmissionByAssignmentAndStudent,
|
|
@@ -118,7 +122,8 @@ export async function postToNewtonChat(
|
|
|
118
122
|
userId: string,
|
|
119
123
|
newtonChatId: string,
|
|
120
124
|
content: string,
|
|
121
|
-
mentionedUserIds: string[] = []
|
|
125
|
+
mentionedUserIds: string[] = [],
|
|
126
|
+
fileIds: string[] = []
|
|
122
127
|
) {
|
|
123
128
|
const newtonChat = await findNewtonChatByIdAndMember(newtonChatId, userId);
|
|
124
129
|
if (!newtonChat) {
|
|
@@ -141,29 +146,33 @@ export async function postToNewtonChat(
|
|
|
141
146
|
}
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
ensureMessageHasContentOrAttachments(content, fileIds);
|
|
150
|
+
const normalizedContent = content.trim();
|
|
151
|
+
|
|
152
|
+
if (!normalizedContent) {
|
|
153
|
+
throw new TRPCError({
|
|
154
|
+
code: "BAD_REQUEST",
|
|
155
|
+
message: "Newton chat messages must include text content",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
144
159
|
const result = await prisma.$transaction(async (tx) => {
|
|
145
160
|
const message = await tx.message.create({
|
|
146
161
|
data: {
|
|
147
|
-
content,
|
|
162
|
+
content: normalizedContent,
|
|
148
163
|
senderId: userId,
|
|
149
164
|
conversationId: newtonChat.conversationId,
|
|
150
165
|
},
|
|
151
|
-
include: {
|
|
152
|
-
sender: {
|
|
153
|
-
select: {
|
|
154
|
-
id: true,
|
|
155
|
-
username: true,
|
|
156
|
-
profile: {
|
|
157
|
-
select: {
|
|
158
|
-
displayName: true,
|
|
159
|
-
profilePicture: true,
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
await claimMessageAttachments(
|
|
169
|
+
tx,
|
|
170
|
+
userId,
|
|
171
|
+
newtonChat.conversationId,
|
|
172
|
+
message.id,
|
|
173
|
+
fileIds
|
|
174
|
+
);
|
|
175
|
+
|
|
167
176
|
if (mentionedUserIds.length > 0) {
|
|
168
177
|
await tx.mention.createMany({
|
|
169
178
|
data: mentionedUserIds.map((mentionedUserId) => ({
|
|
@@ -178,7 +187,26 @@ export async function postToNewtonChat(
|
|
|
178
187
|
data: { updatedAt: new Date() },
|
|
179
188
|
});
|
|
180
189
|
|
|
181
|
-
return message
|
|
190
|
+
return tx.message.findUniqueOrThrow({
|
|
191
|
+
where: { id: message.id },
|
|
192
|
+
include: {
|
|
193
|
+
sender: {
|
|
194
|
+
select: {
|
|
195
|
+
id: true,
|
|
196
|
+
username: true,
|
|
197
|
+
profile: {
|
|
198
|
+
select: {
|
|
199
|
+
displayName: true,
|
|
200
|
+
profilePicture: true,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
attachments: {
|
|
206
|
+
select: { id: true, name: true, type: true },
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
});
|
|
182
210
|
});
|
|
183
211
|
|
|
184
212
|
try {
|
|
@@ -192,7 +220,9 @@ export async function postToNewtonChat(
|
|
|
192
220
|
conversationId: result.conversationId,
|
|
193
221
|
createdAt: result.createdAt,
|
|
194
222
|
sender: result.sender,
|
|
223
|
+
attachments: result.attachments ?? [],
|
|
195
224
|
mentionedUserIds,
|
|
225
|
+
meta: {},
|
|
196
226
|
}
|
|
197
227
|
);
|
|
198
228
|
} catch (error) {
|
|
@@ -202,7 +232,7 @@ export async function postToNewtonChat(
|
|
|
202
232
|
if (!isAIUser(userId)) {
|
|
203
233
|
generateAndSendNewtonResponse(
|
|
204
234
|
newtonChatId,
|
|
205
|
-
|
|
235
|
+
normalizedContent,
|
|
206
236
|
newtonChat.conversationId,
|
|
207
237
|
newtonChat.submission
|
|
208
238
|
).catch((error) => {
|
|
@@ -217,6 +247,7 @@ export async function postToNewtonChat(
|
|
|
217
247
|
conversationId: result.conversationId,
|
|
218
248
|
createdAt: result.createdAt,
|
|
219
249
|
sender: result.sender,
|
|
250
|
+
attachments: result.attachments ?? [],
|
|
220
251
|
mentionedUserIds,
|
|
221
252
|
};
|
|
222
253
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Notification service – list, send, and mark notifications as read.
|
|
3
3
|
*/
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
4
5
|
import {
|
|
5
6
|
findNotificationsByReceiverId,
|
|
6
|
-
|
|
7
|
+
findNotificationByIdForReceiver,
|
|
7
8
|
createNotification,
|
|
8
9
|
createManyNotifications,
|
|
9
|
-
|
|
10
|
+
markNotificationAsReadForReceiver,
|
|
10
11
|
} from "../models/notification.js";
|
|
11
12
|
|
|
12
13
|
/** List notifications for a user. */
|
|
@@ -14,9 +15,18 @@ export async function listNotifications(userId: string) {
|
|
|
14
15
|
return findNotificationsByReceiverId(userId);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
/** Get a single notification by ID. */
|
|
18
|
-
export async function getNotification(id: string) {
|
|
19
|
-
|
|
18
|
+
/** Get a single notification by ID for its receiver. */
|
|
19
|
+
export async function getNotification(userId: string, id: string) {
|
|
20
|
+
const notification = await findNotificationByIdForReceiver(id, userId);
|
|
21
|
+
|
|
22
|
+
if (!notification) {
|
|
23
|
+
throw new TRPCError({
|
|
24
|
+
code: "NOT_FOUND",
|
|
25
|
+
message: "Notification not found",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return notification;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/** Send a notification to one user. */
|
|
@@ -44,7 +54,16 @@ export async function sendToMultiple(input: {
|
|
|
44
54
|
return result;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
/** Mark a notification as read. */
|
|
48
|
-
export async function markAsRead(id: string) {
|
|
49
|
-
|
|
57
|
+
/** Mark a notification as read for its receiver. */
|
|
58
|
+
export async function markAsRead(userId: string, id: string) {
|
|
59
|
+
const notification = await markNotificationAsReadForReceiver(id, userId);
|
|
60
|
+
|
|
61
|
+
if (!notification) {
|
|
62
|
+
throw new TRPCError({
|
|
63
|
+
code: "NOT_FOUND",
|
|
64
|
+
message: "Notification not found",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return notification;
|
|
50
69
|
}
|