@studious-lms/server 1.4.5 → 1.4.7
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/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/access.d.ts +112 -0
- package/dist/models/access.d.ts.map +1 -0
- package/dist/models/access.js +49 -0
- package/dist/models/access.js.map +1 -0
- package/dist/models/announcement.d.ts +17 -2
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/announcement.js +21 -4
- package/dist/models/announcement.js.map +1 -1
- package/dist/models/assignment.d.ts +37 -37
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/assignment.js +15 -15
- package/dist/models/assignment.js.map +1 -1
- package/dist/models/attendance.d.ts +1 -1
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +6 -5
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/auth.d.ts +5 -5
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +7 -11
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +26 -26
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +7 -11
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +233 -6
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +133 -3
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +7 -7
- package/dist/models/event.d.ts +52 -3
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/event.js +56 -6
- package/dist/models/event.js.map +1 -1
- package/dist/models/file.d.ts +178 -103
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +54 -14
- package/dist/models/file.js.map +1 -1
- package/dist/models/folder.d.ts +22 -22
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/folder.js +5 -4
- package/dist/models/folder.js.map +1 -1
- package/dist/models/labChat.d.ts +20 -20
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +8 -10
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/marketing.d.ts +1 -1
- package/dist/models/message.d.ts +6 -6
- package/dist/models/newtonChat.d.ts +5 -5
- package/dist/models/school.d.ts +361 -0
- package/dist/models/school.d.ts.map +1 -0
- package/dist/models/school.js +314 -0
- package/dist/models/school.js.map +1 -0
- package/dist/models/section.d.ts +1 -1
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/section.js +5 -4
- package/dist/models/section.js.map +1 -1
- package/dist/models/user.d.ts +4 -4
- package/dist/models/worksheet.d.ts +67 -65
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +6 -14
- package/dist/models/worksheet.js.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/pipelines/gradeWorksheet.d.ts +2 -2
- package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
- package/dist/routers/_app.d.ts +2462 -1104
- 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 +13 -5
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +25 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +266 -36
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +221 -3
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/class.d.ts +21 -20
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +40 -3
- 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 +6 -6
- package/dist/routers/event.d.ts +21 -2
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/folder.d.ts +24 -24
- package/dist/routers/labChat.d.ts +7 -7
- package/dist/routers/school.d.ts +384 -0
- package/dist/routers/school.d.ts.map +1 -0
- package/dist/routers/school.js +79 -0
- package/dist/routers/school.js.map +1 -0
- package/dist/routers/search.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +23 -21
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +10 -6
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts +36 -36
- package/dist/services/class.d.ts +20 -19
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +22 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +31 -5
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/comment.js +102 -19
- package/dist/services/comment.js.map +1 -1
- package/dist/services/conversation.d.ts +6 -6
- package/dist/services/event.d.ts +21 -2
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/event.js +11 -7
- package/dist/services/event.js.map +1 -1
- package/dist/services/file.d.ts +2 -2
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +17 -8
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +24 -24
- package/dist/services/labChat.d.ts +7 -7
- package/dist/services/school.d.ts +299 -0
- package/dist/services/school.d.ts.map +1 -0
- package/dist/services/school.js +225 -0
- package/dist/services/school.js.map +1 -0
- package/dist/services/worksheet.d.ts +23 -21
- package/dist/services/worksheet.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +60 -0
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +81 -0
- package/prisma/schema.prisma +75 -2
- package/src/lib/config/cors.ts +8 -1
- package/src/models/access.ts +48 -0
- package/src/models/announcement.ts +19 -2
- package/src/models/assignment.ts +17 -13
- package/src/models/attendance.ts +4 -3
- package/src/models/auth.ts +8 -9
- package/src/models/class.ts +8 -9
- package/src/models/comment.ts +137 -1
- package/src/models/event.ts +54 -4
- package/src/models/file.ts +53 -12
- package/src/models/folder.ts +3 -2
- package/src/models/labChat.ts +9 -8
- package/src/models/school.ts +361 -0
- package/src/models/section.ts +3 -2
- package/src/models/worksheet.ts +7 -12
- package/src/pipelines/aiLabChat.ts +2 -1
- package/src/routers/_app.ts +2 -0
- package/src/routers/announcement.ts +30 -4
- package/src/routers/assignment.ts +243 -2
- package/src/routers/class.ts +1 -0
- package/src/routers/comment.ts +19 -2
- package/src/routers/school.ts +122 -0
- package/src/services/announcement.ts +12 -4
- package/src/services/class.ts +25 -2
- package/src/services/comment.ts +122 -18
- package/src/services/event.ts +23 -6
- package/src/services/file.ts +20 -6
- package/src/services/school.ts +308 -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/vercel.json +8 -0
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,90 @@ 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
|
+
const classData = getCommentClass(comment);
|
|
45
|
+
return Boolean(
|
|
46
|
+
classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
|
|
47
|
+
classData?.school?.memberships?.some(
|
|
48
|
+
(admin: { userId: string }) => admin.userId === userId
|
|
49
|
+
) ||
|
|
50
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isClassMember(comment: any, userId: string) {
|
|
55
|
+
const classData = getCommentClass(comment);
|
|
56
|
+
return (
|
|
57
|
+
classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
|
|
58
|
+
classData?.students?.some((student: { id: string }) => student.id === userId) ||
|
|
59
|
+
classData?.school?.memberships?.some((admin: { userId: string }) => admin.userId === userId) ||
|
|
60
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId) ||
|
|
61
|
+
false
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function canReadComment(comment: any, userId: string) {
|
|
66
|
+
const privateStudentId = getPrivateStudentId(comment);
|
|
67
|
+
if (privateStudentId) {
|
|
68
|
+
return privateStudentId === userId || isClassTeacher(comment, userId);
|
|
69
|
+
}
|
|
70
|
+
return isClassMember(comment, userId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function canDeleteComment(comment: any, userId: string) {
|
|
74
|
+
return comment.authorId === userId || isClassTeacher(comment, userId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function requireCommentAccess(commentId: string, userId: string) {
|
|
78
|
+
const comment = await findCommentWithContext(commentId);
|
|
79
|
+
if (!comment) {
|
|
80
|
+
throw new TRPCError({
|
|
81
|
+
code: "NOT_FOUND",
|
|
82
|
+
message: "Comment not found",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (!canReadComment(comment, userId)) {
|
|
86
|
+
throw new TRPCError({
|
|
87
|
+
code: "FORBIDDEN",
|
|
88
|
+
message: "You do not have access to this comment",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return comment;
|
|
92
|
+
}
|
|
93
|
+
|
|
16
94
|
/** Get a comment by ID. */
|
|
17
|
-
export async function getComment(id: string) {
|
|
95
|
+
export async function getComment(userId: string, id: string) {
|
|
96
|
+
await requireCommentAccess(id, userId);
|
|
18
97
|
const comment = await findCommentById(id);
|
|
19
98
|
if (!comment) {
|
|
20
99
|
throw new TRPCError({
|
|
@@ -26,7 +105,8 @@ export async function getComment(id: string) {
|
|
|
26
105
|
}
|
|
27
106
|
|
|
28
107
|
/** Get all replies to a comment. */
|
|
29
|
-
export async function getReplies(commentId: string) {
|
|
108
|
+
export async function getReplies(userId: string, commentId: string) {
|
|
109
|
+
await requireCommentAccess(commentId, userId);
|
|
30
110
|
return findRepliesByCommentId(commentId);
|
|
31
111
|
}
|
|
32
112
|
|
|
@@ -36,26 +116,55 @@ export async function replyToComment(
|
|
|
36
116
|
parentCommentId: string,
|
|
37
117
|
content: string
|
|
38
118
|
) {
|
|
119
|
+
const parentComment = await requireCommentAccess(parentCommentId, userId);
|
|
39
120
|
return createComment({
|
|
40
121
|
parentCommentId,
|
|
41
122
|
content,
|
|
42
123
|
authorId: userId,
|
|
124
|
+
announcementId: parentComment.announcementId,
|
|
125
|
+
assignmentId: parentComment.assignmentId,
|
|
126
|
+
submissionId: parentComment.submissionId,
|
|
127
|
+
studentQuestionProgressId: parentComment.studentQuestionProgressId,
|
|
43
128
|
});
|
|
44
129
|
}
|
|
45
130
|
|
|
131
|
+
/** Update a comment authored by the user. */
|
|
132
|
+
export async function updateComment(userId: string, id: string, content: string) {
|
|
133
|
+
const comment = await requireCommentAccess(id, userId);
|
|
134
|
+
if (comment.authorId !== userId) {
|
|
135
|
+
throw new TRPCError({
|
|
136
|
+
code: "FORBIDDEN",
|
|
137
|
+
message: "Only the comment author can update this comment",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return prisma.comment.update({
|
|
142
|
+
where: { id },
|
|
143
|
+
data: { content },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Delete a comment authored by the user or moderated by a class teacher. */
|
|
148
|
+
export async function deleteComment(userId: string, id: string) {
|
|
149
|
+
const comment = await requireCommentAccess(id, userId);
|
|
150
|
+
if (!canDeleteComment(comment, userId)) {
|
|
151
|
+
throw new TRPCError({
|
|
152
|
+
code: "FORBIDDEN",
|
|
153
|
+
message: "Only the comment author or class teachers can delete comments",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await prisma.comment.delete({ where: { id } });
|
|
158
|
+
return { success: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
46
161
|
/** Add or update a reaction (emoji) on a comment. */
|
|
47
162
|
export async function addReaction(
|
|
48
163
|
userId: string,
|
|
49
164
|
commentId: string,
|
|
50
165
|
type: string
|
|
51
166
|
) {
|
|
52
|
-
|
|
53
|
-
if (!comment) {
|
|
54
|
-
throw new TRPCError({
|
|
55
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
56
|
-
message: "Unexpected error",
|
|
57
|
-
});
|
|
58
|
-
}
|
|
167
|
+
await requireCommentAccess(commentId, userId);
|
|
59
168
|
|
|
60
169
|
const reaction = await upsertReaction({ userId, commentId, type });
|
|
61
170
|
return { reaction };
|
|
@@ -63,6 +172,7 @@ export async function addReaction(
|
|
|
63
172
|
|
|
64
173
|
/** Remove user's reaction from a comment. */
|
|
65
174
|
export async function removeReaction(userId: string, commentId: string) {
|
|
175
|
+
await requireCommentAccess(commentId, userId);
|
|
66
176
|
const reaction = await findReactionByUserAndComment(userId, commentId);
|
|
67
177
|
if (!reaction) {
|
|
68
178
|
throw new TRPCError({
|
|
@@ -75,13 +185,7 @@ export async function removeReaction(userId: string, commentId: string) {
|
|
|
75
185
|
}
|
|
76
186
|
|
|
77
187
|
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
|
-
}
|
|
188
|
+
await requireCommentAccess(commentId, userId);
|
|
85
189
|
|
|
86
190
|
const reactionCounts = await getReactionCountsByCommentId(commentId);
|
|
87
191
|
const userReaction = await findReactionByUserAndComment(userId, commentId);
|
package/src/services/event.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function getEvent(userId: string, id: string) {
|
|
|
26
26
|
message: "Event not found",
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
if (event
|
|
29
|
+
if (!canModifyEvent(event, userId)) {
|
|
30
30
|
throw new TRPCError({
|
|
31
31
|
code: "UNAUTHORIZED",
|
|
32
32
|
message: "You are not authorized to view this event",
|
|
@@ -92,7 +92,7 @@ export async function updateEventRecord(
|
|
|
92
92
|
message: "Event not found",
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
-
if (event
|
|
95
|
+
if (!canModifyEvent(event, userId)) {
|
|
96
96
|
throw new TRPCError({
|
|
97
97
|
code: "UNAUTHORIZED",
|
|
98
98
|
message: "You are not authorized to update this event",
|
|
@@ -120,7 +120,7 @@ export async function deleteEventRecord(userId: string, id: string) {
|
|
|
120
120
|
message: "Event not found",
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
-
if (event
|
|
123
|
+
if (!canModifyEvent(event, userId)) {
|
|
124
124
|
throw new TRPCError({
|
|
125
125
|
code: "UNAUTHORIZED",
|
|
126
126
|
message: "You are not authorized to delete this event",
|
|
@@ -132,11 +132,24 @@ export async function deleteEventRecord(userId: string, id: string) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
function canModifyEvent(
|
|
135
|
-
event: {
|
|
135
|
+
event: {
|
|
136
|
+
userId: string | null;
|
|
137
|
+
class?: {
|
|
138
|
+
teachers: { id: string }[];
|
|
139
|
+
school?: {
|
|
140
|
+
users: { id: string }[];
|
|
141
|
+
memberships?: { userId: string }[];
|
|
142
|
+
} | null;
|
|
143
|
+
} | null;
|
|
144
|
+
},
|
|
136
145
|
userId: string
|
|
137
146
|
) {
|
|
138
147
|
if (event.userId === userId) return true;
|
|
139
|
-
return
|
|
148
|
+
return Boolean(
|
|
149
|
+
event.class?.teachers.some((t) => t.id === userId) ||
|
|
150
|
+
event.class?.school?.memberships?.some((admin) => admin.userId === userId) ||
|
|
151
|
+
event.class?.school?.users.some((admin) => admin.id === userId)
|
|
152
|
+
);
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
export async function attachAssignment(
|
|
@@ -165,7 +178,11 @@ export async function attachAssignment(
|
|
|
165
178
|
message: "Assignment not found",
|
|
166
179
|
});
|
|
167
180
|
}
|
|
168
|
-
if (
|
|
181
|
+
if (
|
|
182
|
+
!assignment.class.teachers.some((t) => t.id === userId) &&
|
|
183
|
+
!assignment.class.school?.memberships?.some((admin) => admin.userId === userId) &&
|
|
184
|
+
!assignment.class.school?.users.some((admin) => admin.id === userId)
|
|
185
|
+
) {
|
|
169
186
|
throw new TRPCError({
|
|
170
187
|
code: "UNAUTHORIZED",
|
|
171
188
|
message: "You are not authorized to modify this assignment",
|
package/src/services/file.ts
CHANGED
|
@@ -16,6 +16,15 @@ import {
|
|
|
16
16
|
} from "../models/file.js";
|
|
17
17
|
import { findConversationMembership } from "../models/message.js";
|
|
18
18
|
|
|
19
|
+
function isSchoolAdminForClass(classData: any, userId: string) {
|
|
20
|
+
return Boolean(
|
|
21
|
+
classData?.school?.memberships?.some(
|
|
22
|
+
(membership: { userId: string }) => membership.userId === userId
|
|
23
|
+
) ||
|
|
24
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/** Generate a signed URL for file download. Validates user has access. */
|
|
20
29
|
export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
21
30
|
const file = await findFileById(fileId);
|
|
@@ -35,31 +44,35 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
35
44
|
const isStudent = file.assignment.class.students.some(
|
|
36
45
|
(s) => s.id === userId
|
|
37
46
|
);
|
|
47
|
+
const isSchoolAdmin = isSchoolAdminForClass(file.assignment.class, userId);
|
|
38
48
|
logger.info(
|
|
39
|
-
`Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}`
|
|
49
|
+
`Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}, isSchoolAdmin: ${isSchoolAdmin}`
|
|
40
50
|
);
|
|
41
|
-
hasAccess = isTeacher || isStudent;
|
|
51
|
+
hasAccess = isTeacher || isStudent || isSchoolAdmin;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
if ((file as any).announcement?.class) {
|
|
45
55
|
const ann = (file as any).announcement;
|
|
46
56
|
const isTeacher = ann.class.teachers.some((t: any) => t.id === userId);
|
|
47
57
|
const isStudent = ann.class.students.some((s: any) => s.id === userId);
|
|
48
|
-
|
|
58
|
+
const isSchoolAdmin = isSchoolAdminForClass(ann.class, userId);
|
|
59
|
+
hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
if (file.submission?.assignment?.classId) {
|
|
52
63
|
hasAccess =
|
|
53
64
|
hasAccess ||
|
|
54
65
|
file.submission.studentId === userId ||
|
|
55
|
-
file.submission.assignment.class.teachers.some((t) => t.id === userId)
|
|
66
|
+
file.submission.assignment.class.teachers.some((t) => t.id === userId) ||
|
|
67
|
+
isSchoolAdminForClass(file.submission.assignment.class, userId);
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
if (file.annotations?.assignment?.classId) {
|
|
59
71
|
hasAccess =
|
|
60
72
|
hasAccess ||
|
|
61
73
|
file.annotations.studentId === userId ||
|
|
62
|
-
file.annotations.assignment.class.teachers.some((t) => t.id === userId)
|
|
74
|
+
file.annotations.assignment.class.teachers.some((t) => t.id === userId) ||
|
|
75
|
+
isSchoolAdminForClass(file.annotations.assignment.class, userId);
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
if (file.userId === userId) {
|
|
@@ -74,7 +87,8 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
74
87
|
if (file.folder?.class) {
|
|
75
88
|
const isTeacher = file.folder.class.teachers.some((t) => t.id === userId);
|
|
76
89
|
const isStudent = file.folder.class.students.some((s) => s.id === userId);
|
|
77
|
-
|
|
90
|
+
const isSchoolAdmin = isSchoolAdminForClass(file.folder.class, userId);
|
|
91
|
+
hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
if (!hasAccess) {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* School service - school admin workflow, memberships, and invites.
|
|
3
|
+
*/
|
|
4
|
+
import { SchoolMembershipRole } from "@prisma/client";
|
|
5
|
+
import { TRPCError } from "@trpc/server";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
import { env } from "../lib/config/env.js";
|
|
8
|
+
import { prisma } from "../lib/prisma.js";
|
|
9
|
+
import { sendMail } from "../utils/email.js";
|
|
10
|
+
import { logger } from "../utils/logger.js";
|
|
11
|
+
import {
|
|
12
|
+
acceptSchoolInvite,
|
|
13
|
+
countActiveAdmins,
|
|
14
|
+
createSchoolInvite,
|
|
15
|
+
createSchoolWithAdmin,
|
|
16
|
+
findActiveSchoolAdminMembership,
|
|
17
|
+
findPendingInviteByToken,
|
|
18
|
+
findSchoolMembership,
|
|
19
|
+
findUserByIdForSchool,
|
|
20
|
+
getSchoolById,
|
|
21
|
+
getSchoolOverview,
|
|
22
|
+
listSchoolClasses,
|
|
23
|
+
listSchoolInvites,
|
|
24
|
+
listSchoolMembers,
|
|
25
|
+
listUserSchoolMemberships,
|
|
26
|
+
removeSchoolMembershipPreservingContent,
|
|
27
|
+
revokeSchoolInvite,
|
|
28
|
+
updateSchoolMembershipRole,
|
|
29
|
+
upsertSchoolMembership,
|
|
30
|
+
} from "../models/school.js";
|
|
31
|
+
|
|
32
|
+
async function requireSchoolAdmin(schoolId: string, userId: string) {
|
|
33
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
34
|
+
if (!membership) {
|
|
35
|
+
throw new TRPCError({
|
|
36
|
+
code: "FORBIDDEN",
|
|
37
|
+
message: "Only school admins can perform this action",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return membership;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requireSchoolMember(schoolId: string, userId: string) {
|
|
44
|
+
const membership = await findSchoolMembership(schoolId, userId);
|
|
45
|
+
if (!membership || membership.status !== "ACTIVE") {
|
|
46
|
+
throw new TRPCError({
|
|
47
|
+
code: "FORBIDDEN",
|
|
48
|
+
message: "You are not a member of this school",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return membership;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
55
|
+
const memberships = await listUserSchoolMemberships(userId);
|
|
56
|
+
const activeMembership = schoolId
|
|
57
|
+
? memberships.find((membership) => membership.schoolId === schoolId)
|
|
58
|
+
: memberships[0];
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
activeSchool: activeMembership?.school ?? null,
|
|
62
|
+
activeRole: activeMembership?.role ?? null,
|
|
63
|
+
memberships,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createSchoolRecord(
|
|
68
|
+
userId: string,
|
|
69
|
+
input: { name: string; subdomain?: string; logoId?: string }
|
|
70
|
+
) {
|
|
71
|
+
const school = await createSchoolWithAdmin({
|
|
72
|
+
userId,
|
|
73
|
+
name: input.name,
|
|
74
|
+
subdomain: input.subdomain,
|
|
75
|
+
logoId: input.logoId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { school };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getAdminOverview(userId: string, schoolId: string) {
|
|
82
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
83
|
+
const school = await getSchoolOverview(schoolId);
|
|
84
|
+
if (!school) {
|
|
85
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const roleCounts = school.memberships.reduce(
|
|
89
|
+
(counts, membership) => {
|
|
90
|
+
counts[membership.role] += 1;
|
|
91
|
+
return counts;
|
|
92
|
+
},
|
|
93
|
+
{ ADMIN: 0, TEACHER: 0, STUDENT: 0 } satisfies Record<SchoolMembershipRole, number>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
school: {
|
|
98
|
+
id: school.id,
|
|
99
|
+
name: school.name,
|
|
100
|
+
subdomain: school.subdomain,
|
|
101
|
+
counts: {
|
|
102
|
+
classes: school._count.classes,
|
|
103
|
+
members: school._count.memberships,
|
|
104
|
+
pendingInvites: school._count.invites,
|
|
105
|
+
...roleCounts,
|
|
106
|
+
},
|
|
107
|
+
recentClasses: school.classes,
|
|
108
|
+
recentInvites: school.invites,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function listClasses(userId: string, schoolId: string) {
|
|
114
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
115
|
+
return { classes: await listSchoolClasses(schoolId) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function createSchoolClass(
|
|
119
|
+
userId: string,
|
|
120
|
+
input: {
|
|
121
|
+
schoolId: string;
|
|
122
|
+
name: string;
|
|
123
|
+
section: string;
|
|
124
|
+
subject: string;
|
|
125
|
+
color?: string;
|
|
126
|
+
teachers?: string[];
|
|
127
|
+
students?: string[];
|
|
128
|
+
}
|
|
129
|
+
) {
|
|
130
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
131
|
+
const createdClass = await prisma.class.create({
|
|
132
|
+
data: {
|
|
133
|
+
name: input.name,
|
|
134
|
+
section: input.section,
|
|
135
|
+
subject: input.subject,
|
|
136
|
+
color: input.color,
|
|
137
|
+
schoolId: input.schoolId,
|
|
138
|
+
teachers: input.teachers?.length
|
|
139
|
+
? { connect: input.teachers.map((id) => ({ id })) }
|
|
140
|
+
: undefined,
|
|
141
|
+
students: input.students?.length
|
|
142
|
+
? { connect: input.students.map((id) => ({ id })) }
|
|
143
|
+
: undefined,
|
|
144
|
+
},
|
|
145
|
+
include: { teachers: true, students: true },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return { class: createdClass };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function listMembers(userId: string, schoolId: string) {
|
|
152
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
153
|
+
return { members: await listSchoolMembers(schoolId) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function addMember(
|
|
157
|
+
userId: string,
|
|
158
|
+
input: { schoolId: string; memberUserId: string; role: SchoolMembershipRole }
|
|
159
|
+
) {
|
|
160
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
161
|
+
const membership = await upsertSchoolMembership({
|
|
162
|
+
schoolId: input.schoolId,
|
|
163
|
+
userId: input.memberUserId,
|
|
164
|
+
role: input.role,
|
|
165
|
+
});
|
|
166
|
+
return { membership };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function updateMemberRole(
|
|
170
|
+
userId: string,
|
|
171
|
+
input: { schoolId: string; memberUserId: string; role: SchoolMembershipRole }
|
|
172
|
+
) {
|
|
173
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
174
|
+
const current = await findSchoolMembership(input.schoolId, input.memberUserId);
|
|
175
|
+
if (!current) {
|
|
176
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (current.role === "ADMIN" && input.role !== "ADMIN") {
|
|
180
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
181
|
+
if (adminCount <= 1) {
|
|
182
|
+
throw new TRPCError({
|
|
183
|
+
code: "BAD_REQUEST",
|
|
184
|
+
message: "A school must have at least one active admin",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const membership = await updateSchoolMembershipRole(
|
|
190
|
+
input.schoolId,
|
|
191
|
+
input.memberUserId,
|
|
192
|
+
input.role
|
|
193
|
+
);
|
|
194
|
+
return { membership };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function removeMember(
|
|
198
|
+
userId: string,
|
|
199
|
+
input: { schoolId: string; memberUserId: string }
|
|
200
|
+
) {
|
|
201
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
202
|
+
const current = await findSchoolMembership(input.schoolId, input.memberUserId);
|
|
203
|
+
if (!current) {
|
|
204
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (current.role === "ADMIN" && current.status === "ACTIVE") {
|
|
208
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
209
|
+
if (adminCount <= 1) {
|
|
210
|
+
throw new TRPCError({
|
|
211
|
+
code: "BAD_REQUEST",
|
|
212
|
+
message: "A school must have at least one active admin",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await removeSchoolMembershipPreservingContent(input.schoolId, input.memberUserId);
|
|
218
|
+
return { success: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function listInvites(userId: string, schoolId: string) {
|
|
222
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
223
|
+
return { invites: await listSchoolInvites(schoolId) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function createInvite(
|
|
227
|
+
userId: string,
|
|
228
|
+
input: { schoolId: string; email: string; role: SchoolMembershipRole }
|
|
229
|
+
) {
|
|
230
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
231
|
+
const token = uuidv4();
|
|
232
|
+
const invite = await createSchoolInvite({
|
|
233
|
+
schoolId: input.schoolId,
|
|
234
|
+
invitedById: userId,
|
|
235
|
+
email: input.email,
|
|
236
|
+
role: input.role,
|
|
237
|
+
token,
|
|
238
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await sendMail({
|
|
243
|
+
from: "hello@studious.sh",
|
|
244
|
+
to: input.email,
|
|
245
|
+
subject: `Join ${invite.school.name} on Studious`,
|
|
246
|
+
text: `Click the link to join ${invite.school.name}: ${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`,
|
|
247
|
+
});
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger.error("Failed to send school invite email", {
|
|
250
|
+
schoolId: input.schoolId,
|
|
251
|
+
email: input.email,
|
|
252
|
+
err,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { invite };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function revokeInvite(
|
|
260
|
+
userId: string,
|
|
261
|
+
input: { schoolId: string; inviteId: string }
|
|
262
|
+
) {
|
|
263
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
264
|
+
return { invite: await revokeSchoolInvite(input.inviteId, input.schoolId) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function getInvite(token: string) {
|
|
268
|
+
const invite = await findPendingInviteByToken(token);
|
|
269
|
+
if (!invite || invite.status !== "PENDING") {
|
|
270
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
271
|
+
}
|
|
272
|
+
if (invite.expiresAt < new Date()) {
|
|
273
|
+
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
invite: {
|
|
277
|
+
id: invite.id,
|
|
278
|
+
email: invite.email,
|
|
279
|
+
role: invite.role,
|
|
280
|
+
school: invite.school,
|
|
281
|
+
expiresAt: invite.expiresAt,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function acceptInvite(userId: string, token: string) {
|
|
287
|
+
const invite = await findPendingInviteByToken(token);
|
|
288
|
+
if (!invite || invite.status !== "PENDING") {
|
|
289
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
290
|
+
}
|
|
291
|
+
if (invite.expiresAt < new Date()) {
|
|
292
|
+
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const user = await findUserByIdForSchool(userId);
|
|
296
|
+
if (!user) {
|
|
297
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
298
|
+
}
|
|
299
|
+
if (user.email.toLowerCase() !== invite.email.toLowerCase()) {
|
|
300
|
+
throw new TRPCError({
|
|
301
|
+
code: "FORBIDDEN",
|
|
302
|
+
message: "This invite was sent to a different account",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const membership = await acceptSchoolInvite(invite.id, userId);
|
|
307
|
+
return { membership };
|
|
308
|
+
}
|
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', () => {
|