@studious-lms/server 1.21.0 → 1.23.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 (120) hide show
  1. package/dist/models/agenda.d.ts +6 -0
  2. package/dist/models/agenda.d.ts.map +1 -1
  3. package/dist/models/announcement.d.ts +2 -0
  4. package/dist/models/announcement.d.ts.map +1 -1
  5. package/dist/models/assignment.d.ts +27 -0
  6. package/dist/models/assignment.d.ts.map +1 -1
  7. package/dist/models/auth.d.ts +4 -0
  8. package/dist/models/auth.d.ts.map +1 -1
  9. package/dist/models/class.d.ts +14 -0
  10. package/dist/models/class.d.ts.map +1 -1
  11. package/dist/models/event.d.ts +11 -0
  12. package/dist/models/event.d.ts.map +1 -1
  13. package/dist/models/file.d.ts +19 -0
  14. package/dist/models/file.d.ts.map +1 -1
  15. package/dist/models/folder.d.ts +4 -0
  16. package/dist/models/folder.d.ts.map +1 -1
  17. package/dist/models/gradebook.d.ts +180 -0
  18. package/dist/models/gradebook.d.ts.map +1 -0
  19. package/dist/models/gradebook.js +88 -0
  20. package/dist/models/gradebook.js.map +1 -0
  21. package/dist/models/labChat.d.ts +4 -0
  22. package/dist/models/labChat.d.ts.map +1 -1
  23. package/dist/models/newtonChat.d.ts +2 -0
  24. package/dist/models/newtonChat.d.ts.map +1 -1
  25. package/dist/models/school.d.ts +177 -0
  26. package/dist/models/school.d.ts.map +1 -1
  27. package/dist/models/school.js +75 -2
  28. package/dist/models/school.js.map +1 -1
  29. package/dist/models/section.d.ts +3 -0
  30. package/dist/models/section.d.ts.map +1 -1
  31. package/dist/models/worksheet.d.ts +6 -0
  32. package/dist/models/worksheet.d.ts.map +1 -1
  33. package/dist/routers/_app.d.ts +1475 -73
  34. package/dist/routers/_app.d.ts.map +1 -1
  35. package/dist/routers/_app.js +4 -2
  36. package/dist/routers/_app.js.map +1 -1
  37. package/dist/routers/admin.d.ts +164 -0
  38. package/dist/routers/admin.d.ts.map +1 -1
  39. package/dist/routers/admin.js +23 -4
  40. package/dist/routers/admin.js.map +1 -1
  41. package/dist/routers/agenda.d.ts +6 -0
  42. package/dist/routers/agenda.d.ts.map +1 -1
  43. package/dist/routers/assignment.d.ts +30 -3
  44. package/dist/routers/assignment.d.ts.map +1 -1
  45. package/dist/routers/class.d.ts +17 -0
  46. package/dist/routers/class.d.ts.map +1 -1
  47. package/dist/routers/event.d.ts +4 -0
  48. package/dist/routers/event.d.ts.map +1 -1
  49. package/dist/routers/file.d.ts +66 -0
  50. package/dist/routers/file.d.ts.map +1 -1
  51. package/dist/routers/file.js +111 -2
  52. package/dist/routers/file.js.map +1 -1
  53. package/dist/routers/gradebook.d.ts +299 -0
  54. package/dist/routers/gradebook.d.ts.map +1 -0
  55. package/dist/routers/gradebook.js +107 -0
  56. package/dist/routers/gradebook.js.map +1 -0
  57. package/dist/routers/school.d.ts +118 -0
  58. package/dist/routers/school.d.ts.map +1 -1
  59. package/dist/routers/school.js +32 -3
  60. package/dist/routers/school.js.map +1 -1
  61. package/dist/routers/section.d.ts +1 -1
  62. package/dist/routers/worksheet.d.ts +3 -1
  63. package/dist/routers/worksheet.d.ts.map +1 -1
  64. package/dist/services/adminInstitution.d.ts +51 -0
  65. package/dist/services/adminInstitution.d.ts.map +1 -1
  66. package/dist/services/adminInstitution.js +52 -2
  67. package/dist/services/adminInstitution.js.map +1 -1
  68. package/dist/services/adminMetrics.d.ts +17 -0
  69. package/dist/services/adminMetrics.d.ts.map +1 -0
  70. package/dist/services/adminMetrics.js +37 -0
  71. package/dist/services/adminMetrics.js.map +1 -0
  72. package/dist/services/adminRunJob.d.ts +9 -0
  73. package/dist/services/adminRunJob.d.ts.map +1 -1
  74. package/dist/services/adminRunJob.js +18 -2
  75. package/dist/services/adminRunJob.js.map +1 -1
  76. package/dist/services/adminUser.d.ts +67 -0
  77. package/dist/services/adminUser.d.ts.map +1 -0
  78. package/dist/services/adminUser.js +87 -0
  79. package/dist/services/adminUser.js.map +1 -0
  80. package/dist/services/agenda.d.ts +6 -0
  81. package/dist/services/agenda.d.ts.map +1 -1
  82. package/dist/services/assignment.d.ts +27 -0
  83. package/dist/services/assignment.d.ts.map +1 -1
  84. package/dist/services/class.d.ts +17 -0
  85. package/dist/services/class.d.ts.map +1 -1
  86. package/dist/services/event.d.ts +4 -0
  87. package/dist/services/event.d.ts.map +1 -1
  88. package/dist/services/gradeCalculation.d.ts +70 -0
  89. package/dist/services/gradeCalculation.d.ts.map +1 -0
  90. package/dist/services/gradeCalculation.js +148 -0
  91. package/dist/services/gradeCalculation.js.map +1 -0
  92. package/dist/services/gradebook.d.ts +197 -0
  93. package/dist/services/gradebook.d.ts.map +1 -0
  94. package/dist/services/gradebook.js +263 -0
  95. package/dist/services/gradebook.js.map +1 -0
  96. package/dist/services/school.d.ts +98 -0
  97. package/dist/services/school.d.ts.map +1 -1
  98. package/dist/services/school.js +116 -4
  99. package/dist/services/school.js.map +1 -1
  100. package/dist/services/worksheet.d.ts +2 -0
  101. package/dist/services/worksheet.d.ts.map +1 -1
  102. package/package.json +1 -1
  103. package/prisma/migrations/20260708011629_add_gradebook_categories_columns_policies/migration.sql +78 -0
  104. package/prisma/migrations/20260723142314_make_file_annotation_submission_optional/migration.sql +2 -0
  105. package/prisma/schema.prisma +62 -2
  106. package/src/models/gradebook.ts +91 -0
  107. package/src/models/school.ts +89 -0
  108. package/src/routers/_app.ts +2 -0
  109. package/src/routers/admin.ts +28 -0
  110. package/src/routers/file.ts +128 -0
  111. package/src/routers/gradebook.ts +173 -0
  112. package/src/routers/school.ts +45 -0
  113. package/src/services/adminInstitution.ts +52 -0
  114. package/src/services/adminMetrics.ts +46 -0
  115. package/src/services/adminRunJob.ts +24 -0
  116. package/src/services/adminUser.ts +93 -0
  117. package/src/services/gradeCalculation.ts +253 -0
  118. package/src/services/gradebook.ts +383 -0
  119. package/src/services/school.ts +178 -1
  120. package/tests/unit/services/gradeCalculation.test.ts +206 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Gradebook service – grade categories, custom columns/entries, class
3
+ * grade-calculation policies, the computed gradebook, and CSV export.
4
+ *
5
+ * All grade math lives in `gradeCalculation.ts` (pure, unit-tested); this
6
+ * service only assembles inputs and persists configuration.
7
+ */
8
+ import { GradeRoundingMode as PrismaGradeRoundingMode } from "@prisma/client";
9
+ import { TRPCError } from "@trpc/server";
10
+ import { prisma } from "../lib/prisma.js";
11
+ import { isTeacherInClass } from "../models/class.js";
12
+ import {
13
+ findGradeCategoriesByClassId,
14
+ findGradeCategory,
15
+ findCustomGradeColumnsByClassId,
16
+ findCustomGradeColumn,
17
+ findClassForGradebook,
18
+ findClassGradePolicies,
19
+ } from "../models/gradebook.js";
20
+ import {
21
+ computeFinalGrade,
22
+ resolveLetterGrade,
23
+ type FinalGradeResult,
24
+ type GradeCategoryInput,
25
+ type ScoreInput,
26
+ } from "./gradeCalculation.js";
27
+
28
+ // ===== Categories =====
29
+
30
+ export async function listGradeCategories(classId: string) {
31
+ return findGradeCategoriesByClassId(classId);
32
+ }
33
+
34
+ export async function createGradeCategory(
35
+ classId: string,
36
+ data: { name: string; weight: number; dropLowest?: number; position?: number }
37
+ ) {
38
+ return prisma.gradeCategory.create({
39
+ data: {
40
+ classId,
41
+ name: data.name,
42
+ weight: data.weight,
43
+ dropLowest: data.dropLowest ?? 0,
44
+ position: data.position ?? (await findGradeCategoriesByClassId(classId)).length,
45
+ },
46
+ });
47
+ }
48
+
49
+ export async function updateGradeCategory(
50
+ categoryId: string,
51
+ classId: string,
52
+ data: {
53
+ name?: string;
54
+ weight?: number;
55
+ dropLowest?: number;
56
+ position?: number;
57
+ }
58
+ ) {
59
+ const category = await findGradeCategory(categoryId, classId);
60
+ if (!category) {
61
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
62
+ }
63
+ return prisma.gradeCategory.update({
64
+ where: { id: categoryId },
65
+ data,
66
+ });
67
+ }
68
+
69
+ export async function deleteGradeCategory(categoryId: string, classId: string) {
70
+ const category = await findGradeCategory(categoryId, classId);
71
+ if (!category) {
72
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
73
+ }
74
+ return prisma.gradeCategory.delete({ where: { id: categoryId } });
75
+ }
76
+
77
+ export async function assignAssignmentCategory(
78
+ classId: string,
79
+ assignmentId: string,
80
+ gradeCategoryId: string | null
81
+ ) {
82
+ if (gradeCategoryId) {
83
+ const category = await findGradeCategory(gradeCategoryId, classId);
84
+ if (!category) {
85
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
86
+ }
87
+ }
88
+ return prisma.assignment.update({
89
+ where: { id: assignmentId, classId },
90
+ data: { gradeCategoryId },
91
+ });
92
+ }
93
+
94
+ // ===== Custom columns =====
95
+
96
+ export async function listCustomGradeColumns(classId: string) {
97
+ return findCustomGradeColumnsByClassId(classId);
98
+ }
99
+
100
+ export async function createCustomGradeColumn(
101
+ classId: string,
102
+ data: {
103
+ name: string;
104
+ maxPoints: number;
105
+ gradeCategoryId?: string | null;
106
+ position?: number;
107
+ }
108
+ ) {
109
+ if (data.gradeCategoryId) {
110
+ const category = await findGradeCategory(data.gradeCategoryId, classId);
111
+ if (!category) {
112
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
113
+ }
114
+ }
115
+ return prisma.customGradeColumn.create({
116
+ data: {
117
+ classId,
118
+ name: data.name,
119
+ maxPoints: data.maxPoints,
120
+ gradeCategoryId: data.gradeCategoryId ?? null,
121
+ position:
122
+ data.position ?? (await findCustomGradeColumnsByClassId(classId)).length,
123
+ },
124
+ });
125
+ }
126
+
127
+ export async function updateCustomGradeColumn(
128
+ columnId: string,
129
+ classId: string,
130
+ data: {
131
+ name?: string;
132
+ maxPoints?: number;
133
+ gradeCategoryId?: string | null;
134
+ position?: number;
135
+ }
136
+ ) {
137
+ const column = await findCustomGradeColumn(columnId, classId);
138
+ if (!column) {
139
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
140
+ }
141
+ if (data.gradeCategoryId) {
142
+ const category = await findGradeCategory(data.gradeCategoryId, classId);
143
+ if (!category) {
144
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
145
+ }
146
+ }
147
+ return prisma.customGradeColumn.update({
148
+ where: { id: columnId },
149
+ data,
150
+ });
151
+ }
152
+
153
+ export async function deleteCustomGradeColumn(
154
+ columnId: string,
155
+ classId: string
156
+ ) {
157
+ const column = await findCustomGradeColumn(columnId, classId);
158
+ if (!column) {
159
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
160
+ }
161
+ return prisma.customGradeColumn.delete({ where: { id: columnId } });
162
+ }
163
+
164
+ export async function setCustomGradeEntry(
165
+ classId: string,
166
+ columnId: string,
167
+ studentId: string,
168
+ points: number | null,
169
+ note?: string | null
170
+ ) {
171
+ const column = await findCustomGradeColumn(columnId, classId);
172
+ if (!column) {
173
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
174
+ }
175
+ if (points === null) {
176
+ await prisma.customGradeEntry.deleteMany({
177
+ where: { columnId, studentId },
178
+ });
179
+ return null;
180
+ }
181
+ return prisma.customGradeEntry.upsert({
182
+ where: { columnId_studentId: { columnId, studentId } },
183
+ create: { columnId, studentId, points, note: note ?? null },
184
+ update: { points, ...(note !== undefined ? { note } : {}) },
185
+ });
186
+ }
187
+
188
+ // ===== Policies =====
189
+
190
+ export async function getGradePolicies(classId: string) {
191
+ const policies = await findClassGradePolicies(classId);
192
+ if (!policies) {
193
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
194
+ }
195
+ return policies;
196
+ }
197
+
198
+ export async function updateGradePolicies(
199
+ classId: string,
200
+ data: {
201
+ treatMissingAsZero?: boolean;
202
+ gradeRoundingMode?: PrismaGradeRoundingMode;
203
+ }
204
+ ) {
205
+ return prisma.class.update({
206
+ where: { id: classId },
207
+ data,
208
+ select: {
209
+ id: true,
210
+ treatMissingAsZero: true,
211
+ gradeRoundingMode: true,
212
+ },
213
+ });
214
+ }
215
+
216
+ // ===== Computed gradebook =====
217
+
218
+ export interface GradebookStudentRow {
219
+ studentId: string;
220
+ studentName: string;
221
+ assignmentScores: Record<string, number | null>;
222
+ customScores: Record<string, { points: number | null; note: string | null }>;
223
+ result: FinalGradeResult;
224
+ letterGrade: string | null;
225
+ }
226
+
227
+ export async function getComputedGradebook(classId: string, userId: string) {
228
+ const [classData, categories, columns, isTeacher] = await Promise.all([
229
+ findClassForGradebook(classId),
230
+ findGradeCategoriesByClassId(classId),
231
+ findCustomGradeColumnsByClassId(classId),
232
+ isTeacherInClass(classId, userId),
233
+ ]);
234
+ if (!classData) {
235
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
236
+ }
237
+
238
+ // Students only ever see their own row.
239
+ const students = isTeacher
240
+ ? classData.students
241
+ : classData.students.filter((student) => student.id === userId);
242
+
243
+ const policies = {
244
+ treatMissingAsZero: classData.treatMissingAsZero,
245
+ roundingMode: classData.gradeRoundingMode,
246
+ };
247
+ const categoryInputs: GradeCategoryInput[] = categories.map((category) => ({
248
+ id: category.id,
249
+ name: category.name,
250
+ weight: category.weight,
251
+ dropLowest: category.dropLowest,
252
+ }));
253
+ const boundary = classData.gradingBoundaries[0] ?? null;
254
+
255
+ const rows: GradebookStudentRow[] = students.map((student) => {
256
+ const assignmentScores: Record<string, number | null> = {};
257
+ const scores: ScoreInput[] = [];
258
+
259
+ for (const assignment of classData.assignments) {
260
+ const submission = assignment.submissions.find(
261
+ (item) => item.studentId === student.id
262
+ );
263
+ const pointsEarned = submission?.gradeReceived ?? null;
264
+ assignmentScores[assignment.id] = pointsEarned;
265
+ scores.push({
266
+ categoryId: assignment.gradeCategoryId,
267
+ pointsEarned,
268
+ maxPoints: assignment.maxGrade ?? 0,
269
+ weight: assignment.weight,
270
+ });
271
+ }
272
+
273
+ const customScores: GradebookStudentRow["customScores"] = {};
274
+ for (const column of columns) {
275
+ const entry = column.entries.find(
276
+ (item) => item.studentId === student.id
277
+ );
278
+ customScores[column.id] = {
279
+ points: entry?.points ?? null,
280
+ note: entry?.note ?? null,
281
+ };
282
+ scores.push({
283
+ categoryId: column.gradeCategoryId,
284
+ pointsEarned: entry?.points ?? null,
285
+ maxPoints: column.maxPoints,
286
+ });
287
+ }
288
+
289
+ const result = computeFinalGrade(policies, categoryInputs, scores);
290
+ const letterGrade =
291
+ boundary && result.percentage !== null
292
+ ? resolveLetterGrade(boundary.structured, result.percentage)
293
+ : null;
294
+
295
+ return {
296
+ studentId: student.id,
297
+ studentName: student.profile?.displayName || student.username,
298
+ assignmentScores,
299
+ customScores,
300
+ result,
301
+ letterGrade,
302
+ };
303
+ });
304
+
305
+ return {
306
+ policies,
307
+ categories,
308
+ assignments: classData.assignments.map((assignment) => ({
309
+ id: assignment.id,
310
+ title: assignment.title,
311
+ maxGrade: assignment.maxGrade,
312
+ weight: assignment.weight,
313
+ gradeCategoryId: assignment.gradeCategoryId,
314
+ dueDate: assignment.dueDate,
315
+ })),
316
+ customColumns: columns.map((column) => ({
317
+ id: column.id,
318
+ name: column.name,
319
+ maxPoints: column.maxPoints,
320
+ gradeCategoryId: column.gradeCategoryId,
321
+ position: column.position,
322
+ })),
323
+ gradingBoundary: boundary,
324
+ students: rows,
325
+ };
326
+ }
327
+
328
+ // ===== CSV export =====
329
+
330
+ function escapeCsvCell(value: string): string {
331
+ if (/[",\n\r]/.test(value)) {
332
+ return `"${value.replace(/"/g, '""')}"`;
333
+ }
334
+ return value;
335
+ }
336
+
337
+ export async function exportGradebookCsv(classId: string, userId: string) {
338
+ const gradebook = await getComputedGradebook(classId, userId);
339
+
340
+ const header = [
341
+ "Student",
342
+ ...gradebook.assignments.map(
343
+ (assignment) => `${assignment.title} (/${assignment.maxGrade ?? 0})`
344
+ ),
345
+ ...gradebook.customColumns.map(
346
+ (column) => `${column.name} (/${column.maxPoints})`
347
+ ),
348
+ ...gradebook.categories.map((category) => `${category.name} Avg (%)`),
349
+ "Final (%)",
350
+ "Letter",
351
+ ];
352
+
353
+ const lines = [header.map(escapeCsvCell).join(",")];
354
+
355
+ for (const row of gradebook.students) {
356
+ const categoryCells = gradebook.categories.map((category) => {
357
+ const average = row.result.categories.find(
358
+ (item) => item.categoryId === category.id
359
+ )?.average;
360
+ return average === null || average === undefined
361
+ ? ""
362
+ : average.toFixed(2);
363
+ });
364
+
365
+ const cells = [
366
+ row.studentName,
367
+ ...gradebook.assignments.map((assignment) => {
368
+ const points = row.assignmentScores[assignment.id];
369
+ return points === null || points === undefined ? "" : String(points);
370
+ }),
371
+ ...gradebook.customColumns.map((column) => {
372
+ const points = row.customScores[column.id]?.points;
373
+ return points === null || points === undefined ? "" : String(points);
374
+ }),
375
+ ...categoryCells,
376
+ row.result.percentage === null ? "" : row.result.percentage.toFixed(2),
377
+ row.letterGrade ?? "",
378
+ ];
379
+ lines.push(cells.map(escapeCsvCell).join(","));
380
+ }
381
+
382
+ return { csv: lines.join("\n") };
383
+ }
@@ -23,17 +23,24 @@ import {
23
23
  countActiveAdmins,
24
24
  createSchoolInvite,
25
25
  createSchoolWithAdmin,
26
+ deleteSchoolClassRecord,
26
27
  findActiveSchoolAdminMembership,
27
28
  findPendingInviteByToken,
29
+ findSchoolClass,
28
30
  findSchoolMembership,
29
31
  getSchoolById,
32
+ getSchoolMemberDetail,
30
33
  getSchoolOverview,
34
+ listActiveSchoolMemberships,
35
+ listMemberClasses,
36
+ listRecentSchoolMembers,
31
37
  listSchoolClasses,
32
38
  listSchoolInvites,
33
39
  listSchoolMembers,
34
40
  listUserSchoolMemberships,
35
41
  removeSchoolMembershipPreservingContent,
36
42
  revokeSchoolInvite,
43
+ updateSchoolClassMembers,
37
44
  updateSchoolMembershipRole,
38
45
  updateSchoolRecord,
39
46
  upsertSchoolMembership,
@@ -133,7 +140,10 @@ export async function updateSchoolDetails(
133
140
 
134
141
  export async function getAdminOverview(userId: string, schoolId: string) {
135
142
  await requireSchoolAdmin(schoolId, userId);
136
- const school = await getSchoolOverview(schoolId);
143
+ const [school, recentMembers] = await Promise.all([
144
+ getSchoolOverview(schoolId),
145
+ listRecentSchoolMembers(schoolId),
146
+ ]);
137
147
  if (!school) {
138
148
  throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
139
149
  }
@@ -159,6 +169,7 @@ export async function getAdminOverview(userId: string, schoolId: string) {
159
169
  },
160
170
  recentClasses: school.classes,
161
171
  recentInvites: school.invites,
172
+ recentMembers,
162
173
  },
163
174
  };
164
175
  }
@@ -265,6 +276,172 @@ export async function updateMemberRole(
265
276
  return { membership };
266
277
  }
267
278
 
279
+ export async function getMemberDetail(
280
+ userId: string,
281
+ input: { schoolId: string; memberUserId: string }
282
+ ) {
283
+ await requireSchoolAdmin(input.schoolId, userId);
284
+ const membership = await getSchoolMemberDetail(
285
+ input.schoolId,
286
+ input.memberUserId
287
+ );
288
+ if (!membership) {
289
+ throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
290
+ }
291
+
292
+ const [teachingClasses, enrolledClasses] = await listMemberClasses(
293
+ input.schoolId,
294
+ input.memberUserId
295
+ );
296
+
297
+ const toClassSummary = (cls: (typeof teachingClasses)[number]) => ({
298
+ id: cls.id,
299
+ name: cls.name,
300
+ section: cls.section,
301
+ subject: cls.subject,
302
+ color: cls.color,
303
+ teacherCount: cls._count.teachers,
304
+ studentCount: cls._count.students,
305
+ });
306
+
307
+ return {
308
+ membership,
309
+ teachingClasses: teachingClasses.map(toClassSummary),
310
+ enrolledClasses: enrolledClasses.map(toClassSummary),
311
+ };
312
+ }
313
+
314
+ export async function bulkUpdateMemberRoles(
315
+ userId: string,
316
+ input: {
317
+ schoolId: string;
318
+ memberUserIds: string[];
319
+ role: SchoolMembershipRole;
320
+ }
321
+ ) {
322
+ await requireSchoolAdmin(input.schoolId, userId);
323
+ const memberships = await listActiveSchoolMemberships(
324
+ input.schoolId,
325
+ input.memberUserIds
326
+ );
327
+ if (memberships.length === 0) {
328
+ throw new TRPCError({ code: "NOT_FOUND", message: "No members found" });
329
+ }
330
+
331
+ if (input.role !== "ADMIN") {
332
+ const demotedAdmins = memberships.filter((m) => m.role === "ADMIN").length;
333
+ if (demotedAdmins > 0) {
334
+ const adminCount = await countActiveAdmins(input.schoolId);
335
+ if (adminCount - demotedAdmins < 1) {
336
+ throw new TRPCError({
337
+ code: "BAD_REQUEST",
338
+ message: "A school must have at least one active admin",
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ await prisma.schoolMembership.updateMany({
345
+ where: {
346
+ schoolId: input.schoolId,
347
+ userId: { in: memberships.map((m) => m.userId) },
348
+ status: "ACTIVE",
349
+ },
350
+ data: { role: input.role },
351
+ });
352
+ return { updatedCount: memberships.length };
353
+ }
354
+
355
+ export async function bulkRemoveMembers(
356
+ userId: string,
357
+ input: { schoolId: string; memberUserIds: string[] }
358
+ ) {
359
+ await requireSchoolAdmin(input.schoolId, userId);
360
+ const targetIds = input.memberUserIds.filter((id) => id !== userId);
361
+ const memberships = await listActiveSchoolMemberships(
362
+ input.schoolId,
363
+ targetIds
364
+ );
365
+ if (memberships.length === 0) {
366
+ throw new TRPCError({ code: "NOT_FOUND", message: "No members found" });
367
+ }
368
+
369
+ const removedAdmins = memberships.filter((m) => m.role === "ADMIN").length;
370
+ if (removedAdmins > 0) {
371
+ const adminCount = await countActiveAdmins(input.schoolId);
372
+ if (adminCount - removedAdmins < 1) {
373
+ throw new TRPCError({
374
+ code: "BAD_REQUEST",
375
+ message: "A school must have at least one active admin",
376
+ });
377
+ }
378
+ }
379
+
380
+ for (const membership of memberships) {
381
+ await removeSchoolMembershipPreservingContent(
382
+ input.schoolId,
383
+ membership.userId
384
+ );
385
+ }
386
+ return { removedCount: memberships.length };
387
+ }
388
+
389
+ export async function deleteSchoolClass(
390
+ userId: string,
391
+ input: { schoolId: string; classId: string }
392
+ ) {
393
+ await requireSchoolAdmin(input.schoolId, userId);
394
+ const cls = await findSchoolClass(input.schoolId, input.classId);
395
+ if (!cls) {
396
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
397
+ }
398
+ await deleteSchoolClassRecord(input.schoolId, input.classId);
399
+ return { deletedClass: { id: input.classId } };
400
+ }
401
+
402
+ export async function updateClassMembers(
403
+ userId: string,
404
+ input: {
405
+ schoolId: string;
406
+ classId: string;
407
+ addTeacherIds?: string[];
408
+ removeTeacherIds?: string[];
409
+ addStudentIds?: string[];
410
+ removeStudentIds?: string[];
411
+ }
412
+ ) {
413
+ await requireSchoolAdmin(input.schoolId, userId);
414
+ const cls = await findSchoolClass(input.schoolId, input.classId);
415
+ if (!cls) {
416
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
417
+ }
418
+
419
+ const addTeacherIds = input.addTeacherIds ?? [];
420
+ const addStudentIds = input.addStudentIds ?? [];
421
+ const addedIds = [...addTeacherIds, ...addStudentIds];
422
+ if (addedIds.length > 0) {
423
+ const memberships = await listActiveSchoolMemberships(
424
+ input.schoolId,
425
+ addedIds
426
+ );
427
+ const activeIds = new Set(memberships.map((m) => m.userId));
428
+ if (addedIds.some((id) => !activeIds.has(id))) {
429
+ throw new TRPCError({
430
+ code: "BAD_REQUEST",
431
+ message: "All added users must be active members of this school",
432
+ });
433
+ }
434
+ }
435
+
436
+ const updated = await updateSchoolClassMembers(input.classId, {
437
+ addTeacherIds,
438
+ removeTeacherIds: input.removeTeacherIds ?? [],
439
+ addStudentIds,
440
+ removeStudentIds: input.removeStudentIds ?? [],
441
+ });
442
+ return { class: updated };
443
+ }
444
+
268
445
  export async function removeMember(
269
446
  userId: string,
270
447
  input: { schoolId: string; memberUserId: string }