@studious-lms/server 1.4.4 → 1.4.6
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/dist/index.js +3 -17
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +8 -3
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/models/comment.d.ts +163 -0
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +145 -3
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +22 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +13 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/models/labChat.d.ts +3 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +2 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +2 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +4 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/routers/_app.d.ts +665 -1
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/announcement.d.ts +8 -0
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +230 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +207 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/comment.d.ts +37 -0
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/comment.js +14 -5
- package/dist/routers/comment.js.map +1 -1
- package/dist/routers/conversation.d.ts +14 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/conversation.js +6 -3
- package/dist/routers/conversation.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/search.d.ts +41 -0
- package/dist/routers/search.d.ts.map +1 -0
- package/dist/routers/search.js +15 -0
- package/dist/routers/search.js.map +1 -0
- package/dist/routers/worksheet.d.ts +2 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/services/comment.d.ts +28 -2
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/comment.js +97 -19
- package/dist/services/comment.js.map +1 -1
- package/dist/services/conversation.d.ts +8 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +24 -6
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +9 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/search.d.ts +21 -0
- package/dist/services/search.d.ts.map +1 -0
- package/dist/services/search.js +352 -0
- package/dist/services/search.js.map +1 -0
- package/dist/services/worksheet.d.ts +2 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
- package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +60 -0
- package/prisma/schema.prisma +10 -0
- package/src/index.ts +1 -17
- package/src/lib/config/cors.ts +8 -1
- package/src/models/comment.ts +148 -1
- package/src/models/conversation.ts +12 -0
- package/src/pipelines/aiLabChat.ts +2 -1
- package/src/routers/_app.ts +2 -0
- package/src/routers/assignment.ts +224 -1
- package/src/routers/comment.ts +19 -2
- package/src/routers/conversation.ts +7 -0
- package/src/routers/search.ts +14 -0
- package/src/services/comment.ts +113 -18
- package/src/services/conversation.ts +27 -3
- package/src/services/message.ts +8 -0
- package/src/services/search.ts +397 -0
- package/tests/lib/cors.test.ts +13 -1
- package/tests/routers/assignment.test.ts +68 -2
- package/tests/routers/comment.test.ts +42 -1
- package/tests/routers/conversation.test.ts +114 -2
- package/tests/routers/search.test.ts +109 -0
- package/vercel.json +8 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
3
|
+
import { searchGlobalClasses } from "../services/search.js";
|
|
4
|
+
|
|
5
|
+
export const searchRouter = createTRPCRouter({
|
|
6
|
+
global: protectedProcedure
|
|
7
|
+
.input(
|
|
8
|
+
z.object({
|
|
9
|
+
query: z.string().max(80).optional(),
|
|
10
|
+
limit: z.number().int().min(1).max(30).optional(),
|
|
11
|
+
})
|
|
12
|
+
)
|
|
13
|
+
.query(({ ctx, input }) => searchGlobalClasses(ctx.user!.id, input)),
|
|
14
|
+
});
|
package/src/services/comment.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Comment service – get comments, replies, and reactions.
|
|
2
|
+
* Comment service – get comments, replies, and reactions.
|
|
3
3
|
*/
|
|
4
4
|
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import { prisma } from "../lib/prisma.js";
|
|
5
6
|
import {
|
|
6
7
|
findCommentById,
|
|
7
8
|
findRepliesByCommentId,
|
|
@@ -9,12 +10,81 @@ import {
|
|
|
9
10
|
findReactionByUserAndComment,
|
|
10
11
|
upsertReaction,
|
|
11
12
|
deleteReactionById,
|
|
12
|
-
|
|
13
|
+
findCommentWithContext,
|
|
13
14
|
getReactionCountsByCommentId,
|
|
14
15
|
} from "../models/comment.js";
|
|
15
16
|
|
|
17
|
+
function getCommentClass(comment: any) {
|
|
18
|
+
return (
|
|
19
|
+
comment.announcement?.class ??
|
|
20
|
+
comment.assignment?.class ??
|
|
21
|
+
comment.submission?.assignment?.class ??
|
|
22
|
+
comment.studentQuestionProgress?.studentWorksheetResponse?.submission?.assignment?.class ??
|
|
23
|
+
comment.parentComment?.announcement?.class ??
|
|
24
|
+
comment.parentComment?.assignment?.class ??
|
|
25
|
+
comment.parentComment?.submission?.assignment?.class ??
|
|
26
|
+
comment.parentComment?.studentQuestionProgress?.studentWorksheetResponse?.submission?.assignment?.class ??
|
|
27
|
+
null
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getPrivateStudentId(comment: any) {
|
|
32
|
+
return (
|
|
33
|
+
comment.submission?.studentId ??
|
|
34
|
+
comment.studentQuestionProgress?.studentWorksheetResponse?.submission?.studentId ??
|
|
35
|
+
comment.studentQuestionProgress?.studentId ??
|
|
36
|
+
comment.parentComment?.submission?.studentId ??
|
|
37
|
+
comment.parentComment?.studentQuestionProgress?.studentWorksheetResponse?.submission?.studentId ??
|
|
38
|
+
comment.parentComment?.studentQuestionProgress?.studentId ??
|
|
39
|
+
null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isClassTeacher(comment: any, userId: string) {
|
|
44
|
+
return getCommentClass(comment)?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ?? false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isClassMember(comment: any, userId: string) {
|
|
48
|
+
const classData = getCommentClass(comment);
|
|
49
|
+
return (
|
|
50
|
+
classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
|
|
51
|
+
classData?.students?.some((student: { id: string }) => student.id === userId) ||
|
|
52
|
+
false
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function canReadComment(comment: any, userId: string) {
|
|
57
|
+
const privateStudentId = getPrivateStudentId(comment);
|
|
58
|
+
if (privateStudentId) {
|
|
59
|
+
return privateStudentId === userId || isClassTeacher(comment, userId);
|
|
60
|
+
}
|
|
61
|
+
return isClassMember(comment, userId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canDeleteComment(comment: any, userId: string) {
|
|
65
|
+
return comment.authorId === userId || isClassTeacher(comment, userId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requireCommentAccess(commentId: string, userId: string) {
|
|
69
|
+
const comment = await findCommentWithContext(commentId);
|
|
70
|
+
if (!comment) {
|
|
71
|
+
throw new TRPCError({
|
|
72
|
+
code: "NOT_FOUND",
|
|
73
|
+
message: "Comment not found",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!canReadComment(comment, userId)) {
|
|
77
|
+
throw new TRPCError({
|
|
78
|
+
code: "FORBIDDEN",
|
|
79
|
+
message: "You do not have access to this comment",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return comment;
|
|
83
|
+
}
|
|
84
|
+
|
|
16
85
|
/** Get a comment by ID. */
|
|
17
|
-
export async function getComment(id: string) {
|
|
86
|
+
export async function getComment(userId: string, id: string) {
|
|
87
|
+
await requireCommentAccess(id, userId);
|
|
18
88
|
const comment = await findCommentById(id);
|
|
19
89
|
if (!comment) {
|
|
20
90
|
throw new TRPCError({
|
|
@@ -26,7 +96,8 @@ export async function getComment(id: string) {
|
|
|
26
96
|
}
|
|
27
97
|
|
|
28
98
|
/** Get all replies to a comment. */
|
|
29
|
-
export async function getReplies(commentId: string) {
|
|
99
|
+
export async function getReplies(userId: string, commentId: string) {
|
|
100
|
+
await requireCommentAccess(commentId, userId);
|
|
30
101
|
return findRepliesByCommentId(commentId);
|
|
31
102
|
}
|
|
32
103
|
|
|
@@ -36,26 +107,55 @@ export async function replyToComment(
|
|
|
36
107
|
parentCommentId: string,
|
|
37
108
|
content: string
|
|
38
109
|
) {
|
|
110
|
+
const parentComment = await requireCommentAccess(parentCommentId, userId);
|
|
39
111
|
return createComment({
|
|
40
112
|
parentCommentId,
|
|
41
113
|
content,
|
|
42
114
|
authorId: userId,
|
|
115
|
+
announcementId: parentComment.announcementId,
|
|
116
|
+
assignmentId: parentComment.assignmentId,
|
|
117
|
+
submissionId: parentComment.submissionId,
|
|
118
|
+
studentQuestionProgressId: parentComment.studentQuestionProgressId,
|
|
43
119
|
});
|
|
44
120
|
}
|
|
45
121
|
|
|
122
|
+
/** Update a comment authored by the user. */
|
|
123
|
+
export async function updateComment(userId: string, id: string, content: string) {
|
|
124
|
+
const comment = await requireCommentAccess(id, userId);
|
|
125
|
+
if (comment.authorId !== userId) {
|
|
126
|
+
throw new TRPCError({
|
|
127
|
+
code: "FORBIDDEN",
|
|
128
|
+
message: "Only the comment author can update this comment",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return prisma.comment.update({
|
|
133
|
+
where: { id },
|
|
134
|
+
data: { content },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Delete a comment authored by the user or moderated by a class teacher. */
|
|
139
|
+
export async function deleteComment(userId: string, id: string) {
|
|
140
|
+
const comment = await requireCommentAccess(id, userId);
|
|
141
|
+
if (!canDeleteComment(comment, userId)) {
|
|
142
|
+
throw new TRPCError({
|
|
143
|
+
code: "FORBIDDEN",
|
|
144
|
+
message: "Only the comment author or class teachers can delete comments",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await prisma.comment.delete({ where: { id } });
|
|
149
|
+
return { success: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
46
152
|
/** Add or update a reaction (emoji) on a comment. */
|
|
47
153
|
export async function addReaction(
|
|
48
154
|
userId: string,
|
|
49
155
|
commentId: string,
|
|
50
156
|
type: string
|
|
51
157
|
) {
|
|
52
|
-
|
|
53
|
-
if (!comment) {
|
|
54
|
-
throw new TRPCError({
|
|
55
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
56
|
-
message: "Unexpected error",
|
|
57
|
-
});
|
|
58
|
-
}
|
|
158
|
+
await requireCommentAccess(commentId, userId);
|
|
59
159
|
|
|
60
160
|
const reaction = await upsertReaction({ userId, commentId, type });
|
|
61
161
|
return { reaction };
|
|
@@ -63,6 +163,7 @@ export async function addReaction(
|
|
|
63
163
|
|
|
64
164
|
/** Remove user's reaction from a comment. */
|
|
65
165
|
export async function removeReaction(userId: string, commentId: string) {
|
|
166
|
+
await requireCommentAccess(commentId, userId);
|
|
66
167
|
const reaction = await findReactionByUserAndComment(userId, commentId);
|
|
67
168
|
if (!reaction) {
|
|
68
169
|
throw new TRPCError({
|
|
@@ -75,13 +176,7 @@ export async function removeReaction(userId: string, commentId: string) {
|
|
|
75
176
|
}
|
|
76
177
|
|
|
77
178
|
export async function getReactions(userId: string, commentId: string) {
|
|
78
|
-
|
|
79
|
-
if (!comment) {
|
|
80
|
-
throw new TRPCError({
|
|
81
|
-
code: "NOT_FOUND",
|
|
82
|
-
message: "Comment not found",
|
|
83
|
-
});
|
|
84
|
-
}
|
|
179
|
+
await requireCommentAccess(commentId, userId);
|
|
85
180
|
|
|
86
181
|
const reactionCounts = await getReactionCountsByCommentId(commentId);
|
|
87
182
|
const userReaction = await findReactionByUserAndComment(userId, commentId);
|
|
@@ -13,15 +13,25 @@ import {
|
|
|
13
13
|
createConversation,
|
|
14
14
|
findConversationByIdAndMember,
|
|
15
15
|
createConversationMember,
|
|
16
|
+
hideConversationMember,
|
|
16
17
|
deleteConversationMember,
|
|
17
18
|
} from "../models/conversation.js";
|
|
18
19
|
|
|
19
20
|
/** List user's conversations with unread and mention counts. */
|
|
20
21
|
export async function listConversations(userId: string) {
|
|
21
22
|
const conversations = await findConversationsByUserId(userId);
|
|
23
|
+
const visibleConversations = conversations.filter((conversation) => {
|
|
24
|
+
const userMembership = conversation.members.find((member) => member.userId === userId);
|
|
25
|
+
|
|
26
|
+
if (!userMembership?.hiddenAt) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return conversation.updatedAt > userMembership.hiddenAt;
|
|
31
|
+
});
|
|
22
32
|
|
|
23
33
|
const conversationsWithUnread = await Promise.all(
|
|
24
|
-
|
|
34
|
+
visibleConversations.map(async (conversation) => {
|
|
25
35
|
const userMembership = conversation.members.find(
|
|
26
36
|
(m) => m.userId === userId
|
|
27
37
|
);
|
|
@@ -65,6 +75,20 @@ export async function listConversations(userId: string) {
|
|
|
65
75
|
return conversationsWithUnread;
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
export async function hideConversation(userId: string, conversationId: string) {
|
|
79
|
+
const conversation = await findConversationByIdAndMember(conversationId, userId);
|
|
80
|
+
if (!conversation) {
|
|
81
|
+
throw new TRPCError({
|
|
82
|
+
code: "FORBIDDEN",
|
|
83
|
+
message: "Not a member of this conversation",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await hideConversationMember(userId, conversationId);
|
|
88
|
+
|
|
89
|
+
return { success: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/** Create a DM or group conversation with the given members. */
|
|
69
93
|
export async function createConversationRecord(
|
|
70
94
|
userId: string,
|
|
@@ -87,11 +111,11 @@ export async function createConversationRecord(
|
|
|
87
111
|
}
|
|
88
112
|
|
|
89
113
|
if (type === "DM") {
|
|
90
|
-
const targetUser = await
|
|
114
|
+
const [targetUser] = await findUsersByIdsOrUsernames([memberIds[0]]);
|
|
91
115
|
if (!targetUser) {
|
|
92
116
|
throw new TRPCError({
|
|
93
117
|
code: "BAD_REQUEST",
|
|
94
|
-
message: `User
|
|
118
|
+
message: `User not found`,
|
|
95
119
|
});
|
|
96
120
|
}
|
|
97
121
|
|
package/src/services/message.ts
CHANGED
|
@@ -259,6 +259,14 @@ export async function sendMessage(
|
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
await tx.conversationMember.updateMany({
|
|
263
|
+
where: {
|
|
264
|
+
conversationId,
|
|
265
|
+
hiddenAt: { not: null },
|
|
266
|
+
},
|
|
267
|
+
data: { hiddenAt: null },
|
|
268
|
+
});
|
|
269
|
+
|
|
262
270
|
await tx.conversation.update({
|
|
263
271
|
where: { id: conversationId },
|
|
264
272
|
data: { updatedAt: new Date() },
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { prisma } from "../lib/prisma.js";
|
|
2
|
+
|
|
3
|
+
type SearchScope =
|
|
4
|
+
| "class"
|
|
5
|
+
| "assignment"
|
|
6
|
+
| "event"
|
|
7
|
+
| "syllabus"
|
|
8
|
+
| "worksheet"
|
|
9
|
+
| "file"
|
|
10
|
+
| "announcement"
|
|
11
|
+
| "section";
|
|
12
|
+
|
|
13
|
+
type SearchResult = {
|
|
14
|
+
id: string;
|
|
15
|
+
type: SearchScope;
|
|
16
|
+
title: string;
|
|
17
|
+
subtitle: string;
|
|
18
|
+
classId: string;
|
|
19
|
+
className: string;
|
|
20
|
+
href: string;
|
|
21
|
+
matchedText?: string | null;
|
|
22
|
+
date?: Date | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SEARCH_LIMIT = 12;
|
|
26
|
+
|
|
27
|
+
const contains = (query: string) => ({
|
|
28
|
+
contains: query,
|
|
29
|
+
mode: "insensitive" as const,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const includesToken = (query: string, ...tokens: string[]) =>
|
|
33
|
+
tokens.some((token) => token.includes(query.toLowerCase()));
|
|
34
|
+
|
|
35
|
+
const excerpt = (value: string | null | undefined, query: string) => {
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
const trimmed = value.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!trimmed) return null;
|
|
39
|
+
if (!query) return trimmed.slice(0, 140);
|
|
40
|
+
|
|
41
|
+
const index = trimmed.toLowerCase().indexOf(query.toLowerCase());
|
|
42
|
+
if (index < 0) return trimmed.slice(0, 140);
|
|
43
|
+
|
|
44
|
+
const start = Math.max(0, index - 48);
|
|
45
|
+
const end = Math.min(trimmed.length, index + query.length + 92);
|
|
46
|
+
return `${start > 0 ? "..." : ""}${trimmed.slice(start, end)}${end < trimmed.length ? "..." : ""}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const trimResults = (results: SearchResult[], limit: number) =>
|
|
50
|
+
results
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const priority: Record<SearchScope, number> = {
|
|
53
|
+
class: 0,
|
|
54
|
+
assignment: 1,
|
|
55
|
+
event: 2,
|
|
56
|
+
syllabus: 3,
|
|
57
|
+
worksheet: 4,
|
|
58
|
+
file: 5,
|
|
59
|
+
announcement: 6,
|
|
60
|
+
section: 7,
|
|
61
|
+
};
|
|
62
|
+
const priorityDiff = priority[a.type] - priority[b.type];
|
|
63
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
64
|
+
return (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0);
|
|
65
|
+
})
|
|
66
|
+
.slice(0, limit);
|
|
67
|
+
|
|
68
|
+
export async function searchGlobalClasses(
|
|
69
|
+
userId: string,
|
|
70
|
+
input: { query?: string; limit?: number }
|
|
71
|
+
) {
|
|
72
|
+
const rawQuery = input.query?.trim() ?? "";
|
|
73
|
+
const query = rawQuery.slice(0, 80);
|
|
74
|
+
const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
|
|
75
|
+
|
|
76
|
+
const memberships = await prisma.class.findMany({
|
|
77
|
+
where: {
|
|
78
|
+
OR: [
|
|
79
|
+
{ teachers: { some: { id: userId } } },
|
|
80
|
+
{ students: { some: { id: userId } } },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
select: {
|
|
84
|
+
id: true,
|
|
85
|
+
name: true,
|
|
86
|
+
section: true,
|
|
87
|
+
subject: true,
|
|
88
|
+
syllabus: true,
|
|
89
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
90
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const allClassIds = memberships.map((cls) => cls.id);
|
|
95
|
+
if (allClassIds.length === 0) {
|
|
96
|
+
return { query, results: [] as SearchResult[] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const teacherClassIds = memberships
|
|
100
|
+
.filter((cls) => cls.teachers.length > 0)
|
|
101
|
+
.map((cls) => cls.id);
|
|
102
|
+
const studentClassIds = memberships
|
|
103
|
+
.filter((cls) => cls.students.length > 0 && cls.teachers.length === 0)
|
|
104
|
+
.map((cls) => cls.id);
|
|
105
|
+
const classById = new Map(memberships.map((cls) => [cls.id, cls]));
|
|
106
|
+
|
|
107
|
+
const queryFilters = query
|
|
108
|
+
? {
|
|
109
|
+
class: [
|
|
110
|
+
{ name: contains(query) },
|
|
111
|
+
{ section: contains(query) },
|
|
112
|
+
{ subject: contains(query) },
|
|
113
|
+
],
|
|
114
|
+
assignment: [
|
|
115
|
+
{ title: contains(query) },
|
|
116
|
+
{ instructions: contains(query) },
|
|
117
|
+
],
|
|
118
|
+
event: [
|
|
119
|
+
{ name: contains(query) },
|
|
120
|
+
{ location: contains(query) },
|
|
121
|
+
{ remarks: contains(query) },
|
|
122
|
+
],
|
|
123
|
+
worksheet: [{ name: contains(query) }],
|
|
124
|
+
file: [{ name: contains(query) }, { type: contains(query) }],
|
|
125
|
+
announcement: [{ remarks: contains(query) }],
|
|
126
|
+
section: [{ name: contains(query) }],
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
const assignmentAccess = [
|
|
131
|
+
...(teacherClassIds.length > 0
|
|
132
|
+
? [{ classId: { in: teacherClassIds } }]
|
|
133
|
+
: []),
|
|
134
|
+
...(studentClassIds.length > 0
|
|
135
|
+
? [
|
|
136
|
+
{
|
|
137
|
+
classId: { in: studentClassIds },
|
|
138
|
+
OR: [
|
|
139
|
+
{ assignedTo: { some: { id: userId } } },
|
|
140
|
+
{ assignedTo: { none: {} } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const [
|
|
148
|
+
classes,
|
|
149
|
+
assignments,
|
|
150
|
+
events,
|
|
151
|
+
worksheets,
|
|
152
|
+
files,
|
|
153
|
+
announcements,
|
|
154
|
+
sections,
|
|
155
|
+
] = await Promise.all([
|
|
156
|
+
prisma.class.findMany({
|
|
157
|
+
where: {
|
|
158
|
+
id: { in: allClassIds },
|
|
159
|
+
...(queryFilters ? { OR: queryFilters.class } : {}),
|
|
160
|
+
},
|
|
161
|
+
select: { id: true, name: true, section: true, subject: true },
|
|
162
|
+
take: limit,
|
|
163
|
+
}),
|
|
164
|
+
prisma.assignment.findMany({
|
|
165
|
+
where: {
|
|
166
|
+
template: false,
|
|
167
|
+
AND: [
|
|
168
|
+
...(queryFilters ? [{ OR: queryFilters.assignment }] : []),
|
|
169
|
+
...(assignmentAccess.length > 0 ? [{ OR: assignmentAccess }] : []),
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
select: {
|
|
173
|
+
id: true,
|
|
174
|
+
title: true,
|
|
175
|
+
instructions: true,
|
|
176
|
+
dueDate: true,
|
|
177
|
+
modifiedAt: true,
|
|
178
|
+
classId: true,
|
|
179
|
+
class: { select: { name: true } },
|
|
180
|
+
},
|
|
181
|
+
orderBy: { dueDate: "asc" },
|
|
182
|
+
take: limit,
|
|
183
|
+
}),
|
|
184
|
+
prisma.event.findMany({
|
|
185
|
+
where: {
|
|
186
|
+
classId: { in: allClassIds },
|
|
187
|
+
...(queryFilters ? { OR: queryFilters.event } : {}),
|
|
188
|
+
},
|
|
189
|
+
select: {
|
|
190
|
+
id: true,
|
|
191
|
+
name: true,
|
|
192
|
+
location: true,
|
|
193
|
+
remarks: true,
|
|
194
|
+
startTime: true,
|
|
195
|
+
classId: true,
|
|
196
|
+
class: { select: { name: true } },
|
|
197
|
+
},
|
|
198
|
+
orderBy: { startTime: "asc" },
|
|
199
|
+
take: limit,
|
|
200
|
+
}),
|
|
201
|
+
prisma.worksheet.findMany({
|
|
202
|
+
where: {
|
|
203
|
+
classId: { in: allClassIds },
|
|
204
|
+
...(queryFilters && !includesToken(query, "worksheet", "worksheets")
|
|
205
|
+
? { OR: queryFilters.worksheet }
|
|
206
|
+
: {}),
|
|
207
|
+
},
|
|
208
|
+
select: {
|
|
209
|
+
id: true,
|
|
210
|
+
name: true,
|
|
211
|
+
updatedAt: true,
|
|
212
|
+
classId: true,
|
|
213
|
+
class: { select: { name: true } },
|
|
214
|
+
},
|
|
215
|
+
orderBy: { updatedAt: "desc" },
|
|
216
|
+
take: limit,
|
|
217
|
+
}),
|
|
218
|
+
prisma.file.findMany({
|
|
219
|
+
where: {
|
|
220
|
+
...(queryFilters ? { OR: queryFilters.file } : {}),
|
|
221
|
+
AND: [
|
|
222
|
+
{
|
|
223
|
+
OR: [
|
|
224
|
+
{ folder: { classId: { in: allClassIds } } },
|
|
225
|
+
{ classDraftId: { in: teacherClassIds } },
|
|
226
|
+
{
|
|
227
|
+
assignment: {
|
|
228
|
+
OR: assignmentAccess,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{ announcement: { classId: { in: allClassIds } } },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
select: {
|
|
237
|
+
id: true,
|
|
238
|
+
name: true,
|
|
239
|
+
type: true,
|
|
240
|
+
uploadedAt: true,
|
|
241
|
+
folder: { select: { classId: true, class: { select: { name: true } } } },
|
|
242
|
+
classDraft: { select: { id: true, name: true } },
|
|
243
|
+
assignment: { select: { classId: true, class: { select: { name: true } } } },
|
|
244
|
+
announcement: { select: { classId: true, class: { select: { name: true } } } },
|
|
245
|
+
},
|
|
246
|
+
orderBy: { uploadedAt: "desc" },
|
|
247
|
+
take: limit,
|
|
248
|
+
}),
|
|
249
|
+
prisma.announcement.findMany({
|
|
250
|
+
where: {
|
|
251
|
+
classId: { in: allClassIds },
|
|
252
|
+
...(queryFilters ? { OR: queryFilters.announcement } : {}),
|
|
253
|
+
},
|
|
254
|
+
select: {
|
|
255
|
+
id: true,
|
|
256
|
+
remarks: true,
|
|
257
|
+
createdAt: true,
|
|
258
|
+
classId: true,
|
|
259
|
+
class: { select: { name: true } },
|
|
260
|
+
},
|
|
261
|
+
orderBy: { createdAt: "desc" },
|
|
262
|
+
take: limit,
|
|
263
|
+
}),
|
|
264
|
+
prisma.section.findMany({
|
|
265
|
+
where: {
|
|
266
|
+
classId: { in: allClassIds },
|
|
267
|
+
...(queryFilters && !includesToken(query, "section", "sections", "assignment", "assignments")
|
|
268
|
+
? { OR: queryFilters.section }
|
|
269
|
+
: {}),
|
|
270
|
+
},
|
|
271
|
+
select: {
|
|
272
|
+
id: true,
|
|
273
|
+
name: true,
|
|
274
|
+
classId: true,
|
|
275
|
+
class: { select: { name: true } },
|
|
276
|
+
},
|
|
277
|
+
take: limit,
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const results: SearchResult[] = [
|
|
282
|
+
...classes.map((cls) => ({
|
|
283
|
+
id: cls.id,
|
|
284
|
+
type: "class" as const,
|
|
285
|
+
title: cls.name,
|
|
286
|
+
subtitle: [cls.subject, cls.section].filter(Boolean).join(" · "),
|
|
287
|
+
classId: cls.id,
|
|
288
|
+
className: cls.name,
|
|
289
|
+
href: `/class/${cls.id}`,
|
|
290
|
+
})),
|
|
291
|
+
...assignments.map((assignment) => ({
|
|
292
|
+
id: assignment.id,
|
|
293
|
+
type: "assignment" as const,
|
|
294
|
+
title: assignment.title,
|
|
295
|
+
subtitle: `${assignment.class.name} · Assignment`,
|
|
296
|
+
classId: assignment.classId,
|
|
297
|
+
className: assignment.class.name,
|
|
298
|
+
href: `/class/${assignment.classId}/assignment/${assignment.id}`,
|
|
299
|
+
matchedText: excerpt(assignment.instructions, query),
|
|
300
|
+
date: assignment.dueDate ?? assignment.modifiedAt,
|
|
301
|
+
})),
|
|
302
|
+
...events.map((event) => ({
|
|
303
|
+
id: event.id,
|
|
304
|
+
type: "event" as const,
|
|
305
|
+
title: event.name || "Untitled event",
|
|
306
|
+
subtitle: `${event.class?.name ?? "Class"} · Event`,
|
|
307
|
+
classId: event.classId!,
|
|
308
|
+
className: event.class?.name ?? "Class",
|
|
309
|
+
href: `/class/${event.classId}/attendance/${event.id}`,
|
|
310
|
+
matchedText: excerpt(event.location || event.remarks, query),
|
|
311
|
+
date: event.startTime,
|
|
312
|
+
})),
|
|
313
|
+
...memberships
|
|
314
|
+
.filter((cls) => {
|
|
315
|
+
if (!cls.syllabus) return false;
|
|
316
|
+
if (!query) return true;
|
|
317
|
+
return (
|
|
318
|
+
includesToken(query, "syllabus") ||
|
|
319
|
+
cls.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
320
|
+
cls.subject.toLowerCase().includes(query.toLowerCase()) ||
|
|
321
|
+
cls.syllabus.toLowerCase().includes(query.toLowerCase())
|
|
322
|
+
);
|
|
323
|
+
})
|
|
324
|
+
.slice(0, limit)
|
|
325
|
+
.map((cls) => ({
|
|
326
|
+
id: `${cls.id}:syllabus`,
|
|
327
|
+
type: "syllabus" as const,
|
|
328
|
+
title: `${cls.name} syllabus`,
|
|
329
|
+
subtitle: `${cls.subject} · Syllabus`,
|
|
330
|
+
classId: cls.id,
|
|
331
|
+
className: cls.name,
|
|
332
|
+
href: `/class/${cls.id}/syllabus`,
|
|
333
|
+
matchedText: excerpt(cls.syllabus, query),
|
|
334
|
+
})),
|
|
335
|
+
...worksheets.map((worksheet) => ({
|
|
336
|
+
id: worksheet.id,
|
|
337
|
+
type: "worksheet" as const,
|
|
338
|
+
title: worksheet.name,
|
|
339
|
+
subtitle: `${worksheet.class?.name ?? "Class"} · Worksheet`,
|
|
340
|
+
classId: worksheet.classId,
|
|
341
|
+
className: worksheet.class?.name ?? "Class",
|
|
342
|
+
href: `/class/${worksheet.classId}/worksheets`,
|
|
343
|
+
date: worksheet.updatedAt,
|
|
344
|
+
})),
|
|
345
|
+
...files.flatMap((file) => {
|
|
346
|
+
const classId =
|
|
347
|
+
file.folder?.classId ??
|
|
348
|
+
file.classDraft?.id ??
|
|
349
|
+
file.assignment?.classId ??
|
|
350
|
+
file.announcement?.classId;
|
|
351
|
+
const className =
|
|
352
|
+
file.folder?.class?.name ??
|
|
353
|
+
file.classDraft?.name ??
|
|
354
|
+
file.assignment?.class?.name ??
|
|
355
|
+
file.announcement?.class?.name ??
|
|
356
|
+
(classId ? classById.get(classId)?.name : undefined);
|
|
357
|
+
|
|
358
|
+
if (!classId || !className) return [];
|
|
359
|
+
|
|
360
|
+
return [
|
|
361
|
+
{
|
|
362
|
+
id: file.id,
|
|
363
|
+
type: "file" as const,
|
|
364
|
+
title: file.name,
|
|
365
|
+
subtitle: `${className} · File`,
|
|
366
|
+
classId,
|
|
367
|
+
className,
|
|
368
|
+
href: `/class/${classId}/files`,
|
|
369
|
+
matchedText: file.type,
|
|
370
|
+
date: file.uploadedAt,
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}),
|
|
374
|
+
...announcements.map((announcement) => ({
|
|
375
|
+
id: announcement.id,
|
|
376
|
+
type: "announcement" as const,
|
|
377
|
+
title: excerpt(announcement.remarks, query) ?? "Announcement",
|
|
378
|
+
subtitle: `${announcement.class.name} · Announcement`,
|
|
379
|
+
classId: announcement.classId,
|
|
380
|
+
className: announcement.class.name,
|
|
381
|
+
href: `/class/${announcement.classId}`,
|
|
382
|
+
matchedText: excerpt(announcement.remarks, query),
|
|
383
|
+
date: announcement.createdAt,
|
|
384
|
+
})),
|
|
385
|
+
...sections.map((section) => ({
|
|
386
|
+
id: section.id,
|
|
387
|
+
type: "section" as const,
|
|
388
|
+
title: section.name,
|
|
389
|
+
subtitle: `${section.class.name} · Section`,
|
|
390
|
+
classId: section.classId,
|
|
391
|
+
className: section.class.name,
|
|
392
|
+
href: `/class/${section.classId}/assignments`,
|
|
393
|
+
})),
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
return { query, results: trimResults(results, limit) };
|
|
397
|
+
}
|
package/tests/lib/cors.test.ts
CHANGED
|
@@ -64,7 +64,19 @@ describe('CORS configuration', () => {
|
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
|
|
67
|
-
|
|
67
|
+
// Same shape on a non-.vercel.app host must not match the env-only pattern.
|
|
68
|
+
expect(isAllowedOrigin('https://studious-git-fake-studious-lms.evil.example')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('allows any https://*.vercel.app origin', () => {
|
|
72
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
73
|
+
NODE_ENV: 'production',
|
|
74
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(isAllowedOrigin('https://preview-abc123.vercel.app')).toBe(true);
|
|
78
|
+
expect(isAllowedOrigin('https://my-app-git-main-username.vercel.app')).toBe(true);
|
|
79
|
+
expect(isAllowedOrigin('http://preview.vercel.app')).toBe(false);
|
|
68
80
|
});
|
|
69
81
|
|
|
70
82
|
test('rejects missing and unconfigured origins instead of using a fallback', () => {
|