@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.
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +27 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +4 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +14 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/event.d.ts +11 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +19 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +4 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/gradebook.d.ts +180 -0
- package/dist/models/gradebook.d.ts.map +1 -0
- package/dist/models/gradebook.js +88 -0
- package/dist/models/gradebook.js.map +1 -0
- package/dist/models/labChat.d.ts +4 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +2 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +177 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +75 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +6 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/routers/_app.d.ts +1475 -73
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +164 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +23 -4
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +30 -3
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts +17 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/event.d.ts +4 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +66 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/file.js +111 -2
- package/dist/routers/file.js.map +1 -1
- package/dist/routers/gradebook.d.ts +299 -0
- package/dist/routers/gradebook.d.ts.map +1 -0
- package/dist/routers/gradebook.js +107 -0
- package/dist/routers/gradebook.js.map +1 -0
- package/dist/routers/school.d.ts +118 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +32 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/section.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +3 -1
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/services/adminInstitution.d.ts +51 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +52 -2
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +17 -0
- package/dist/services/adminMetrics.d.ts.map +1 -0
- package/dist/services/adminMetrics.js +37 -0
- package/dist/services/adminMetrics.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +9 -0
- package/dist/services/adminRunJob.d.ts.map +1 -1
- package/dist/services/adminRunJob.js +18 -2
- package/dist/services/adminRunJob.js.map +1 -1
- package/dist/services/adminUser.d.ts +67 -0
- package/dist/services/adminUser.d.ts.map +1 -0
- package/dist/services/adminUser.js +87 -0
- package/dist/services/adminUser.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +27 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/class.d.ts +17 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/event.d.ts +4 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/gradeCalculation.d.ts +70 -0
- package/dist/services/gradeCalculation.d.ts.map +1 -0
- package/dist/services/gradeCalculation.js +148 -0
- package/dist/services/gradeCalculation.js.map +1 -0
- package/dist/services/gradebook.d.ts +197 -0
- package/dist/services/gradebook.d.ts.map +1 -0
- package/dist/services/gradebook.js +263 -0
- package/dist/services/gradebook.js.map +1 -0
- package/dist/services/school.d.ts +98 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +116 -4
- package/dist/services/school.js.map +1 -1
- package/dist/services/worksheet.d.ts +2 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260708011629_add_gradebook_categories_columns_policies/migration.sql +78 -0
- package/prisma/migrations/20260723142314_make_file_annotation_submission_optional/migration.sql +2 -0
- package/prisma/schema.prisma +62 -2
- package/src/models/gradebook.ts +91 -0
- package/src/models/school.ts +89 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +28 -0
- package/src/routers/file.ts +128 -0
- package/src/routers/gradebook.ts +173 -0
- package/src/routers/school.ts +45 -0
- package/src/services/adminInstitution.ts +52 -0
- package/src/services/adminMetrics.ts +46 -0
- package/src/services/adminRunJob.ts +24 -0
- package/src/services/adminUser.ts +93 -0
- package/src/services/gradeCalculation.ts +253 -0
- package/src/services/gradebook.ts +383 -0
- package/src/services/school.ts +178 -1
- 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
|
+
}
|
package/src/services/school.ts
CHANGED
|
@@ -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
|
|
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 }
|