@studious-lms/server 1.4.6 → 1.4.9

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 (177) hide show
  1. package/.github/workflows/npm.yml +28 -0
  2. package/dist/models/access.d.ts +112 -0
  3. package/dist/models/access.d.ts.map +1 -0
  4. package/dist/models/access.js +49 -0
  5. package/dist/models/access.js.map +1 -0
  6. package/dist/models/announcement.d.ts +17 -2
  7. package/dist/models/announcement.d.ts.map +1 -1
  8. package/dist/models/announcement.js +21 -4
  9. package/dist/models/announcement.js.map +1 -1
  10. package/dist/models/assignment.d.ts +69 -37
  11. package/dist/models/assignment.d.ts.map +1 -1
  12. package/dist/models/assignment.js +124 -15
  13. package/dist/models/assignment.js.map +1 -1
  14. package/dist/models/attendance.d.ts +1 -1
  15. package/dist/models/attendance.d.ts.map +1 -1
  16. package/dist/models/attendance.js +6 -5
  17. package/dist/models/attendance.js.map +1 -1
  18. package/dist/models/auth.d.ts +5 -5
  19. package/dist/models/auth.d.ts.map +1 -1
  20. package/dist/models/auth.js +7 -11
  21. package/dist/models/auth.js.map +1 -1
  22. package/dist/models/class.d.ts +26 -26
  23. package/dist/models/class.d.ts.map +1 -1
  24. package/dist/models/class.js +7 -11
  25. package/dist/models/class.js.map +1 -1
  26. package/dist/models/comment.d.ts +134 -70
  27. package/dist/models/comment.d.ts.map +1 -1
  28. package/dist/models/comment.js +30 -42
  29. package/dist/models/comment.js.map +1 -1
  30. package/dist/models/conversation.d.ts +7 -7
  31. package/dist/models/event.d.ts +52 -3
  32. package/dist/models/event.d.ts.map +1 -1
  33. package/dist/models/event.js +56 -6
  34. package/dist/models/event.js.map +1 -1
  35. package/dist/models/file.d.ts +178 -103
  36. package/dist/models/file.d.ts.map +1 -1
  37. package/dist/models/file.js +54 -14
  38. package/dist/models/file.js.map +1 -1
  39. package/dist/models/folder.d.ts +22 -22
  40. package/dist/models/folder.d.ts.map +1 -1
  41. package/dist/models/folder.js +5 -4
  42. package/dist/models/folder.js.map +1 -1
  43. package/dist/models/labChat.d.ts +20 -20
  44. package/dist/models/labChat.d.ts.map +1 -1
  45. package/dist/models/labChat.js +8 -10
  46. package/dist/models/labChat.js.map +1 -1
  47. package/dist/models/marketing.d.ts +1 -1
  48. package/dist/models/message.d.ts +6 -6
  49. package/dist/models/newtonChat.d.ts +5 -5
  50. package/dist/models/school.d.ts +361 -0
  51. package/dist/models/school.d.ts.map +1 -0
  52. package/dist/models/school.js +314 -0
  53. package/dist/models/school.js.map +1 -0
  54. package/dist/models/section.d.ts +1 -1
  55. package/dist/models/section.d.ts.map +1 -1
  56. package/dist/models/section.js +5 -4
  57. package/dist/models/section.js.map +1 -1
  58. package/dist/models/user.d.ts +4 -4
  59. package/dist/models/worksheet.d.ts +163 -141
  60. package/dist/models/worksheet.d.ts.map +1 -1
  61. package/dist/models/worksheet.js +42 -17
  62. package/dist/models/worksheet.js.map +1 -1
  63. package/dist/pipelines/gradeWorksheet.d.ts +2 -2
  64. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  65. package/dist/routers/_app.d.ts +1271 -327
  66. package/dist/routers/_app.d.ts.map +1 -1
  67. package/dist/routers/_app.js +4 -2
  68. package/dist/routers/_app.js.map +1 -1
  69. package/dist/routers/announcement.d.ts +5 -5
  70. package/dist/routers/announcement.d.ts.map +1 -1
  71. package/dist/routers/announcement.js +25 -5
  72. package/dist/routers/announcement.js.map +1 -1
  73. package/dist/routers/assignment.d.ts +79 -44
  74. package/dist/routers/assignment.d.ts.map +1 -1
  75. package/dist/routers/assignment.js +32 -12
  76. package/dist/routers/assignment.js.map +1 -1
  77. package/dist/routers/class.d.ts +40 -20
  78. package/dist/routers/class.d.ts.map +1 -1
  79. package/dist/routers/class.js +3 -2
  80. package/dist/routers/class.js.map +1 -1
  81. package/dist/routers/comment.d.ts +5 -5
  82. package/dist/routers/conversation.d.ts +6 -6
  83. package/dist/routers/event.d.ts +21 -2
  84. package/dist/routers/event.d.ts.map +1 -1
  85. package/dist/routers/file.d.ts +2 -2
  86. package/dist/routers/folder.d.ts +24 -24
  87. package/dist/routers/labChat.d.ts +7 -7
  88. package/dist/routers/school.d.ts +384 -0
  89. package/dist/routers/school.d.ts.map +1 -0
  90. package/dist/routers/school.js +79 -0
  91. package/dist/routers/school.js.map +1 -0
  92. package/dist/routers/search.d.ts +1 -1
  93. package/dist/routers/worksheet.d.ts +36 -20
  94. package/dist/routers/worksheet.d.ts.map +1 -1
  95. package/dist/routers/worksheet.js +10 -3
  96. package/dist/routers/worksheet.js.map +1 -1
  97. package/dist/services/announcement.d.ts.map +1 -1
  98. package/dist/services/announcement.js +10 -6
  99. package/dist/services/announcement.js.map +1 -1
  100. package/dist/services/assignment.d.ts +66 -36
  101. package/dist/services/assignment.d.ts.map +1 -1
  102. package/dist/services/assignment.js +37 -3
  103. package/dist/services/assignment.js.map +1 -1
  104. package/dist/services/class.d.ts +39 -19
  105. package/dist/services/class.d.ts.map +1 -1
  106. package/dist/services/class.js +52 -15
  107. package/dist/services/class.js.map +1 -1
  108. package/dist/services/comment.d.ts +5 -5
  109. package/dist/services/comment.d.ts.map +1 -1
  110. package/dist/services/comment.js +8 -3
  111. package/dist/services/comment.js.map +1 -1
  112. package/dist/services/conversation.d.ts +6 -6
  113. package/dist/services/event.d.ts +21 -2
  114. package/dist/services/event.d.ts.map +1 -1
  115. package/dist/services/event.js +11 -7
  116. package/dist/services/event.js.map +1 -1
  117. package/dist/services/file.d.ts +2 -2
  118. package/dist/services/file.d.ts.map +1 -1
  119. package/dist/services/file.js +17 -8
  120. package/dist/services/file.js.map +1 -1
  121. package/dist/services/folder.d.ts +24 -24
  122. package/dist/services/labChat.d.ts +7 -7
  123. package/dist/services/school.d.ts +299 -0
  124. package/dist/services/school.d.ts.map +1 -0
  125. package/dist/services/school.js +225 -0
  126. package/dist/services/school.js.map +1 -0
  127. package/dist/services/worksheet.d.ts +31 -20
  128. package/dist/services/worksheet.d.ts.map +1 -1
  129. package/dist/services/worksheet.js +37 -3
  130. package/dist/services/worksheet.js.map +1 -1
  131. package/package.json +1 -1
  132. package/prisma/migrations/20260504071500_school_memberships/migration.sql +81 -0
  133. package/prisma/schema.prisma +66 -2
  134. package/src/models/access.ts +48 -0
  135. package/src/models/announcement.ts +19 -2
  136. package/src/models/assignment.ts +149 -13
  137. package/src/models/attendance.ts +4 -3
  138. package/src/models/auth.ts +8 -9
  139. package/src/models/class.ts +8 -9
  140. package/src/models/comment.ts +29 -40
  141. package/src/models/event.ts +54 -4
  142. package/src/models/file.ts +53 -12
  143. package/src/models/folder.ts +3 -2
  144. package/src/models/labChat.ts +9 -8
  145. package/src/models/school.ts +361 -0
  146. package/src/models/section.ts +3 -2
  147. package/src/models/worksheet.ts +61 -12
  148. package/src/routers/_app.ts +2 -0
  149. package/src/routers/announcement.ts +30 -4
  150. package/src/routers/assignment.ts +46 -9
  151. package/src/routers/class.ts +1 -0
  152. package/src/routers/school.ts +122 -0
  153. package/src/routers/worksheet.ts +18 -0
  154. package/src/services/announcement.ts +12 -4
  155. package/src/services/assignment.ts +47 -1
  156. package/src/services/class.ts +61 -14
  157. package/src/services/comment.ts +10 -1
  158. package/src/services/event.ts +23 -6
  159. package/src/services/file.ts +20 -6
  160. package/src/services/school.ts +308 -0
  161. package/src/services/worksheet.ts +46 -0
  162. package/dist/lib/notificationHandler.d.ts +0 -25
  163. package/dist/lib/notificationHandler.d.ts.map +0 -1
  164. package/dist/lib/notificationHandler.js +0 -32
  165. package/dist/lib/notificationHandler.js.map +0 -1
  166. package/dist/server/pipelines/aiLabChat.d.ts +0 -21
  167. package/dist/server/pipelines/aiLabChat.d.ts.map +0 -1
  168. package/dist/server/pipelines/aiLabChat.js +0 -456
  169. package/dist/server/pipelines/aiLabChat.js.map +0 -1
  170. package/dist/server/pipelines/aiNewtonChat.d.ts +0 -30
  171. package/dist/server/pipelines/aiNewtonChat.d.ts.map +0 -1
  172. package/dist/server/pipelines/aiNewtonChat.js +0 -285
  173. package/dist/server/pipelines/aiNewtonChat.js.map +0 -1
  174. package/dist/server/pipelines/gradeWorksheet.d.ts +0 -30
  175. package/dist/server/pipelines/gradeWorksheet.d.ts.map +0 -1
  176. package/dist/server/pipelines/gradeWorksheet.js +0 -248
  177. package/dist/server/pipelines/gradeWorksheet.js.map +0 -1
@@ -0,0 +1,361 @@
1
+ /**
2
+ * School model - school membership, invites, and admin lookups.
3
+ */
4
+ import { SchoolMembershipRole } from "@prisma/client";
5
+ import { prisma } from "../lib/prisma.js";
6
+
7
+ const memberSelect = {
8
+ id: true,
9
+ role: true,
10
+ status: true,
11
+ createdAt: true,
12
+ updatedAt: true,
13
+ user: {
14
+ select: {
15
+ id: true,
16
+ username: true,
17
+ email: true,
18
+ profile: {
19
+ select: {
20
+ displayName: true,
21
+ profilePicture: true,
22
+ profilePictureThumbnail: true,
23
+ },
24
+ },
25
+ },
26
+ },
27
+ } as const;
28
+
29
+ export function findSchoolMembership(schoolId: string, userId: string) {
30
+ return prisma.schoolMembership.findUnique({
31
+ where: { schoolId_userId: { schoolId, userId } },
32
+ });
33
+ }
34
+
35
+ export function findActiveSchoolMembership(schoolId: string, userId: string) {
36
+ return prisma.schoolMembership.findFirst({
37
+ where: { schoolId, userId, status: "ACTIVE" },
38
+ });
39
+ }
40
+
41
+ export function findActiveSchoolAdminMembership(schoolId: string, userId: string) {
42
+ return prisma.schoolMembership.findFirst({
43
+ where: { schoolId, userId, role: "ADMIN", status: "ACTIVE" },
44
+ });
45
+ }
46
+
47
+ export function listUserSchoolMemberships(userId: string) {
48
+ return prisma.schoolMembership.findMany({
49
+ where: { userId, status: "ACTIVE" },
50
+ include: {
51
+ school: {
52
+ select: {
53
+ id: true,
54
+ name: true,
55
+ subdomain: true,
56
+ logoId: true,
57
+ createdAt: true,
58
+ updatedAt: true,
59
+ },
60
+ },
61
+ },
62
+ orderBy: { createdAt: "asc" },
63
+ });
64
+ }
65
+
66
+ export function createSchoolWithAdmin(data: {
67
+ name: string;
68
+ subdomain?: string;
69
+ logoId?: string;
70
+ userId: string;
71
+ }) {
72
+ return prisma.school.create({
73
+ data: {
74
+ name: data.name,
75
+ subdomain: data.subdomain,
76
+ logoId: data.logoId,
77
+ memberships: {
78
+ create: {
79
+ userId: data.userId,
80
+ role: "ADMIN",
81
+ status: "ACTIVE",
82
+ },
83
+ },
84
+ },
85
+ include: {
86
+ memberships: {
87
+ where: { userId: data.userId },
88
+ },
89
+ },
90
+ });
91
+ }
92
+
93
+ export function getSchoolById(schoolId: string) {
94
+ return prisma.school.findUnique({
95
+ where: { id: schoolId },
96
+ select: {
97
+ id: true,
98
+ name: true,
99
+ subdomain: true,
100
+ logoId: true,
101
+ createdAt: true,
102
+ updatedAt: true,
103
+ },
104
+ });
105
+ }
106
+
107
+ export function getSchoolOverview(schoolId: string) {
108
+ return prisma.school.findUnique({
109
+ where: { id: schoolId },
110
+ select: {
111
+ id: true,
112
+ name: true,
113
+ subdomain: true,
114
+ _count: {
115
+ select: {
116
+ classes: true,
117
+ memberships: { where: { status: "ACTIVE" } },
118
+ invites: { where: { status: "PENDING" } },
119
+ },
120
+ },
121
+ memberships: {
122
+ where: { status: "ACTIVE" },
123
+ select: { role: true },
124
+ },
125
+ classes: {
126
+ orderBy: { name: "asc" },
127
+ take: 5,
128
+ select: {
129
+ id: true,
130
+ name: true,
131
+ subject: true,
132
+ section: true,
133
+ color: true,
134
+ _count: { select: { teachers: true, students: true } },
135
+ },
136
+ },
137
+ invites: {
138
+ where: { status: "PENDING" },
139
+ orderBy: { createdAt: "desc" },
140
+ take: 5,
141
+ select: {
142
+ id: true,
143
+ email: true,
144
+ role: true,
145
+ expiresAt: true,
146
+ createdAt: true,
147
+ },
148
+ },
149
+ },
150
+ });
151
+ }
152
+
153
+ export function listSchoolClasses(schoolId: string) {
154
+ return prisma.class.findMany({
155
+ where: { schoolId },
156
+ orderBy: [{ name: "asc" }, { section: "asc" }],
157
+ include: {
158
+ teachers: {
159
+ select: {
160
+ id: true,
161
+ username: true,
162
+ profile: {
163
+ select: {
164
+ displayName: true,
165
+ profilePicture: true,
166
+ profilePictureThumbnail: true,
167
+ },
168
+ },
169
+ },
170
+ },
171
+ students: { select: { id: true } },
172
+ _count: { select: { teachers: true, students: true, assignments: true } },
173
+ },
174
+ });
175
+ }
176
+
177
+ export function listSchoolMembers(schoolId: string) {
178
+ return prisma.schoolMembership.findMany({
179
+ where: { schoolId },
180
+ select: memberSelect,
181
+ orderBy: [{ role: "asc" }, { createdAt: "asc" }],
182
+ });
183
+ }
184
+
185
+ export function countActiveAdmins(schoolId: string) {
186
+ return prisma.schoolMembership.count({
187
+ where: { schoolId, role: "ADMIN", status: "ACTIVE" },
188
+ });
189
+ }
190
+
191
+ export function upsertSchoolMembership(data: {
192
+ schoolId: string;
193
+ userId: string;
194
+ role: SchoolMembershipRole;
195
+ }) {
196
+ return prisma.schoolMembership.upsert({
197
+ where: {
198
+ schoolId_userId: {
199
+ schoolId: data.schoolId,
200
+ userId: data.userId,
201
+ },
202
+ },
203
+ update: {
204
+ role: data.role,
205
+ status: "ACTIVE",
206
+ },
207
+ create: {
208
+ schoolId: data.schoolId,
209
+ userId: data.userId,
210
+ role: data.role,
211
+ status: "ACTIVE",
212
+ },
213
+ });
214
+ }
215
+
216
+ export function updateSchoolMembershipRole(
217
+ schoolId: string,
218
+ userId: string,
219
+ role: SchoolMembershipRole
220
+ ) {
221
+ return prisma.schoolMembership.update({
222
+ where: { schoolId_userId: { schoolId, userId } },
223
+ data: { role },
224
+ });
225
+ }
226
+
227
+ export function removeSchoolMembershipPreservingContent(schoolId: string, userId: string) {
228
+ return prisma.$transaction(async (tx) => {
229
+ const classes = await tx.class.findMany({
230
+ where: { schoolId },
231
+ select: { id: true },
232
+ });
233
+ const classIds = classes.map((cls) => ({ id: cls.id }));
234
+
235
+ for (const classWhere of classIds) {
236
+ await tx.class.update({
237
+ where: classWhere,
238
+ data: {
239
+ teachers: { disconnect: { id: userId } },
240
+ students: { disconnect: { id: userId } },
241
+ },
242
+ });
243
+ }
244
+
245
+ return tx.schoolMembership.delete({
246
+ where: { schoolId_userId: { schoolId, userId } },
247
+ });
248
+ });
249
+ }
250
+
251
+ export function findUserByEmailForSchool(email: string) {
252
+ return prisma.user.findFirst({
253
+ where: { email },
254
+ select: { id: true, email: true, username: true },
255
+ });
256
+ }
257
+
258
+ export function findUserByIdForSchool(userId: string) {
259
+ return prisma.user.findUnique({
260
+ where: { id: userId },
261
+ select: { id: true, email: true, username: true },
262
+ });
263
+ }
264
+
265
+ export function createSchoolInvite(data: {
266
+ schoolId: string;
267
+ invitedById: string;
268
+ email: string;
269
+ role: SchoolMembershipRole;
270
+ token: string;
271
+ expiresAt: Date;
272
+ }) {
273
+ return prisma.schoolInvite.create({
274
+ data,
275
+ include: {
276
+ school: { select: { id: true, name: true } },
277
+ },
278
+ });
279
+ }
280
+
281
+ export function listSchoolInvites(schoolId: string) {
282
+ return prisma.schoolInvite.findMany({
283
+ where: { schoolId },
284
+ orderBy: { createdAt: "desc" },
285
+ select: {
286
+ id: true,
287
+ email: true,
288
+ role: true,
289
+ status: true,
290
+ expiresAt: true,
291
+ acceptedAt: true,
292
+ revokedAt: true,
293
+ createdAt: true,
294
+ invitedBy: {
295
+ select: {
296
+ id: true,
297
+ username: true,
298
+ profile: { select: { displayName: true } },
299
+ },
300
+ },
301
+ },
302
+ });
303
+ }
304
+
305
+ export function revokeSchoolInvite(inviteId: string, schoolId: string) {
306
+ return prisma.schoolInvite.update({
307
+ where: { id: inviteId, schoolId },
308
+ data: {
309
+ status: "REVOKED",
310
+ revokedAt: new Date(),
311
+ },
312
+ });
313
+ }
314
+
315
+ export function findPendingInviteByToken(token: string) {
316
+ return prisma.schoolInvite.findUnique({
317
+ where: { token },
318
+ include: { school: { select: { id: true, name: true } } },
319
+ });
320
+ }
321
+
322
+ export function acceptSchoolInvite(inviteId: string, userId: string) {
323
+ return prisma.$transaction(async (tx) => {
324
+ const invite = await tx.schoolInvite.findUnique({
325
+ where: { id: inviteId },
326
+ });
327
+
328
+ if (!invite) {
329
+ throw new Error("Invite not found");
330
+ }
331
+
332
+ const membership = await tx.schoolMembership.upsert({
333
+ where: {
334
+ schoolId_userId: {
335
+ schoolId: invite.schoolId,
336
+ userId,
337
+ },
338
+ },
339
+ update: {
340
+ role: invite.role,
341
+ status: "ACTIVE",
342
+ },
343
+ create: {
344
+ schoolId: invite.schoolId,
345
+ userId,
346
+ role: invite.role,
347
+ status: "ACTIVE",
348
+ },
349
+ });
350
+
351
+ await tx.schoolInvite.update({
352
+ where: { id: inviteId },
353
+ data: {
354
+ status: "ACCEPTED",
355
+ acceptedAt: new Date(),
356
+ },
357
+ });
358
+
359
+ return membership;
360
+ });
361
+ }
@@ -2,6 +2,7 @@
2
2
  * Section model – class sections, ordering, assignment grouping.
3
3
  */
4
4
  import { prisma } from "../lib/prisma.js";
5
+ import { classTeacherOrSchoolAdminWhere } from "./access.js";
5
6
 
6
7
  /** @returns Section by ID. */
7
8
  export function findSectionById(id: string) {
@@ -10,12 +11,12 @@ export function findSectionById(id: string) {
10
11
  });
11
12
  }
12
13
 
13
- /** @returns Class if user is a teacher. */
14
+ /** @returns Class if user is a teacher or school admin. */
14
15
  export function findClassWithTeacher(classId: string, teacherId: string) {
15
16
  return prisma.class.findFirst({
16
17
  where: {
17
18
  id: classId,
18
- teachers: { some: { id: teacherId } },
19
+ ...classTeacherOrSchoolAdminWhere(teacherId),
19
20
  },
20
21
  });
21
22
  }
@@ -1,22 +1,20 @@
1
1
  /**
2
2
  * Worksheet model – worksheets, questions, submissions, student progress, grading.
3
3
  */
4
+ import type { Prisma } from "@prisma/client";
4
5
  import { prisma } from "../lib/prisma.js";
5
6
  import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
7
+ import {
8
+ classMemberOrSchoolAdminWhere,
9
+ classTeacherOrSchoolAdminWhere,
10
+ } from "./access.js";
6
11
 
7
12
  const worksheetClassMemberWhere = (userId: string) => ({
8
- class: {
9
- OR: [
10
- { teachers: { some: { id: userId } } },
11
- { students: { some: { id: userId } } },
12
- ],
13
- },
13
+ class: classMemberOrSchoolAdminWhere(userId),
14
14
  });
15
15
 
16
16
  const worksheetTeacherWhere = (userId: string) => ({
17
- class: {
18
- teachers: { some: { id: userId } },
19
- },
17
+ class: classTeacherOrSchoolAdminWhere(userId),
20
18
  });
21
19
 
22
20
  /** @returns Worksheet with questions and class. */
@@ -118,6 +116,59 @@ export function deleteWorksheet(id: string) {
118
116
  });
119
117
  }
120
118
 
119
+ /**
120
+ * Clone a worksheet and its questions into another class with a new name.
121
+ * Does not copy student responses or assignment links.
122
+ */
123
+ export async function duplicateWorksheetWithClient(
124
+ tx: Prisma.TransactionClient,
125
+ sourceWorksheetId: string,
126
+ targetClassId: string,
127
+ name: string
128
+ ) {
129
+ const source = await tx.worksheet.findUnique({
130
+ where: { id: sourceWorksheetId },
131
+ include: {
132
+ questions: { orderBy: { order: "asc" } },
133
+ },
134
+ });
135
+
136
+ if (!source) {
137
+ return null;
138
+ }
139
+
140
+ const created = await tx.worksheet.create({
141
+ data: { classId: targetClassId, name },
142
+ });
143
+
144
+ if (source.questions.length > 0) {
145
+ await tx.worksheetQuestion.createMany({
146
+ data: source.questions.map((q) => ({
147
+ worksheetId: created.id,
148
+ type: q.type,
149
+ question: q.question,
150
+ answer: q.answer,
151
+ points: q.points,
152
+ options: q.options ?? undefined,
153
+ markScheme: q.markScheme ?? undefined,
154
+ order: q.order,
155
+ })),
156
+ });
157
+ }
158
+
159
+ return created;
160
+ }
161
+
162
+ export async function duplicateWorksheet(
163
+ sourceWorksheetId: string,
164
+ targetClassId: string,
165
+ name: string
166
+ ) {
167
+ return prisma.$transaction((tx) =>
168
+ duplicateWorksheetWithClient(tx, sourceWorksheetId, targetClassId, name)
169
+ );
170
+ }
171
+
121
172
  /** @returns Questions for a worksheet. */
122
173
  export function findQuestionsByWorksheetId(worksheetId: string) {
123
174
  return prisma.worksheetQuestion.findMany({
@@ -207,9 +258,7 @@ export function findSubmissionByIdForWorksheetAccess(
207
258
  },
208
259
  {
209
260
  assignment: {
210
- class: {
211
- teachers: { some: { id: userId } },
212
- },
261
+ class: classTeacherOrSchoolAdminWhere(userId),
213
262
  },
214
263
  },
215
264
  ],
@@ -22,6 +22,7 @@ import { worksheetRouter } from "./worksheet.js";
22
22
  import { commentRouter } from "./comment.js";
23
23
  import { studentProgressRouter } from "./studentProgress.js";
24
24
  import { searchRouter } from "./search.js";
25
+ import { schoolRouter } from "./school.js";
25
26
 
26
27
  export const appRouter = createTRPCRouter({
27
28
  class: classRouter,
@@ -45,6 +46,7 @@ export const appRouter = createTRPCRouter({
45
46
  comment: commentRouter,
46
47
  studentProgress: studentProgressRouter,
47
48
  search: searchRouter,
49
+ school: schoolRouter,
48
50
  });
49
51
 
50
52
  // Export type router type definition
@@ -339,6 +339,24 @@ export const announcementRouter = createTRPCRouter({
339
339
  class: {
340
340
  include: {
341
341
  teachers: true,
342
+ school: {
343
+ include: {
344
+ users: {
345
+ where: {
346
+ role: "ADMIN",
347
+ },
348
+ },
349
+ memberships: {
350
+ where: {
351
+ role: "ADMIN",
352
+ status: "ACTIVE",
353
+ },
354
+ select: {
355
+ userId: true,
356
+ },
357
+ },
358
+ },
359
+ },
342
360
  },
343
361
  },
344
362
  },
@@ -353,17 +371,25 @@ export const announcementRouter = createTRPCRouter({
353
371
  });
354
372
  }
355
373
 
356
- // Only the author or a class teacher can delete comments
374
+ // Only the author, a class teacher, or the class school admin can delete comments
357
375
  const userId = ctx.user.id;
358
376
  const isAuthor = comment.authorId === userId;
359
377
  const isClassTeacher = comment.announcement!.class.teachers.some(
360
378
  (teacher) => teacher.id === userId
361
379
  );
380
+ const isSchoolAdmin = Boolean(
381
+ comment.announcement!.class.school?.memberships.some(
382
+ (admin) => admin.userId === userId
383
+ ) ||
384
+ comment.announcement!.class.school?.users.some(
385
+ (admin) => admin.id === userId
386
+ )
387
+ );
362
388
 
363
- if (!isAuthor && !isClassTeacher) {
389
+ if (!isAuthor && !isClassTeacher && !isSchoolAdmin) {
364
390
  throw new TRPCError({
365
391
  code: "FORBIDDEN",
366
- message: "Only the comment author or class teachers can delete comments",
392
+ message: "Only the comment author, class teachers, or school admins can delete comments",
367
393
  });
368
394
  }
369
395
 
@@ -696,4 +722,4 @@ export const announcementRouter = createTRPCRouter({
696
722
  message: "Unexpected error",
697
723
  });
698
724
  }),
699
- });
725
+ });
@@ -6,6 +6,10 @@ import { createDirectUploadFiles, type DirectUploadFile, confirmDirectUpload, up
6
6
  import { deleteFile } from "../lib/googleCloudStorage.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { gradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
9
+ import {
10
+ classMemberOrSchoolAdminWhere,
11
+ classTeacherOrSchoolAdminWhere,
12
+ } from "../models/access.js";
9
13
  import {
10
14
  assignmentExists,
11
15
  getDueToday,
@@ -14,6 +18,7 @@ import {
14
18
  getSubmissionById,
15
19
  getSubmissions,
16
20
  createAssignmentRecord,
21
+ duplicateAssignmentRecord,
17
22
  updateAssignmentRecord,
18
23
  deleteAssignmentRecord,
19
24
  updateSubmissionRecord,
@@ -269,6 +274,24 @@ export const assignmentRouter = createTRPCRouter({
269
274
  create: protectedTeacherProcedure
270
275
  .input(createAssignmentSchema)
271
276
  .mutation(({ ctx, input }) => createAssignmentRecord(ctx.user!.id, input)),
277
+
278
+ duplicateAssignment: protectedTeacherProcedure
279
+ .input(
280
+ z.object({
281
+ classId: z.string(),
282
+ title: z.string(),
283
+ assignmentId: z.string(),
284
+ })
285
+ )
286
+ .mutation(({ ctx, input }) =>
287
+ duplicateAssignmentRecord(
288
+ input.assignmentId,
289
+ input.classId,
290
+ input.title,
291
+ ctx.user!.id
292
+ )
293
+ ),
294
+
272
295
  update: protectedTeacherProcedure
273
296
  .input(updateAssignmentSchema)
274
297
  .mutation(({ ctx, input }) => updateAssignmentRecord(ctx.user!.id, input)),
@@ -394,16 +417,11 @@ export const assignmentRouter = createTRPCRouter({
394
417
  assignmentId: input.assignmentId,
395
418
  assignment: {
396
419
  classId: input.classId,
397
- class: {
398
- OR: [
399
- { teachers: { some: { id: ctx.user!.id } } },
400
- { students: { some: { id: ctx.user!.id } } },
401
- ],
402
- },
420
+ class: classMemberOrSchoolAdminWhere(ctx.user!.id),
403
421
  },
404
422
  OR: [
405
423
  { studentId: ctx.user!.id },
406
- { assignment: { class: { teachers: { some: { id: ctx.user!.id } } } } },
424
+ { assignment: { class: classTeacherOrSchoolAdminWhere(ctx.user!.id) } },
407
425
  ],
408
426
  },
409
427
  select: { id: true },
@@ -437,7 +455,7 @@ export const assignmentRouter = createTRPCRouter({
437
455
  assignment: { classId: input.classId },
438
456
  OR: [
439
457
  { studentId: ctx.user!.id },
440
- { assignment: { class: { teachers: { some: { id: ctx.user!.id } } } } },
458
+ { assignment: { class: classTeacherOrSchoolAdminWhere(ctx.user!.id) } },
441
459
  ],
442
460
  },
443
461
  select: { id: true },
@@ -630,6 +648,21 @@ export const assignmentRouter = createTRPCRouter({
630
648
  include: {
631
649
  students: true,
632
650
  teachers: true,
651
+ school: {
652
+ include: {
653
+ users: {
654
+ where: { role: "ADMIN" },
655
+ select: { id: true },
656
+ },
657
+ memberships: {
658
+ where: {
659
+ role: "ADMIN",
660
+ status: "ACTIVE",
661
+ },
662
+ select: { userId: true },
663
+ },
664
+ },
665
+ },
633
666
  },
634
667
  },
635
668
  },
@@ -647,8 +680,12 @@ export const assignmentRouter = createTRPCRouter({
647
680
  // Check if user is the student who owns the submission or a teacher of the class
648
681
  const isStudent = submission.studentId === ctx.user.id;
649
682
  const isTeacher = submission.assignment.class.teachers.some(teacher => teacher.id === ctx.user?.id);
683
+ const isSchoolAdmin = Boolean(
684
+ submission.assignment.class.school?.memberships.some(admin => admin.userId === ctx.user?.id) ||
685
+ submission.assignment.class.school?.users.some(admin => admin.id === ctx.user?.id)
686
+ );
650
687
 
651
- if (!isStudent && !isTeacher) {
688
+ if (!isStudent && !isTeacher && !isSchoolAdmin) {
652
689
  throw new TRPCError({
653
690
  code: "FORBIDDEN",
654
691
  message: "You don't have permission to upload files to this submission",
@@ -74,6 +74,7 @@ export const classRouter = createTRPCRouter({
74
74
  section: z.string(),
75
75
  subject: z.string(),
76
76
  color: z.string().optional(),
77
+ schoolId: z.string().optional(),
77
78
  })
78
79
  )
79
80
  .mutation(({ ctx, input }) =>