@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,93 @@
|
|
|
1
|
+
import type { Prisma } from '@prisma/client';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { prisma } from '../lib/prisma.js';
|
|
4
|
+
|
|
5
|
+
export async function searchUsersForAdmin(input: {
|
|
6
|
+
page: number;
|
|
7
|
+
pageSize: number;
|
|
8
|
+
query?: string;
|
|
9
|
+
}) {
|
|
10
|
+
const skip = input.page * input.pageSize;
|
|
11
|
+
const where: Prisma.UserWhereInput = {};
|
|
12
|
+
const q = input.query?.trim();
|
|
13
|
+
if (q) {
|
|
14
|
+
where.OR = [
|
|
15
|
+
{ username: { contains: q, mode: 'insensitive' } },
|
|
16
|
+
{ email: { contains: q, mode: 'insensitive' } },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const [items, total] = await Promise.all([
|
|
21
|
+
prisma.user.findMany({
|
|
22
|
+
where,
|
|
23
|
+
orderBy: { username: 'asc' },
|
|
24
|
+
skip,
|
|
25
|
+
take: input.pageSize,
|
|
26
|
+
select: {
|
|
27
|
+
id: true,
|
|
28
|
+
username: true,
|
|
29
|
+
email: true,
|
|
30
|
+
verified: true,
|
|
31
|
+
school: { select: { id: true, name: true } },
|
|
32
|
+
_count: {
|
|
33
|
+
select: { teacherIn: true, studentIn: true, sessions: true },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
prisma.user.count({ where }),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
items,
|
|
42
|
+
total,
|
|
43
|
+
page: input.page,
|
|
44
|
+
pageSize: input.pageSize,
|
|
45
|
+
pageCount: Math.max(1, Math.ceil(total / input.pageSize)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getUserForAdmin(id: string) {
|
|
50
|
+
const user = await prisma.user.findUnique({
|
|
51
|
+
where: { id },
|
|
52
|
+
select: {
|
|
53
|
+
id: true,
|
|
54
|
+
username: true,
|
|
55
|
+
email: true,
|
|
56
|
+
verified: true,
|
|
57
|
+
termsAcceptedAt: true,
|
|
58
|
+
termsVersion: true,
|
|
59
|
+
profile: {
|
|
60
|
+
select: { displayName: true, location: true, createdAt: true },
|
|
61
|
+
},
|
|
62
|
+
school: { select: { id: true, name: true, subdomain: true } },
|
|
63
|
+
schoolMemberships: {
|
|
64
|
+
select: {
|
|
65
|
+
id: true,
|
|
66
|
+
role: true,
|
|
67
|
+
status: true,
|
|
68
|
+
createdAt: true,
|
|
69
|
+
school: { select: { id: true, name: true } },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
sessions: {
|
|
73
|
+
orderBy: { createdAt: 'desc' },
|
|
74
|
+
take: 10,
|
|
75
|
+
select: { id: true, type: true, createdAt: true, expiresAt: true },
|
|
76
|
+
},
|
|
77
|
+
_count: {
|
|
78
|
+
select: {
|
|
79
|
+
teacherIn: true,
|
|
80
|
+
studentIn: true,
|
|
81
|
+
sessions: true,
|
|
82
|
+
submissions: true,
|
|
83
|
+
files: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!user) {
|
|
90
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
91
|
+
}
|
|
92
|
+
return user;
|
|
93
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade calculation – pure functions for computing final class grades from
|
|
3
|
+
* categories, weights, and grade-calculation policies. No I/O; unit-testable.
|
|
4
|
+
*
|
|
5
|
+
* Future policies (standards-based grading, etc.) should extend
|
|
6
|
+
* `GradeCalculationPolicies` and be handled inside `computeFinalGrade` so
|
|
7
|
+
* callers never need to change.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type GradeRoundingMode = "NONE" | "HALF_UP" | "FLOOR" | "CEILING";
|
|
11
|
+
|
|
12
|
+
export interface GradeCalculationPolicies {
|
|
13
|
+
/** Count ungraded/missing scores as 0% instead of excluding them. */
|
|
14
|
+
treatMissingAsZero: boolean;
|
|
15
|
+
/** How the final percentage is rounded to a whole number. */
|
|
16
|
+
roundingMode: GradeRoundingMode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GradeCategoryInput {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
/** Relative weight of this category toward the final grade. */
|
|
23
|
+
weight: number;
|
|
24
|
+
/** Number of lowest-percentage scores dropped within this category. */
|
|
25
|
+
dropLowest: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScoreInput {
|
|
29
|
+
/** Category the score belongs to; null falls into the default category. */
|
|
30
|
+
categoryId: string | null;
|
|
31
|
+
/** Points earned; null means missing/ungraded. */
|
|
32
|
+
pointsEarned: number | null;
|
|
33
|
+
/** Maximum points for the item; items with maxPoints <= 0 are ignored. */
|
|
34
|
+
maxPoints: number;
|
|
35
|
+
/** Relative weight of the item within its category (defaults to 1). */
|
|
36
|
+
weight?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CategoryAverage {
|
|
40
|
+
categoryId: string | null;
|
|
41
|
+
name: string;
|
|
42
|
+
weight: number;
|
|
43
|
+
/** Percentage 0–100, or null when no scores counted. */
|
|
44
|
+
average: number | null;
|
|
45
|
+
countedScores: number;
|
|
46
|
+
droppedScores: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FinalGradeResult {
|
|
50
|
+
/** Final percentage 0–100 (rounded per policy), or null when no data. */
|
|
51
|
+
percentage: number | null;
|
|
52
|
+
categories: CategoryAverage[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Synthetic category used for scores without a category. */
|
|
56
|
+
export const DEFAULT_CATEGORY = {
|
|
57
|
+
id: null,
|
|
58
|
+
weight: 1,
|
|
59
|
+
dropLowest: 0,
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
export function roundPercentage(
|
|
63
|
+
value: number,
|
|
64
|
+
mode: GradeRoundingMode
|
|
65
|
+
): number {
|
|
66
|
+
switch (mode) {
|
|
67
|
+
case "HALF_UP":
|
|
68
|
+
return Math.round(value);
|
|
69
|
+
case "FLOOR":
|
|
70
|
+
return Math.floor(value);
|
|
71
|
+
case "CEILING":
|
|
72
|
+
return Math.ceil(value);
|
|
73
|
+
case "NONE":
|
|
74
|
+
default:
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface CountableScore {
|
|
80
|
+
percentage: number;
|
|
81
|
+
weight: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectCountableScores(
|
|
85
|
+
scores: ScoreInput[],
|
|
86
|
+
policies: GradeCalculationPolicies
|
|
87
|
+
): CountableScore[] {
|
|
88
|
+
const countable: CountableScore[] = [];
|
|
89
|
+
for (const score of scores) {
|
|
90
|
+
if (score.maxPoints <= 0) continue;
|
|
91
|
+
if (score.pointsEarned === null) {
|
|
92
|
+
if (!policies.treatMissingAsZero) continue;
|
|
93
|
+
countable.push({ percentage: 0, weight: score.weight ?? 1 });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
countable.push({
|
|
97
|
+
percentage: (score.pointsEarned / score.maxPoints) * 100,
|
|
98
|
+
weight: score.weight ?? 1,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return countable;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function averageCategory(
|
|
105
|
+
countable: CountableScore[],
|
|
106
|
+
dropLowest: number
|
|
107
|
+
): { average: number | null; counted: number; dropped: number } {
|
|
108
|
+
// Always keep at least one score so a category never collapses to null
|
|
109
|
+
// just because dropLowest >= score count.
|
|
110
|
+
const dropCount = Math.min(Math.max(dropLowest, 0), countable.length - 1);
|
|
111
|
+
const kept =
|
|
112
|
+
dropCount > 0
|
|
113
|
+
? [...countable]
|
|
114
|
+
.sort((a, b) => a.percentage - b.percentage)
|
|
115
|
+
.slice(dropCount)
|
|
116
|
+
: countable;
|
|
117
|
+
|
|
118
|
+
const totalWeight = kept.reduce((sum, s) => sum + s.weight, 0);
|
|
119
|
+
if (kept.length === 0 || totalWeight <= 0) {
|
|
120
|
+
return { average: null, counted: 0, dropped: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const weighted = kept.reduce((sum, s) => sum + s.percentage * s.weight, 0);
|
|
124
|
+
return {
|
|
125
|
+
average: weighted / totalWeight,
|
|
126
|
+
counted: kept.length,
|
|
127
|
+
dropped: dropCount,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Computes the final class grade as the weighted average of category
|
|
133
|
+
* averages. Scores with no category fall into a default category with
|
|
134
|
+
* weight `DEFAULT_CATEGORY.weight`. Categories without counted scores are
|
|
135
|
+
* excluded from the weighted average.
|
|
136
|
+
*/
|
|
137
|
+
export function computeFinalGrade(
|
|
138
|
+
policies: GradeCalculationPolicies,
|
|
139
|
+
categories: GradeCategoryInput[],
|
|
140
|
+
scores: ScoreInput[],
|
|
141
|
+
options?: { defaultCategoryName?: string }
|
|
142
|
+
): FinalGradeResult {
|
|
143
|
+
const knownIds = new Set(categories.map((c) => c.id));
|
|
144
|
+
const byCategory = new Map<string | null, ScoreInput[]>();
|
|
145
|
+
for (const score of scores) {
|
|
146
|
+
const key =
|
|
147
|
+
score.categoryId !== null && knownIds.has(score.categoryId)
|
|
148
|
+
? score.categoryId
|
|
149
|
+
: null;
|
|
150
|
+
const bucket = byCategory.get(key);
|
|
151
|
+
if (bucket) {
|
|
152
|
+
bucket.push(score);
|
|
153
|
+
} else {
|
|
154
|
+
byCategory.set(key, [score]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const categoryAverages: CategoryAverage[] = [];
|
|
159
|
+
|
|
160
|
+
for (const category of categories) {
|
|
161
|
+
const countable = collectCountableScores(
|
|
162
|
+
byCategory.get(category.id) ?? [],
|
|
163
|
+
policies
|
|
164
|
+
);
|
|
165
|
+
const { average, counted, dropped } = averageCategory(
|
|
166
|
+
countable,
|
|
167
|
+
category.dropLowest
|
|
168
|
+
);
|
|
169
|
+
categoryAverages.push({
|
|
170
|
+
categoryId: category.id,
|
|
171
|
+
name: category.name,
|
|
172
|
+
weight: category.weight,
|
|
173
|
+
average,
|
|
174
|
+
countedScores: counted,
|
|
175
|
+
droppedScores: dropped,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const uncategorized = byCategory.get(null) ?? [];
|
|
180
|
+
if (uncategorized.length > 0) {
|
|
181
|
+
const countable = collectCountableScores(uncategorized, policies);
|
|
182
|
+
const { average, counted, dropped } = averageCategory(
|
|
183
|
+
countable,
|
|
184
|
+
DEFAULT_CATEGORY.dropLowest
|
|
185
|
+
);
|
|
186
|
+
categoryAverages.push({
|
|
187
|
+
categoryId: DEFAULT_CATEGORY.id,
|
|
188
|
+
name: options?.defaultCategoryName ?? "Uncategorized",
|
|
189
|
+
weight: DEFAULT_CATEGORY.weight,
|
|
190
|
+
average,
|
|
191
|
+
countedScores: counted,
|
|
192
|
+
droppedScores: dropped,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const contributing = categoryAverages.filter(
|
|
197
|
+
(c) => c.average !== null && c.weight > 0
|
|
198
|
+
);
|
|
199
|
+
const totalWeight = contributing.reduce((sum, c) => sum + c.weight, 0);
|
|
200
|
+
|
|
201
|
+
const percentage =
|
|
202
|
+
totalWeight > 0
|
|
203
|
+
? roundPercentage(
|
|
204
|
+
contributing.reduce(
|
|
205
|
+
(sum, c) => sum + (c.average as number) * c.weight,
|
|
206
|
+
0
|
|
207
|
+
) / totalWeight,
|
|
208
|
+
policies.roundingMode
|
|
209
|
+
)
|
|
210
|
+
: null;
|
|
211
|
+
|
|
212
|
+
return { percentage, categories: categoryAverages };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface ParsedBoundary {
|
|
216
|
+
grade: string;
|
|
217
|
+
minPercentage: number;
|
|
218
|
+
maxPercentage: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Maps a percentage to a letter grade using a GradingBoundary `structured`
|
|
223
|
+
* JSON payload (`{ boundaries: [{ grade, minPercentage, maxPercentage }] }`).
|
|
224
|
+
* Returns null when the payload is unparseable or nothing matches.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveLetterGrade(
|
|
227
|
+
structured: string,
|
|
228
|
+
percentage: number
|
|
229
|
+
): string | null {
|
|
230
|
+
let boundaries: ParsedBoundary[];
|
|
231
|
+
try {
|
|
232
|
+
const parsed = JSON.parse(structured) as {
|
|
233
|
+
boundaries?: ParsedBoundary[];
|
|
234
|
+
};
|
|
235
|
+
if (!Array.isArray(parsed.boundaries)) return null;
|
|
236
|
+
boundaries = parsed.boundaries;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sorted = [...boundaries].sort(
|
|
242
|
+
(a, b) => b.minPercentage - a.minPercentage
|
|
243
|
+
);
|
|
244
|
+
for (const boundary of sorted) {
|
|
245
|
+
if (
|
|
246
|
+
typeof boundary.minPercentage === "number" &&
|
|
247
|
+
percentage >= boundary.minPercentage
|
|
248
|
+
) {
|
|
249
|
+
return boundary.grade ?? null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|