@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.
Files changed (167) hide show
  1. package/dist/lib/config/cors.d.ts.map +1 -1
  2. package/dist/lib/config/cors.js +8 -3
  3. package/dist/lib/config/cors.js.map +1 -1
  4. package/dist/models/access.d.ts +112 -0
  5. package/dist/models/access.d.ts.map +1 -0
  6. package/dist/models/access.js +49 -0
  7. package/dist/models/access.js.map +1 -0
  8. package/dist/models/announcement.d.ts +17 -2
  9. package/dist/models/announcement.d.ts.map +1 -1
  10. package/dist/models/announcement.js +21 -4
  11. package/dist/models/announcement.js.map +1 -1
  12. package/dist/models/assignment.d.ts +37 -37
  13. package/dist/models/assignment.d.ts.map +1 -1
  14. package/dist/models/assignment.js +15 -15
  15. package/dist/models/assignment.js.map +1 -1
  16. package/dist/models/attendance.d.ts +1 -1
  17. package/dist/models/attendance.d.ts.map +1 -1
  18. package/dist/models/attendance.js +6 -5
  19. package/dist/models/attendance.js.map +1 -1
  20. package/dist/models/auth.d.ts +5 -5
  21. package/dist/models/auth.d.ts.map +1 -1
  22. package/dist/models/auth.js +7 -11
  23. package/dist/models/auth.js.map +1 -1
  24. package/dist/models/class.d.ts +26 -26
  25. package/dist/models/class.d.ts.map +1 -1
  26. package/dist/models/class.js +7 -11
  27. package/dist/models/class.js.map +1 -1
  28. package/dist/models/comment.d.ts +233 -6
  29. package/dist/models/comment.d.ts.map +1 -1
  30. package/dist/models/comment.js +133 -3
  31. package/dist/models/comment.js.map +1 -1
  32. package/dist/models/conversation.d.ts +7 -7
  33. package/dist/models/event.d.ts +52 -3
  34. package/dist/models/event.d.ts.map +1 -1
  35. package/dist/models/event.js +56 -6
  36. package/dist/models/event.js.map +1 -1
  37. package/dist/models/file.d.ts +178 -103
  38. package/dist/models/file.d.ts.map +1 -1
  39. package/dist/models/file.js +54 -14
  40. package/dist/models/file.js.map +1 -1
  41. package/dist/models/folder.d.ts +22 -22
  42. package/dist/models/folder.d.ts.map +1 -1
  43. package/dist/models/folder.js +5 -4
  44. package/dist/models/folder.js.map +1 -1
  45. package/dist/models/labChat.d.ts +20 -20
  46. package/dist/models/labChat.d.ts.map +1 -1
  47. package/dist/models/labChat.js +8 -10
  48. package/dist/models/labChat.js.map +1 -1
  49. package/dist/models/marketing.d.ts +1 -1
  50. package/dist/models/message.d.ts +6 -6
  51. package/dist/models/newtonChat.d.ts +5 -5
  52. package/dist/models/school.d.ts +361 -0
  53. package/dist/models/school.d.ts.map +1 -0
  54. package/dist/models/school.js +314 -0
  55. package/dist/models/school.js.map +1 -0
  56. package/dist/models/section.d.ts +1 -1
  57. package/dist/models/section.d.ts.map +1 -1
  58. package/dist/models/section.js +5 -4
  59. package/dist/models/section.js.map +1 -1
  60. package/dist/models/user.d.ts +4 -4
  61. package/dist/models/worksheet.d.ts +67 -65
  62. package/dist/models/worksheet.d.ts.map +1 -1
  63. package/dist/models/worksheet.js +6 -14
  64. package/dist/models/worksheet.js.map +1 -1
  65. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  66. package/dist/pipelines/aiLabChat.js +4 -3
  67. package/dist/pipelines/aiLabChat.js.map +1 -1
  68. package/dist/pipelines/gradeWorksheet.d.ts +2 -2
  69. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  70. package/dist/routers/_app.d.ts +2462 -1104
  71. package/dist/routers/_app.d.ts.map +1 -1
  72. package/dist/routers/_app.js +4 -2
  73. package/dist/routers/_app.js.map +1 -1
  74. package/dist/routers/announcement.d.ts +13 -5
  75. package/dist/routers/announcement.d.ts.map +1 -1
  76. package/dist/routers/announcement.js +25 -5
  77. package/dist/routers/announcement.js.map +1 -1
  78. package/dist/routers/assignment.d.ts +266 -36
  79. package/dist/routers/assignment.d.ts.map +1 -1
  80. package/dist/routers/assignment.js +221 -3
  81. package/dist/routers/assignment.js.map +1 -1
  82. package/dist/routers/class.d.ts +21 -20
  83. package/dist/routers/class.d.ts.map +1 -1
  84. package/dist/routers/class.js +3 -2
  85. package/dist/routers/class.js.map +1 -1
  86. package/dist/routers/comment.d.ts +40 -3
  87. package/dist/routers/comment.d.ts.map +1 -1
  88. package/dist/routers/comment.js +14 -5
  89. package/dist/routers/comment.js.map +1 -1
  90. package/dist/routers/conversation.d.ts +6 -6
  91. package/dist/routers/event.d.ts +21 -2
  92. package/dist/routers/event.d.ts.map +1 -1
  93. package/dist/routers/file.d.ts +2 -2
  94. package/dist/routers/folder.d.ts +24 -24
  95. package/dist/routers/labChat.d.ts +7 -7
  96. package/dist/routers/school.d.ts +384 -0
  97. package/dist/routers/school.d.ts.map +1 -0
  98. package/dist/routers/school.js +79 -0
  99. package/dist/routers/school.js.map +1 -0
  100. package/dist/routers/search.d.ts +1 -1
  101. package/dist/routers/worksheet.d.ts +23 -21
  102. package/dist/routers/worksheet.d.ts.map +1 -1
  103. package/dist/services/announcement.d.ts.map +1 -1
  104. package/dist/services/announcement.js +10 -6
  105. package/dist/services/announcement.js.map +1 -1
  106. package/dist/services/assignment.d.ts +36 -36
  107. package/dist/services/class.d.ts +20 -19
  108. package/dist/services/class.d.ts.map +1 -1
  109. package/dist/services/class.js +22 -3
  110. package/dist/services/class.js.map +1 -1
  111. package/dist/services/comment.d.ts +31 -5
  112. package/dist/services/comment.d.ts.map +1 -1
  113. package/dist/services/comment.js +102 -19
  114. package/dist/services/comment.js.map +1 -1
  115. package/dist/services/conversation.d.ts +6 -6
  116. package/dist/services/event.d.ts +21 -2
  117. package/dist/services/event.d.ts.map +1 -1
  118. package/dist/services/event.js +11 -7
  119. package/dist/services/event.js.map +1 -1
  120. package/dist/services/file.d.ts +2 -2
  121. package/dist/services/file.d.ts.map +1 -1
  122. package/dist/services/file.js +17 -8
  123. package/dist/services/file.js.map +1 -1
  124. package/dist/services/folder.d.ts +24 -24
  125. package/dist/services/labChat.d.ts +7 -7
  126. package/dist/services/school.d.ts +299 -0
  127. package/dist/services/school.d.ts.map +1 -0
  128. package/dist/services/school.js +225 -0
  129. package/dist/services/school.js.map +1 -0
  130. package/dist/services/worksheet.d.ts +23 -21
  131. package/dist/services/worksheet.d.ts.map +1 -1
  132. package/package.json +1 -1
  133. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +60 -0
  134. package/prisma/migrations/20260504071500_school_memberships/migration.sql +81 -0
  135. package/prisma/schema.prisma +75 -2
  136. package/src/lib/config/cors.ts +8 -1
  137. package/src/models/access.ts +48 -0
  138. package/src/models/announcement.ts +19 -2
  139. package/src/models/assignment.ts +17 -13
  140. package/src/models/attendance.ts +4 -3
  141. package/src/models/auth.ts +8 -9
  142. package/src/models/class.ts +8 -9
  143. package/src/models/comment.ts +137 -1
  144. package/src/models/event.ts +54 -4
  145. package/src/models/file.ts +53 -12
  146. package/src/models/folder.ts +3 -2
  147. package/src/models/labChat.ts +9 -8
  148. package/src/models/school.ts +361 -0
  149. package/src/models/section.ts +3 -2
  150. package/src/models/worksheet.ts +7 -12
  151. package/src/pipelines/aiLabChat.ts +2 -1
  152. package/src/routers/_app.ts +2 -0
  153. package/src/routers/announcement.ts +30 -4
  154. package/src/routers/assignment.ts +243 -2
  155. package/src/routers/class.ts +1 -0
  156. package/src/routers/comment.ts +19 -2
  157. package/src/routers/school.ts +122 -0
  158. package/src/services/announcement.ts +12 -4
  159. package/src/services/class.ts +25 -2
  160. package/src/services/comment.ts +122 -18
  161. package/src/services/event.ts +23 -6
  162. package/src/services/file.ts +20 -6
  163. package/src/services/school.ts +308 -0
  164. package/tests/lib/cors.test.ts +13 -1
  165. package/tests/routers/assignment.test.ts +68 -2
  166. package/tests/routers/comment.test.ts +42 -1
  167. package/vercel.json +8 -0
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Comment service – get comments, replies, and reactions. Used for announcements and worksheets.
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
- findCommentWithAnnouncement,
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
- const comment = await findCommentById(commentId);
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
- const comment = await findCommentWithAnnouncement(commentId);
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);
@@ -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.userId !== userId) {
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.userId !== userId) {
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.userId !== userId) {
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: { userId: string | null; class?: { teachers: { id: string }[] } | null },
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 event.class?.teachers.some((t) => t.id === userId) ?? false;
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 (!assignment.class.teachers.some((t) => t.id === userId)) {
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",
@@ -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
- hasAccess = hasAccess || isTeacher || isStudent;
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
- hasAccess = hasAccess || isTeacher || isStudent;
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
+ }
@@ -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
- expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
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', () => {