@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,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
+ }