@studious-lms/server 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/dist/models/access.d.ts +21 -66
  2. package/dist/models/access.d.ts.map +1 -1
  3. package/dist/models/access.js +10 -26
  4. package/dist/models/access.js.map +1 -1
  5. package/dist/models/agenda.d.ts +2 -0
  6. package/dist/models/agenda.d.ts.map +1 -1
  7. package/dist/models/announcement.d.ts +2 -1
  8. package/dist/models/announcement.d.ts.map +1 -1
  9. package/dist/models/announcement.js +2 -3
  10. package/dist/models/announcement.js.map +1 -1
  11. package/dist/models/assignment.d.ts +7 -0
  12. package/dist/models/assignment.d.ts.map +1 -1
  13. package/dist/models/auth.d.ts +2 -4
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +2 -3
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +32 -4
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +15 -2
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/cloudStorage.d.ts +140 -0
  22. package/dist/models/cloudStorage.d.ts.map +1 -0
  23. package/dist/models/cloudStorage.js +175 -0
  24. package/dist/models/cloudStorage.js.map +1 -0
  25. package/dist/models/comment.d.ts +0 -24
  26. package/dist/models/comment.d.ts.map +1 -1
  27. package/dist/models/comment.js +2 -6
  28. package/dist/models/comment.js.map +1 -1
  29. package/dist/models/conversation.d.ts +38 -22
  30. package/dist/models/conversation.d.ts.map +1 -1
  31. package/dist/models/conversation.js +50 -2
  32. package/dist/models/conversation.js.map +1 -1
  33. package/dist/models/department.d.ts +154 -0
  34. package/dist/models/department.d.ts.map +1 -0
  35. package/dist/models/department.js +135 -0
  36. package/dist/models/department.js.map +1 -0
  37. package/dist/models/event.d.ts +7 -1
  38. package/dist/models/event.d.ts.map +1 -1
  39. package/dist/models/event.js +2 -5
  40. package/dist/models/event.js.map +1 -1
  41. package/dist/models/file.d.ts +25 -10
  42. package/dist/models/file.d.ts.map +1 -1
  43. package/dist/models/file.js +2 -5
  44. package/dist/models/file.js.map +1 -1
  45. package/dist/models/folder.d.ts +47 -0
  46. package/dist/models/folder.d.ts.map +1 -1
  47. package/dist/models/labChat.d.ts +5 -3
  48. package/dist/models/labChat.d.ts.map +1 -1
  49. package/dist/models/message.d.ts +46 -12
  50. package/dist/models/message.d.ts.map +1 -1
  51. package/dist/models/message.js +19 -10
  52. package/dist/models/message.js.map +1 -1
  53. package/dist/models/newtonChat.d.ts +2 -1
  54. package/dist/models/newtonChat.d.ts.map +1 -1
  55. package/dist/models/school.d.ts +45 -12
  56. package/dist/models/school.d.ts.map +1 -1
  57. package/dist/models/school.js +19 -2
  58. package/dist/models/school.js.map +1 -1
  59. package/dist/models/section.d.ts +1 -0
  60. package/dist/models/section.d.ts.map +1 -1
  61. package/dist/models/tag.d.ts +117 -0
  62. package/dist/models/tag.d.ts.map +1 -0
  63. package/dist/models/tag.js +113 -0
  64. package/dist/models/tag.js.map +1 -0
  65. package/dist/models/worksheet.d.ts +3 -0
  66. package/dist/models/worksheet.d.ts.map +1 -1
  67. package/dist/pipelines/aiLabChatContract.d.ts +8 -8
  68. package/dist/routers/_app.d.ts +3556 -2298
  69. package/dist/routers/_app.d.ts.map +1 -1
  70. package/dist/routers/_app.js +6 -2
  71. package/dist/routers/_app.js.map +1 -1
  72. package/dist/routers/agenda.d.ts +2 -0
  73. package/dist/routers/agenda.d.ts.map +1 -1
  74. package/dist/routers/announcement.d.ts.map +1 -1
  75. package/dist/routers/announcement.js +3 -5
  76. package/dist/routers/announcement.js.map +1 -1
  77. package/dist/routers/assignment.d.ts +18 -9
  78. package/dist/routers/assignment.d.ts.map +1 -1
  79. package/dist/routers/assignment.js +2 -3
  80. package/dist/routers/assignment.js.map +1 -1
  81. package/dist/routers/class.d.ts +87 -14
  82. package/dist/routers/class.d.ts.map +1 -1
  83. package/dist/routers/class.js +5 -2
  84. package/dist/routers/class.js.map +1 -1
  85. package/dist/routers/conversation.d.ts +20 -5
  86. package/dist/routers/conversation.d.ts.map +1 -1
  87. package/dist/routers/conversation.js +6 -3
  88. package/dist/routers/conversation.js.map +1 -1
  89. package/dist/routers/department.d.ts +293 -0
  90. package/dist/routers/department.d.ts.map +1 -0
  91. package/dist/routers/department.js +93 -0
  92. package/dist/routers/department.js.map +1 -0
  93. package/dist/routers/event.d.ts +2 -1
  94. package/dist/routers/event.d.ts.map +1 -1
  95. package/dist/routers/file.d.ts +8 -6
  96. package/dist/routers/file.d.ts.map +1 -1
  97. package/dist/routers/file.js +26 -17
  98. package/dist/routers/file.js.map +1 -1
  99. package/dist/routers/folder.d.ts +139 -186
  100. package/dist/routers/folder.d.ts.map +1 -1
  101. package/dist/routers/folder.js +82 -90
  102. package/dist/routers/folder.js.map +1 -1
  103. package/dist/routers/labChat.d.ts +11 -2
  104. package/dist/routers/labChat.d.ts.map +1 -1
  105. package/dist/routers/message.d.ts +20 -2
  106. package/dist/routers/message.d.ts.map +1 -1
  107. package/dist/routers/newtonChat.d.ts +9 -0
  108. package/dist/routers/newtonChat.d.ts.map +1 -1
  109. package/dist/routers/school.d.ts +160 -17
  110. package/dist/routers/school.d.ts.map +1 -1
  111. package/dist/routers/school.js +46 -3
  112. package/dist/routers/school.js.map +1 -1
  113. package/dist/routers/tag.d.ts +119 -0
  114. package/dist/routers/tag.d.ts.map +1 -0
  115. package/dist/routers/tag.js +52 -0
  116. package/dist/routers/tag.js.map +1 -0
  117. package/dist/routers/worksheet.d.ts +1 -0
  118. package/dist/routers/worksheet.d.ts.map +1 -1
  119. package/dist/seedDatabase.d.ts +0 -1
  120. package/dist/seedDatabase.d.ts.map +1 -1
  121. package/dist/services/agenda.d.ts +2 -0
  122. package/dist/services/agenda.d.ts.map +1 -1
  123. package/dist/services/assignment.d.ts +18 -9
  124. package/dist/services/assignment.d.ts.map +1 -1
  125. package/dist/services/assignment.js +4 -4
  126. package/dist/services/assignment.js.map +1 -1
  127. package/dist/services/class.d.ts +87 -14
  128. package/dist/services/class.d.ts.map +1 -1
  129. package/dist/services/class.js +91 -4
  130. package/dist/services/class.js.map +1 -1
  131. package/dist/services/conversation.d.ts +21 -5
  132. package/dist/services/conversation.d.ts.map +1 -1
  133. package/dist/services/conversation.js +22 -3
  134. package/dist/services/conversation.js.map +1 -1
  135. package/dist/services/department.d.ts +200 -0
  136. package/dist/services/department.d.ts.map +1 -0
  137. package/dist/services/department.js +368 -0
  138. package/dist/services/department.js.map +1 -0
  139. package/dist/services/event.d.ts +2 -1
  140. package/dist/services/event.d.ts.map +1 -1
  141. package/dist/services/file.d.ts +85 -0
  142. package/dist/services/file.d.ts.map +1 -1
  143. package/dist/services/file.js +92 -2
  144. package/dist/services/file.js.map +1 -1
  145. package/dist/services/folder.d.ts +125 -149
  146. package/dist/services/folder.d.ts.map +1 -1
  147. package/dist/services/folder.js +258 -199
  148. package/dist/services/folder.js.map +1 -1
  149. package/dist/services/labChat.d.ts +11 -2
  150. package/dist/services/labChat.d.ts.map +1 -1
  151. package/dist/services/labChat.js +8 -7
  152. package/dist/services/labChat.js.map +1 -1
  153. package/dist/services/message.d.ts +20 -2
  154. package/dist/services/message.d.ts.map +1 -1
  155. package/dist/services/message.js +7 -23
  156. package/dist/services/message.js.map +1 -1
  157. package/dist/services/newtonChat.d.ts +9 -0
  158. package/dist/services/newtonChat.d.ts.map +1 -1
  159. package/dist/services/newtonChat.js +7 -6
  160. package/dist/services/newtonChat.js.map +1 -1
  161. package/dist/services/school.d.ts +142 -15
  162. package/dist/services/school.d.ts.map +1 -1
  163. package/dist/services/school.js +308 -54
  164. package/dist/services/school.js.map +1 -1
  165. package/dist/services/tag.d.ts +71 -0
  166. package/dist/services/tag.d.ts.map +1 -0
  167. package/dist/services/tag.js +178 -0
  168. package/dist/services/tag.js.map +1 -0
  169. package/dist/services/worksheet.d.ts +1 -0
  170. package/dist/services/worksheet.d.ts.map +1 -1
  171. package/dist/utils/aiUser.d.ts.map +1 -1
  172. package/dist/utils/aiUser.js +2 -3
  173. package/dist/utils/aiUser.js.map +1 -1
  174. package/package.json +1 -1
  175. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
  176. package/prisma/schema.prisma +119 -35
  177. package/src/models/access.ts +8 -24
  178. package/src/models/announcement.ts +0 -1
  179. package/src/models/auth.ts +0 -1
  180. package/src/models/class.ts +13 -0
  181. package/src/models/cloudStorage.ts +194 -0
  182. package/src/models/comment.ts +0 -4
  183. package/src/models/conversation.ts +54 -0
  184. package/src/models/department.ts +159 -0
  185. package/src/models/event.ts +0 -3
  186. package/src/models/file.ts +0 -3
  187. package/src/models/message.ts +19 -5
  188. package/src/models/school.ts +26 -0
  189. package/src/models/tag.ts +140 -0
  190. package/src/routers/_app.ts +4 -0
  191. package/src/routers/announcement.ts +1 -3
  192. package/src/routers/assignment.ts +0 -1
  193. package/src/routers/class.ts +3 -0
  194. package/src/routers/conversation.ts +7 -0
  195. package/src/routers/department.ts +174 -0
  196. package/src/routers/file.ts +29 -26
  197. package/src/routers/folder.ts +100 -121
  198. package/src/routers/school.ts +83 -0
  199. package/src/routers/tag.ts +78 -0
  200. package/src/services/assignment.ts +2 -3
  201. package/src/services/class.ts +108 -3
  202. package/src/services/conversation.ts +30 -0
  203. package/src/services/department.ts +478 -0
  204. package/src/services/file.ts +100 -0
  205. package/src/services/folder.ts +313 -240
  206. package/src/services/labChat.ts +7 -6
  207. package/src/services/message.ts +5 -21
  208. package/src/services/newtonChat.ts +5 -5
  209. package/src/services/school.ts +369 -57
  210. package/src/services/tag.ts +229 -0
  211. package/src/utils/aiUser.ts +0 -1
  212. package/tests/integration/routers/conversation.test.ts +121 -2
@@ -71,6 +71,9 @@ export async function getAllClasses(userId: string) {
71
71
  assignments: cls.assignments,
72
72
  members: [...cls.students, ...cls.teachers],
73
73
  color: cls.color,
74
+ schoolId: cls.schoolId ?? null,
75
+ school: cls.school ?? null,
76
+ department: cls.department ?? null,
74
77
  })),
75
78
  studentInClass: studentClasses.map((cls) => ({
76
79
  id: cls.id,
@@ -81,6 +84,9 @@ export async function getAllClasses(userId: string) {
81
84
  assignments: cls.assignments,
82
85
  members: [...cls.students, ...cls.teachers],
83
86
  color: cls.color,
87
+ schoolId: cls.schoolId ?? null,
88
+ school: cls.school ?? null,
89
+ department: cls.department ?? null,
84
90
  })),
85
91
  };
86
92
  }
@@ -112,20 +118,87 @@ export async function getClass(userId: string, classId: string) {
112
118
  })),
113
119
  };
114
120
 
115
- const sections = await findSectionsByClassId(classId);
121
+ const [sections, affiliation] = await Promise.all([
122
+ findSectionsByClassId(classId),
123
+ prisma.class.findUnique({
124
+ where: { id: classId },
125
+ select: {
126
+ schoolId: true,
127
+ departmentId: true,
128
+ school: {
129
+ select: {
130
+ id: true,
131
+ name: true,
132
+ description: true,
133
+ logo: { select: { id: true, path: true, type: true } },
134
+ },
135
+ },
136
+ department: {
137
+ select: {
138
+ id: true,
139
+ name: true,
140
+ color: true,
141
+ description: true,
142
+ },
143
+ },
144
+ tags: {
145
+ select: { id: true, name: true, color: true, departmentId: true },
146
+ },
147
+ },
148
+ }),
149
+ ]);
116
150
 
117
151
  return {
118
152
  class: {
119
153
  ...formattedClassData,
120
154
  sections,
155
+ affiliation: affiliation
156
+ ? {
157
+ school: affiliation.school,
158
+ department: affiliation.department,
159
+ tags: affiliation.tags,
160
+ }
161
+ : null,
121
162
  },
122
163
  };
123
164
  }
124
165
 
125
166
  export async function updateClass(
126
167
  classId: string,
127
- data: { name?: string; section?: string; subject?: string }
168
+ data: {
169
+ name?: string;
170
+ section?: string;
171
+ subject?: string;
172
+ color?: string;
173
+ departmentId?: string | null;
174
+ }
128
175
  ) {
176
+ if (data.departmentId !== undefined) {
177
+ // Department must belong to the class's institution.
178
+ const cls = await prisma.class.findUnique({
179
+ where: { id: classId },
180
+ select: { schoolId: true },
181
+ });
182
+ if (!cls?.schoolId && data.departmentId) {
183
+ throw new TRPCError({
184
+ code: "BAD_REQUEST",
185
+ message: "Personal classes can't have a department",
186
+ });
187
+ }
188
+ if (data.departmentId && cls?.schoolId) {
189
+ const dept = await prisma.department.findUnique({
190
+ where: { id: data.departmentId },
191
+ select: { schoolId: true },
192
+ });
193
+ if (!dept || dept.schoolId !== cls.schoolId) {
194
+ throw new TRPCError({
195
+ code: "BAD_REQUEST",
196
+ message: "Department does not belong to this institution",
197
+ });
198
+ }
199
+ }
200
+ }
201
+
129
202
  const updatedClass = await prisma.class.update({
130
203
  where: { id: classId },
131
204
  data,
@@ -134,6 +207,8 @@ export async function updateClass(
134
207
  name: true,
135
208
  section: true,
136
209
  subject: true,
210
+ color: true,
211
+ departmentId: true,
137
212
  },
138
213
  });
139
214
  return { updatedClass };
@@ -149,9 +224,19 @@ export async function createClass(
149
224
  subject: string;
150
225
  color?: string;
151
226
  schoolId?: string;
227
+ departmentId?: string;
152
228
  }
153
229
  ) {
154
- const { students, teachers, name, section, subject, color, schoolId } = input;
230
+ const {
231
+ students,
232
+ teachers,
233
+ name,
234
+ section,
235
+ subject,
236
+ color,
237
+ schoolId,
238
+ departmentId,
239
+ } = input;
155
240
 
156
241
  if (schoolId) {
157
242
  const membership = await prisma.schoolMembership.findFirst({
@@ -174,6 +259,25 @@ export async function createClass(
174
259
  }
175
260
  }
176
261
 
262
+ if (departmentId) {
263
+ if (!schoolId) {
264
+ throw new TRPCError({
265
+ code: "BAD_REQUEST",
266
+ message: "Personal classes can't have a department",
267
+ });
268
+ }
269
+ const dept = await prisma.department.findUnique({
270
+ where: { id: departmentId },
271
+ select: { schoolId: true },
272
+ });
273
+ if (!dept || dept.schoolId !== schoolId) {
274
+ throw new TRPCError({
275
+ code: "BAD_REQUEST",
276
+ message: "Department does not belong to this institution",
277
+ });
278
+ }
279
+ }
280
+
177
281
  const teacherIds = teachers?.length ? teachers : [userId];
178
282
  const studentIds = students?.length ? students : [];
179
283
 
@@ -188,6 +292,7 @@ export async function createClass(
188
292
  subject,
189
293
  color,
190
294
  schoolId,
295
+ departmentId,
191
296
  teachers: { connect: teacherIds.map((id) => ({ id })) },
192
297
  ...(studentIds.length
193
298
  ? { students: { connect: studentIds.map((id) => ({ id })) } }
@@ -8,6 +8,7 @@ import {
8
8
  countUnreadMessages,
9
9
  countUnreadMentions,
10
10
  findUserByUsername,
11
+ searchInstitutionConversationMembers,
11
12
  findDmConversationsByUserId,
12
13
  findUsersByIdsOrUsernames,
13
14
  createConversation,
@@ -17,6 +18,11 @@ import {
17
18
  deleteConversationMember,
18
19
  } from "../models/conversation.js";
19
20
 
21
+ /** Maximum number of member suggestions returned for conversation search. */
22
+ const MAX_MEMBER_SEARCH_RESULTS = 10;
23
+ /** Minimum non-whitespace characters required before running member search. */
24
+ const MIN_MEMBER_SEARCH_QUERY_LENGTH = 2;
25
+
20
26
  /** List user's conversations with unread and mention counts. */
21
27
  export async function listConversations(userId: string) {
22
28
  const conversations = await findConversationsByUserId(userId);
@@ -89,6 +95,30 @@ export async function hideConversation(userId: string, conversationId: string) {
89
95
  return { success: true };
90
96
  }
91
97
 
98
+ /**
99
+ * Search active school peers that can be selected for a new conversation.
100
+ *
101
+ * Blank or one-character input returns an empty list instead of searching all
102
+ * institution members, which keeps typeahead callers from issuing broad result
103
+ * queries.
104
+ */
105
+ export async function searchConversationMembers(userId: string, query: string) {
106
+ const trimmedQuery = query.trim();
107
+
108
+ if (
109
+ !trimmedQuery ||
110
+ trimmedQuery.length < MIN_MEMBER_SEARCH_QUERY_LENGTH
111
+ ) {
112
+ return [];
113
+ }
114
+
115
+ return searchInstitutionConversationMembers(
116
+ userId,
117
+ trimmedQuery,
118
+ MAX_MEMBER_SEARCH_RESULTS
119
+ );
120
+ }
121
+
92
122
  /** Create a DM or group conversation with the given members. */
93
123
  export async function createConversationRecord(
94
124
  userId: string,
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Department service — admin CRUD, members, and template management for
3
+ * department-level rubrics and grading boundaries.
4
+ */
5
+ import { TRPCError } from "@trpc/server";
6
+ import { prisma } from "../lib/prisma.js";
7
+ import {
8
+ addDepartmentMembers,
9
+ createDepartment,
10
+ deleteDepartment,
11
+ findDepartmentMembership,
12
+ getDepartment,
13
+ listDepartmentsForSchool,
14
+ listDepartmentsForUser,
15
+ removeDepartmentMember,
16
+ updateDepartment,
17
+ } from "../models/department.js";
18
+ import {
19
+ findActiveSchoolAdminMembership,
20
+ findSchoolMembership,
21
+ } from "../models/school.js";
22
+
23
+ async function requireAdmin(schoolId: string, userId: string) {
24
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
25
+ if (!membership) {
26
+ throw new TRPCError({
27
+ code: "FORBIDDEN",
28
+ message: "Only school admins can manage departments",
29
+ });
30
+ }
31
+ }
32
+
33
+ async function requireMember(schoolId: string, userId: string) {
34
+ const membership = await findSchoolMembership(schoolId, userId);
35
+ if (!membership || membership.status !== "ACTIVE") {
36
+ throw new TRPCError({
37
+ code: "FORBIDDEN",
38
+ message: "You aren't a member of this institution",
39
+ });
40
+ }
41
+ }
42
+
43
+ export async function listDepartments(
44
+ userId: string,
45
+ schoolId: string
46
+ ) {
47
+ await requireMember(schoolId, userId);
48
+ return listDepartmentsForSchool(schoolId);
49
+ }
50
+
51
+ export async function listMyDepartments(
52
+ userId: string,
53
+ schoolId: string
54
+ ) {
55
+ await requireMember(schoolId, userId);
56
+ return listDepartmentsForUser(userId, schoolId);
57
+ }
58
+
59
+ export async function getDepartmentDetails(
60
+ userId: string,
61
+ departmentId: string
62
+ ) {
63
+ const department = await getDepartment(departmentId);
64
+ if (!department) {
65
+ throw new TRPCError({
66
+ code: "NOT_FOUND",
67
+ message: "Department not found",
68
+ });
69
+ }
70
+ await requireMember(department.schoolId, userId);
71
+ return department;
72
+ }
73
+
74
+ export async function createDepartmentRecord(
75
+ userId: string,
76
+ input: {
77
+ schoolId: string;
78
+ name: string;
79
+ description?: string;
80
+ color?: string;
81
+ }
82
+ ) {
83
+ await requireAdmin(input.schoolId, userId);
84
+ return createDepartment(input);
85
+ }
86
+
87
+ export async function updateDepartmentRecord(
88
+ userId: string,
89
+ input: {
90
+ departmentId: string;
91
+ name?: string;
92
+ description?: string | null;
93
+ color?: string;
94
+ }
95
+ ) {
96
+ const department = await getDepartment(input.departmentId);
97
+ if (!department) {
98
+ throw new TRPCError({
99
+ code: "NOT_FOUND",
100
+ message: "Department not found",
101
+ });
102
+ }
103
+ await requireAdmin(department.schoolId, userId);
104
+ return updateDepartment(input.departmentId, {
105
+ name: input.name,
106
+ description: input.description,
107
+ color: input.color,
108
+ });
109
+ }
110
+
111
+ export async function deleteDepartmentRecord(
112
+ userId: string,
113
+ departmentId: string
114
+ ) {
115
+ const department = await getDepartment(departmentId);
116
+ if (!department) {
117
+ throw new TRPCError({
118
+ code: "NOT_FOUND",
119
+ message: "Department not found",
120
+ });
121
+ }
122
+ await requireAdmin(department.schoolId, userId);
123
+ await deleteDepartment(departmentId);
124
+ return { success: true };
125
+ }
126
+
127
+ export async function addMembersToDepartment(
128
+ userId: string,
129
+ input: { departmentId: string; userIds: string[] }
130
+ ) {
131
+ const department = await getDepartment(input.departmentId);
132
+ if (!department) {
133
+ throw new TRPCError({
134
+ code: "NOT_FOUND",
135
+ message: "Department not found",
136
+ });
137
+ }
138
+ await requireAdmin(department.schoolId, userId);
139
+
140
+ // Members must already be in the institution.
141
+ const memberships = await prisma.schoolMembership.findMany({
142
+ where: {
143
+ schoolId: department.schoolId,
144
+ userId: { in: input.userIds },
145
+ status: "ACTIVE",
146
+ },
147
+ select: { userId: true },
148
+ });
149
+ const validIds = new Set(memberships.map((m) => m.userId));
150
+ const validUserIds = input.userIds.filter((id) => validIds.has(id));
151
+
152
+ if (validUserIds.length === 0) {
153
+ throw new TRPCError({
154
+ code: "BAD_REQUEST",
155
+ message: "None of the supplied users are institution members",
156
+ });
157
+ }
158
+ return addDepartmentMembers(input.departmentId, validUserIds);
159
+ }
160
+
161
+ export async function removeMemberFromDepartment(
162
+ userId: string,
163
+ input: { departmentId: string; memberId: string }
164
+ ) {
165
+ const department = await getDepartment(input.departmentId);
166
+ if (!department) {
167
+ throw new TRPCError({
168
+ code: "NOT_FOUND",
169
+ message: "Department not found",
170
+ });
171
+ }
172
+ await requireAdmin(department.schoolId, userId);
173
+ return removeDepartmentMember(input.departmentId, input.memberId);
174
+ }
175
+
176
+ /** Department template authoring (rubrics + boundaries). */
177
+ export async function createRubricTemplate(
178
+ userId: string,
179
+ input: { departmentId: string; name: string; structured: string }
180
+ ) {
181
+ const department = await getDepartment(input.departmentId);
182
+ if (!department) {
183
+ throw new TRPCError({
184
+ code: "NOT_FOUND",
185
+ message: "Department not found",
186
+ });
187
+ }
188
+ await requireAdmin(department.schoolId, userId);
189
+ return prisma.markScheme.create({
190
+ data: {
191
+ name: input.name,
192
+ structured: input.structured.replace(/\\n/g, "\n"),
193
+ department: { connect: { id: input.departmentId } },
194
+ },
195
+ select: { id: true, name: true, structured: true, departmentId: true },
196
+ });
197
+ }
198
+
199
+ export async function updateRubricTemplate(
200
+ userId: string,
201
+ input: {
202
+ templateId: string;
203
+ name?: string;
204
+ structured?: string;
205
+ }
206
+ ) {
207
+ const template = await prisma.markScheme.findUnique({
208
+ where: { id: input.templateId },
209
+ select: { departmentId: true, department: { select: { schoolId: true } } },
210
+ });
211
+ if (!template?.departmentId || !template.department) {
212
+ throw new TRPCError({
213
+ code: "NOT_FOUND",
214
+ message: "Template not found",
215
+ });
216
+ }
217
+ await requireAdmin(template.department.schoolId, userId);
218
+ return prisma.markScheme.update({
219
+ where: { id: input.templateId },
220
+ data: {
221
+ ...(input.name !== undefined ? { name: input.name } : {}),
222
+ ...(input.structured !== undefined
223
+ ? { structured: input.structured.replace(/\\n/g, "\n") }
224
+ : {}),
225
+ },
226
+ });
227
+ }
228
+
229
+ export async function deleteRubricTemplate(
230
+ userId: string,
231
+ templateId: string
232
+ ) {
233
+ const template = await prisma.markScheme.findUnique({
234
+ where: { id: templateId },
235
+ select: { departmentId: true, department: { select: { schoolId: true } } },
236
+ });
237
+ if (!template?.departmentId || !template.department) {
238
+ throw new TRPCError({
239
+ code: "NOT_FOUND",
240
+ message: "Template not found",
241
+ });
242
+ }
243
+ await requireAdmin(template.department.schoolId, userId);
244
+ return prisma.markScheme.delete({ where: { id: templateId } });
245
+ }
246
+
247
+ export async function createBoundaryTemplate(
248
+ userId: string,
249
+ input: { departmentId: string; name: string; structured: string }
250
+ ) {
251
+ const department = await getDepartment(input.departmentId);
252
+ if (!department) {
253
+ throw new TRPCError({
254
+ code: "NOT_FOUND",
255
+ message: "Department not found",
256
+ });
257
+ }
258
+ await requireAdmin(department.schoolId, userId);
259
+ return prisma.gradingBoundary.create({
260
+ data: {
261
+ name: input.name,
262
+ structured: input.structured.replace(/\\n/g, "\n"),
263
+ department: { connect: { id: input.departmentId } },
264
+ },
265
+ select: { id: true, name: true, structured: true, departmentId: true },
266
+ });
267
+ }
268
+
269
+ export async function updateBoundaryTemplate(
270
+ userId: string,
271
+ input: {
272
+ templateId: string;
273
+ name?: string;
274
+ structured?: string;
275
+ }
276
+ ) {
277
+ const template = await prisma.gradingBoundary.findUnique({
278
+ where: { id: input.templateId },
279
+ select: { departmentId: true, department: { select: { schoolId: true } } },
280
+ });
281
+ if (!template?.departmentId || !template.department) {
282
+ throw new TRPCError({
283
+ code: "NOT_FOUND",
284
+ message: "Template not found",
285
+ });
286
+ }
287
+ await requireAdmin(template.department.schoolId, userId);
288
+ return prisma.gradingBoundary.update({
289
+ where: { id: input.templateId },
290
+ data: {
291
+ ...(input.name !== undefined ? { name: input.name } : {}),
292
+ ...(input.structured !== undefined
293
+ ? { structured: input.structured.replace(/\\n/g, "\n") }
294
+ : {}),
295
+ },
296
+ });
297
+ }
298
+
299
+ export async function deleteBoundaryTemplate(
300
+ userId: string,
301
+ templateId: string
302
+ ) {
303
+ const template = await prisma.gradingBoundary.findUnique({
304
+ where: { id: templateId },
305
+ select: { departmentId: true, department: { select: { schoolId: true } } },
306
+ });
307
+ if (!template?.departmentId || !template.department) {
308
+ throw new TRPCError({
309
+ code: "NOT_FOUND",
310
+ message: "Template not found",
311
+ });
312
+ }
313
+ await requireAdmin(template.department.schoolId, userId);
314
+ return prisma.gradingBoundary.delete({ where: { id: templateId } });
315
+ }
316
+
317
+ /**
318
+ * Apply a department template to a class by *copying* the structured payload
319
+ * into a class-scoped record. Teacher-only (the class teacher).
320
+ */
321
+ export async function applyRubricTemplate(
322
+ userId: string,
323
+ input: { classId: string; templateId: string }
324
+ ) {
325
+ const cls = await prisma.class.findFirst({
326
+ where: {
327
+ id: input.classId,
328
+ OR: [
329
+ { teachers: { some: { id: userId } } },
330
+ {
331
+ school: {
332
+ memberships: {
333
+ some: { userId, role: "ADMIN", status: "ACTIVE" },
334
+ },
335
+ },
336
+ },
337
+ ],
338
+ },
339
+ select: { id: true, departmentId: true },
340
+ });
341
+ if (!cls) {
342
+ throw new TRPCError({
343
+ code: "FORBIDDEN",
344
+ message: "You don't have permission to apply templates to this class",
345
+ });
346
+ }
347
+
348
+ const template = await prisma.markScheme.findUnique({
349
+ where: { id: input.templateId },
350
+ select: { id: true, structured: true, departmentId: true, name: true },
351
+ });
352
+ if (!template?.departmentId) {
353
+ throw new TRPCError({
354
+ code: "NOT_FOUND",
355
+ message: "Template not found",
356
+ });
357
+ }
358
+ if (cls.departmentId && cls.departmentId !== template.departmentId) {
359
+ throw new TRPCError({
360
+ code: "FORBIDDEN",
361
+ message:
362
+ "This template belongs to a different department than the class",
363
+ });
364
+ }
365
+
366
+ return prisma.markScheme.create({
367
+ data: {
368
+ class: { connect: { id: input.classId } },
369
+ structured: template.structured,
370
+ name: template.name,
371
+ },
372
+ });
373
+ }
374
+
375
+ export async function applyBoundaryTemplate(
376
+ userId: string,
377
+ input: { classId: string; templateId: string }
378
+ ) {
379
+ const cls = await prisma.class.findFirst({
380
+ where: {
381
+ id: input.classId,
382
+ OR: [
383
+ { teachers: { some: { id: userId } } },
384
+ {
385
+ school: {
386
+ memberships: {
387
+ some: { userId, role: "ADMIN", status: "ACTIVE" },
388
+ },
389
+ },
390
+ },
391
+ ],
392
+ },
393
+ select: { id: true, departmentId: true },
394
+ });
395
+ if (!cls) {
396
+ throw new TRPCError({
397
+ code: "FORBIDDEN",
398
+ message: "You don't have permission to apply templates to this class",
399
+ });
400
+ }
401
+
402
+ const template = await prisma.gradingBoundary.findUnique({
403
+ where: { id: input.templateId },
404
+ select: { id: true, structured: true, departmentId: true, name: true },
405
+ });
406
+ if (!template?.departmentId) {
407
+ throw new TRPCError({
408
+ code: "NOT_FOUND",
409
+ message: "Template not found",
410
+ });
411
+ }
412
+ if (cls.departmentId && cls.departmentId !== template.departmentId) {
413
+ throw new TRPCError({
414
+ code: "FORBIDDEN",
415
+ message:
416
+ "This template belongs to a different department than the class",
417
+ });
418
+ }
419
+
420
+ return prisma.gradingBoundary.create({
421
+ data: {
422
+ class: { connect: { id: input.classId } },
423
+ structured: template.structured,
424
+ name: template.name,
425
+ },
426
+ });
427
+ }
428
+
429
+ /** Helper used by the auto-tag flow on class creation. */
430
+ export async function setClassDepartment(
431
+ userId: string,
432
+ input: { classId: string; departmentId: string | null }
433
+ ) {
434
+ const cls = await prisma.class.findFirst({
435
+ where: {
436
+ id: input.classId,
437
+ OR: [
438
+ { teachers: { some: { id: userId } } },
439
+ {
440
+ school: {
441
+ memberships: {
442
+ some: { userId, role: "ADMIN", status: "ACTIVE" },
443
+ },
444
+ },
445
+ },
446
+ ],
447
+ },
448
+ select: { id: true, schoolId: true },
449
+ });
450
+ if (!cls?.schoolId) {
451
+ throw new TRPCError({
452
+ code: "BAD_REQUEST",
453
+ message: "Class is not affiliated with an institution",
454
+ });
455
+ }
456
+
457
+ if (input.departmentId) {
458
+ const dept = await getDepartment(input.departmentId);
459
+ if (!dept || dept.schoolId !== cls.schoolId) {
460
+ throw new TRPCError({
461
+ code: "BAD_REQUEST",
462
+ message: "Department does not belong to this institution",
463
+ });
464
+ }
465
+ }
466
+ return prisma.class.update({
467
+ where: { id: input.classId },
468
+ data: { departmentId: input.departmentId },
469
+ select: { id: true, departmentId: true },
470
+ });
471
+ }
472
+
473
+ export async function checkDepartmentMember(
474
+ userId: string,
475
+ departmentId: string
476
+ ) {
477
+ return Boolean(await findDepartmentMembership(departmentId, userId));
478
+ }