@studious-lms/server 1.27.0 → 1.28.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 (106) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +31 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/instrument.js +9 -4
  5. package/dist/instrument.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts +15 -0
  7. package/dist/lib/fileUpload.d.ts.map +1 -1
  8. package/dist/lib/fileUpload.js +43 -2
  9. package/dist/lib/fileUpload.js.map +1 -1
  10. package/dist/pipelines/aiLabChatContract.d.ts +2 -2
  11. package/dist/pipelines/generateVisualization.d.ts +33 -0
  12. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  13. package/dist/pipelines/generateVisualization.js +79 -0
  14. package/dist/pipelines/generateVisualization.js.map +1 -0
  15. package/dist/routers/_app.d.ts +891 -53
  16. package/dist/routers/_app.d.ts.map +1 -1
  17. package/dist/routers/_app.js +4 -2
  18. package/dist/routers/_app.js.map +1 -1
  19. package/dist/routers/admin.d.ts +297 -0
  20. package/dist/routers/admin.d.ts.map +1 -1
  21. package/dist/routers/admin.js +43 -2
  22. package/dist/routers/admin.js.map +1 -1
  23. package/dist/routers/class.d.ts.map +1 -1
  24. package/dist/routers/class.js +16 -3
  25. package/dist/routers/class.js.map +1 -1
  26. package/dist/routers/module.d.ts +1 -1
  27. package/dist/routers/visualization.d.ts +124 -0
  28. package/dist/routers/visualization.d.ts.map +1 -0
  29. package/dist/routers/visualization.js +41 -0
  30. package/dist/routers/visualization.js.map +1 -0
  31. package/dist/services/adminClass.d.ts +153 -0
  32. package/dist/services/adminClass.d.ts.map +1 -0
  33. package/dist/services/adminClass.js +196 -0
  34. package/dist/services/adminClass.js.map +1 -0
  35. package/dist/services/adminInstitution.d.ts +2 -0
  36. package/dist/services/adminInstitution.d.ts.map +1 -1
  37. package/dist/services/adminInstitution.js +11 -4
  38. package/dist/services/adminInstitution.js.map +1 -1
  39. package/dist/services/adminMetrics.d.ts +8 -0
  40. package/dist/services/adminMetrics.d.ts.map +1 -1
  41. package/dist/services/adminMetrics.js +30 -3
  42. package/dist/services/adminMetrics.js.map +1 -1
  43. package/dist/services/adminSpam.d.ts +76 -0
  44. package/dist/services/adminSpam.d.ts.map +1 -0
  45. package/dist/services/adminSpam.js +163 -0
  46. package/dist/services/adminSpam.js.map +1 -0
  47. package/dist/services/adminSql.d.ts +36 -0
  48. package/dist/services/adminSql.d.ts.map +1 -0
  49. package/dist/services/adminSql.js +172 -0
  50. package/dist/services/adminSql.js.map +1 -0
  51. package/dist/services/adminTelemetry.d.ts +56 -0
  52. package/dist/services/adminTelemetry.d.ts.map +1 -0
  53. package/dist/services/adminTelemetry.js +147 -0
  54. package/dist/services/adminTelemetry.js.map +1 -0
  55. package/dist/services/adminUser.d.ts +45 -0
  56. package/dist/services/adminUser.d.ts.map +1 -1
  57. package/dist/services/adminUser.js +48 -11
  58. package/dist/services/adminUser.js.map +1 -1
  59. package/dist/services/auth.d.ts.map +1 -1
  60. package/dist/services/auth.js +10 -4
  61. package/dist/services/auth.js.map +1 -1
  62. package/dist/services/fees.d.ts.map +1 -1
  63. package/dist/services/fees.js +7 -3
  64. package/dist/services/fees.js.map +1 -1
  65. package/dist/services/module.d.ts.map +1 -1
  66. package/dist/services/module.js +23 -4
  67. package/dist/services/module.js.map +1 -1
  68. package/dist/services/visualization.d.ts +72 -0
  69. package/dist/services/visualization.d.ts.map +1 -0
  70. package/dist/services/visualization.js +122 -0
  71. package/dist/services/visualization.js.map +1 -0
  72. package/dist/utils/sentryTrpc.d.ts +3 -0
  73. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  74. package/dist/utils/sentryTrpc.js +26 -0
  75. package/dist/utils/sentryTrpc.js.map +1 -0
  76. package/package.json +1 -1
  77. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  78. package/prisma/schema.prisma +24 -0
  79. package/src/index.ts +30 -4
  80. package/src/instrument.ts +8 -2
  81. package/src/lib/fileUpload.ts +49 -0
  82. package/src/pipelines/generateVisualization.ts +107 -0
  83. package/src/routers/_app.ts +2 -0
  84. package/src/routers/admin.ts +72 -0
  85. package/src/routers/class.ts +14 -1
  86. package/src/routers/visualization.ts +60 -0
  87. package/src/services/adminClass.ts +222 -0
  88. package/src/services/adminInstitution.ts +9 -2
  89. package/src/services/adminMetrics.ts +35 -0
  90. package/src/services/adminSpam.ts +224 -0
  91. package/src/services/adminSql.ts +214 -0
  92. package/src/services/adminTelemetry.ts +194 -0
  93. package/src/services/adminUser.ts +61 -9
  94. package/src/services/auth.ts +8 -2
  95. package/src/services/fees.ts +5 -1
  96. package/src/services/module.ts +29 -2
  97. package/src/services/visualization.ts +147 -0
  98. package/src/utils/sentryTrpc.ts +25 -0
  99. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  100. package/tests/unit/services/adminClass.test.ts +102 -0
  101. package/tests/unit/services/adminSpam.test.ts +108 -0
  102. package/tests/unit/services/adminSql.test.ts +56 -0
  103. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  104. package/tests/unit/services/adminUser.test.ts +2 -0
  105. package/tests/unit/services/visualization.test.ts +130 -0
  106. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -0,0 +1,194 @@
1
+ import { Prisma } from '@prisma/client';
2
+ import { prisma } from '../lib/prisma.js';
3
+
4
+ export type DailyPoint = {
5
+ date: string;
6
+ signups: number;
7
+ logins: number;
8
+ assignments: number;
9
+ submissions: number;
10
+ messages: number;
11
+ };
12
+
13
+ type DayRow = { day: Date; total: bigint };
14
+
15
+ function dayKey(date: Date): string {
16
+ return date.toISOString().slice(0, 10);
17
+ }
18
+
19
+ async function dailyCounts(
20
+ table: Prisma.Sql,
21
+ column: Prisma.Sql,
22
+ since: Date,
23
+ extra: Prisma.Sql = Prisma.empty,
24
+ ): Promise<Map<string, number>> {
25
+ const rows = await prisma.$queryRaw<DayRow[]>`
26
+ SELECT date_trunc('day', ${column}) AS day, count(*) AS total
27
+ FROM ${table}
28
+ WHERE ${column} >= ${since} ${extra}
29
+ GROUP BY 1
30
+ `;
31
+ return new Map(rows.map((row) => [dayKey(row.day), Number(row.total)]));
32
+ }
33
+
34
+ async function dailyDistinctLogins(since: Date): Promise<Map<string, number>> {
35
+ const rows = await prisma.$queryRaw<DayRow[]>`
36
+ SELECT date_trunc('day', "createdAt") AS day,
37
+ count(DISTINCT "userId") AS total
38
+ FROM "Session"
39
+ WHERE "createdAt" >= ${since} AND "type" = 'AUTH' AND "userId" IS NOT NULL
40
+ GROUP BY 1
41
+ `;
42
+ return new Map(rows.map((row) => [dayKey(row.day), Number(row.total)]));
43
+ }
44
+
45
+ /**
46
+ * Per-day platform activity for the last `days` days, zero-filled so the admin
47
+ * chart always has one point per day even when nothing happened.
48
+ */
49
+ export async function getActivitySeriesForAdmin(input: {
50
+ days: number;
51
+ }): Promise<{ points: DailyPoint[] }> {
52
+ const days = Math.min(Math.max(input.days, 1), 90);
53
+ const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000);
54
+ since.setUTCHours(0, 0, 0, 0);
55
+
56
+ const [signups, logins, assignments, submissions, messages] =
57
+ await Promise.all([
58
+ dailyCounts(Prisma.sql`"UserProfile"`, Prisma.sql`"createdAt"`, since),
59
+ dailyDistinctLogins(since),
60
+ dailyCounts(Prisma.sql`"Assignment"`, Prisma.sql`"createdAt"`, since),
61
+ dailyCounts(Prisma.sql`"Submission"`, Prisma.sql`"createdAt"`, since),
62
+ dailyCounts(Prisma.sql`"Message"`, Prisma.sql`"createdAt"`, since),
63
+ ]);
64
+
65
+ const points: DailyPoint[] = [];
66
+ for (let offset = 0; offset < days; offset += 1) {
67
+ const date = new Date(since.getTime() + offset * 24 * 60 * 60 * 1000);
68
+ const key = dayKey(date);
69
+ points.push({
70
+ date: key,
71
+ signups: signups.get(key) ?? 0,
72
+ logins: logins.get(key) ?? 0,
73
+ assignments: assignments.get(key) ?? 0,
74
+ submissions: submissions.get(key) ?? 0,
75
+ messages: messages.get(key) ?? 0,
76
+ });
77
+ }
78
+
79
+ return { points };
80
+ }
81
+
82
+ /** Content inventory plus AI/job health, i.e. "what is actually on the platform". */
83
+ export async function getContentTelemetryForAdmin() {
84
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
85
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
86
+
87
+ const [
88
+ assignments,
89
+ assignmentsLast7d,
90
+ submissions,
91
+ submissionsLast7d,
92
+ gradedSubmissions,
93
+ announcements,
94
+ announcementsLast7d,
95
+ discussions,
96
+ messages,
97
+ messagesLast7d,
98
+ worksheets,
99
+ events,
100
+ modules,
101
+ files,
102
+ fileSize,
103
+ aiCalls7d,
104
+ aiFailures7d,
105
+ aiTokens7d,
106
+ aiByModel7d,
107
+ runJobsByType7d,
108
+ failedJobsLast24h,
109
+ ] = await Promise.all([
110
+ prisma.assignment.count(),
111
+ prisma.assignment.count({ where: { createdAt: { gte: weekAgo } } }),
112
+ prisma.submission.count(),
113
+ prisma.submission.count({ where: { createdAt: { gte: weekAgo } } }),
114
+ prisma.submission.count({ where: { gradeReceived: { not: null } } }),
115
+ prisma.announcement.count(),
116
+ prisma.announcement.count({ where: { createdAt: { gte: weekAgo } } }),
117
+ prisma.discussion.count(),
118
+ prisma.message.count(),
119
+ prisma.message.count({ where: { createdAt: { gte: weekAgo } } }),
120
+ prisma.worksheet.count(),
121
+ prisma.event.count(),
122
+ prisma.module.count(),
123
+ prisma.file.count(),
124
+ prisma.file.aggregate({ _sum: { size: true } }),
125
+ prisma.aiUsageLog.count({ where: { createdAt: { gte: weekAgo } } }),
126
+ prisma.aiUsageLog.count({
127
+ where: { createdAt: { gte: weekAgo }, success: false },
128
+ }),
129
+ prisma.aiUsageLog.aggregate({
130
+ where: { createdAt: { gte: weekAgo } },
131
+ _sum: { totalTokens: true },
132
+ }),
133
+ prisma.aiUsageLog.groupBy({
134
+ by: ['model'],
135
+ where: { createdAt: { gte: weekAgo } },
136
+ _count: { _all: true },
137
+ _sum: { totalTokens: true },
138
+ }),
139
+ prisma.runJob.groupBy({
140
+ by: ['type', 'status'],
141
+ where: { createdAt: { gte: weekAgo } },
142
+ _count: { _all: true },
143
+ }),
144
+ prisma.runJob.count({
145
+ where: { status: 'FAILED', createdAt: { gte: dayAgo } },
146
+ }),
147
+ ]);
148
+
149
+ const jobsByType = new Map<string, { total: number; failed: number }>();
150
+ for (const row of runJobsByType7d) {
151
+ const entry = jobsByType.get(row.type) ?? { total: 0, failed: 0 };
152
+ entry.total += row._count._all;
153
+ if (row.status === 'FAILED') entry.failed += row._count._all;
154
+ jobsByType.set(row.type, entry);
155
+ }
156
+
157
+ return {
158
+ content: {
159
+ assignments,
160
+ assignmentsLast7d,
161
+ submissions,
162
+ submissionsLast7d,
163
+ gradedSubmissions,
164
+ announcements,
165
+ announcementsLast7d,
166
+ discussions,
167
+ messages,
168
+ messagesLast7d,
169
+ worksheets,
170
+ events,
171
+ modules,
172
+ files,
173
+ storageBytes: Number(fileSize._sum.size ?? 0),
174
+ },
175
+ ai: {
176
+ calls7d: aiCalls7d,
177
+ failures7d: aiFailures7d,
178
+ tokens7d: aiTokens7d._sum.totalTokens ?? 0,
179
+ byModel: aiByModel7d
180
+ .map((row) => ({
181
+ model: row.model,
182
+ calls: row._count._all,
183
+ tokens: row._sum.totalTokens ?? 0,
184
+ }))
185
+ .sort((a, b) => b.calls - a.calls),
186
+ },
187
+ jobs: {
188
+ failedLast24h: failedJobsLast24h,
189
+ byType: [...jobsByType.entries()]
190
+ .map(([type, counts]) => ({ type, ...counts }))
191
+ .sort((a, b) => b.total - a.total),
192
+ },
193
+ };
194
+ }
@@ -1,26 +1,70 @@
1
1
  import type { Prisma } from '@prisma/client';
2
2
  import { TRPCError } from '@trpc/server';
3
3
  import { prisma } from '../lib/prisma.js';
4
+ import { classifyUser, suspectedUserWhere } from './adminSpam.js';
5
+ import { listClassesForUserForAdmin } from './adminClass.js';
6
+
7
+ export type AdminUserSpamFilter = 'all' | 'suspected' | 'clean';
8
+ export type AdminUserActivityFilter = 'all' | 'active' | 'inactive';
9
+ export type AdminUserSort = 'username' | 'recent' | 'activity';
4
10
 
5
11
  export async function searchUsersForAdmin(input: {
6
12
  page: number;
7
13
  pageSize: number;
8
14
  query?: string;
15
+ emailDomain?: string;
16
+ schoolId?: string;
17
+ verified?: boolean;
18
+ spam?: AdminUserSpamFilter;
19
+ activity?: AdminUserActivityFilter;
20
+ sort?: AdminUserSort;
9
21
  }) {
10
22
  const skip = input.page * input.pageSize;
11
- const where: Prisma.UserWhereInput = {};
23
+ const filters: Prisma.UserWhereInput[] = [];
24
+
12
25
  const q = input.query?.trim();
13
26
  if (q) {
14
- where.OR = [
15
- { username: { contains: q, mode: 'insensitive' } },
16
- { email: { contains: q, mode: 'insensitive' } },
17
- ];
27
+ filters.push({
28
+ OR: [
29
+ { username: { contains: q, mode: 'insensitive' } },
30
+ { email: { contains: q, mode: 'insensitive' } },
31
+ ],
32
+ });
33
+ }
34
+
35
+ const domain = input.emailDomain?.trim().replace(/^@/, '');
36
+ if (domain) {
37
+ filters.push({ email: { endsWith: `@${domain}`, mode: 'insensitive' } });
38
+ }
39
+
40
+ if (input.schoolId) filters.push({ schoolId: input.schoolId });
41
+ if (input.verified !== undefined) filters.push({ verified: input.verified });
42
+
43
+ if (input.spam === 'suspected') filters.push(suspectedUserWhere());
44
+ if (input.spam === 'clean') filters.push({ NOT: suspectedUserWhere() });
45
+
46
+ // "Active" means the account is attached to at least one class — the cheapest
47
+ // proxy for a real account that is actually using the platform.
48
+ if (input.activity === 'active') {
49
+ filters.push({ OR: [{ teacherIn: { some: {} } }, { studentIn: { some: {} } }] });
50
+ }
51
+ if (input.activity === 'inactive') {
52
+ filters.push({ teacherIn: { none: {} }, studentIn: { none: {} } });
18
53
  }
19
54
 
55
+ const where: Prisma.UserWhereInput = filters.length ? { AND: filters } : {};
56
+
57
+ const orderBy: Prisma.UserOrderByWithRelationInput[] =
58
+ input.sort === 'recent'
59
+ ? [{ profile: { createdAt: 'desc' } }, { username: 'asc' }]
60
+ : input.sort === 'activity'
61
+ ? [{ submissions: { _count: 'desc' } }, { username: 'asc' }]
62
+ : [{ username: 'asc' }];
63
+
20
64
  const [items, total] = await Promise.all([
21
65
  prisma.user.findMany({
22
66
  where,
23
- orderBy: { username: 'asc' },
67
+ orderBy,
24
68
  skip,
25
69
  take: input.pageSize,
26
70
  select: {
@@ -28,9 +72,15 @@ export async function searchUsersForAdmin(input: {
28
72
  username: true,
29
73
  email: true,
30
74
  verified: true,
75
+ profile: { select: { createdAt: true } },
31
76
  school: { select: { id: true, name: true } },
32
77
  _count: {
33
- select: { teacherIn: true, studentIn: true, sessions: true },
78
+ select: {
79
+ teacherIn: true,
80
+ studentIn: true,
81
+ submissions: true,
82
+ sessions: { where: { type: 'AUTH' } },
83
+ },
34
84
  },
35
85
  },
36
86
  }),
@@ -38,7 +88,7 @@ export async function searchUsersForAdmin(input: {
38
88
  ]);
39
89
 
40
90
  return {
41
- items,
91
+ items: items.map((user) => ({ ...user, signals: classifyUser(user) })),
42
92
  total,
43
93
  page: input.page,
44
94
  pageSize: input.pageSize,
@@ -92,5 +142,7 @@ export async function getUserForAdmin(id: string) {
92
142
  if (!user) {
93
143
  throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
94
144
  }
95
- return user;
145
+
146
+ const classes = await listClassesForUserForAdmin(id);
147
+ return { ...user, classes, signals: classifyUser(user) };
96
148
  }
@@ -182,7 +182,10 @@ export async function resendVerificationEmail(email: string) {
182
182
  });
183
183
  }
184
184
 
185
- await deleteSessionsByUserId(user.id);
185
+ // Do NOT delete the user's sessions here — resending a verification email
186
+ // must never log the account out of its active devices. A previously issued
187
+ // verification token simply expires on its own; issuing a fresh one is
188
+ // enough.
186
189
 
187
190
  const verificationToken = await createSession({
188
191
  id: uuidv4(),
@@ -298,6 +301,9 @@ export async function resetPassword(token: string, password: string) {
298
301
  }
299
302
 
300
303
  await updateUserPassword(session.userId, await hash(password, 10));
301
- await deleteSessionById(token);
304
+ // Revoke every session (AUTH + any other reset/verification tokens), not
305
+ // just the consumed reset token, so a stolen bearer token can't outlive a
306
+ // password reset.
307
+ await deleteSessionsByUserId(session.userId);
302
308
  return { success: true };
303
309
  }
@@ -428,8 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
428
428
  },
429
429
  {
430
430
  label: "Paid at",
431
+ // Format in UTC rather than a hardcoded country timezone — Studious is
432
+ // used internationally, and this matches the UTC basis already used
433
+ // for invoice month labels. A per-school timezone setting is the
434
+ // proper long-term home for this (see docs/audits bug audit §2).
431
435
  value: new Intl.DateTimeFormat("en-CA", {
432
- timeZone: "Asia/Kathmandu",
436
+ timeZone: "UTC",
433
437
  year: "numeric",
434
438
  month: "2-digit",
435
439
  day: "2-digit",
@@ -182,6 +182,7 @@ const CONTENT_TYPES: ModuleItemType[] = [
182
182
  ModuleItemType.FILE,
183
183
  ModuleItemType.ANNOUNCEMENT,
184
184
  ModuleItemType.DISCUSSION,
185
+ ModuleItemType.VISUALIZATION,
185
186
  ];
186
187
 
187
188
  const SUBMITTABLE_TYPES: ModuleItemType[] = [
@@ -235,7 +236,9 @@ async function validateItemInput(
235
236
  ? await prisma.announcement.findFirst({ where: { id: itemId, classId } })
236
237
  : itemType === ModuleItemType.DISCUSSION
237
238
  ? await prisma.discussion.findFirst({ where: { id: itemId, classId } })
238
- : await prisma.file.findUnique({ where: { id: itemId } });
239
+ : itemType === ModuleItemType.VISUALIZATION
240
+ ? await prisma.visualization.findFirst({ where: { id: itemId, classId } })
241
+ : await prisma.file.findUnique({ where: { id: itemId } });
239
242
  if (!exists) {
240
243
  throw new TRPCError({
241
244
  code: "NOT_FOUND",
@@ -437,11 +440,35 @@ function isModuleComplete(
437
440
  * completion/accessibility and per-module progress percentages.
438
441
  */
439
442
  export async function getStudentModulesState(classId: string, studentId: string) {
440
- const [modules, progress] = await Promise.all([
443
+ const [allModules, progress] = await Promise.all([
441
444
  findModulesByClassId(classId, true),
442
445
  findStudentProgressForClass(classId, studentId),
443
446
  ]);
444
447
 
448
+ // Hide visualisations the teacher hasn't published yet.
449
+ const visualizationIds = allModules
450
+ .flatMap((m) => m.items)
451
+ .filter((i) => i.itemType === ModuleItemType.VISUALIZATION && i.itemId)
452
+ .map((i) => i.itemId as string);
453
+ const publishedVisualizationIds = new Set(
454
+ visualizationIds.length
455
+ ? (
456
+ await prisma.visualization.findMany({
457
+ where: { id: { in: visualizationIds }, classId, published: true },
458
+ select: { id: true },
459
+ })
460
+ ).map((v) => v.id)
461
+ : []
462
+ );
463
+ const modules = allModules.map((m) => ({
464
+ ...m,
465
+ items: m.items.filter(
466
+ (i) =>
467
+ i.itemType !== ModuleItemType.VISUALIZATION ||
468
+ (!!i.itemId && publishedVisualizationIds.has(i.itemId))
469
+ ),
470
+ }));
471
+
445
472
  const completedItemIds = new Set(
446
473
  progress
447
474
  .filter((p) => p.status === ModuleItemProgressStatus.COMPLETE)
@@ -0,0 +1,147 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { prisma } from "../lib/prisma.js";
3
+ import { isTeacherInClass } from "../models/class.js";
4
+ import {
5
+ generateVisualization,
6
+ MAX_VISUALIZATION_HTML_LENGTH,
7
+ reviseVisualization,
8
+ } from "../pipelines/generateVisualization.js";
9
+
10
+ const summarySelect = {
11
+ id: true,
12
+ classId: true,
13
+ title: true,
14
+ prompt: true,
15
+ published: true,
16
+ createdAt: true,
17
+ updatedAt: true,
18
+ } as const;
19
+
20
+ const detailSelect = { ...summarySelect, html: true } as const;
21
+
22
+ async function getClassContext(classId: string) {
23
+ return prisma.class.findUnique({
24
+ where: { id: classId },
25
+ select: { name: true, subject: true, schoolId: true },
26
+ });
27
+ }
28
+
29
+ async function findTeacherVisualization(classId: string, id: string) {
30
+ const viz = await prisma.visualization.findFirst({
31
+ where: { id, classId },
32
+ select: detailSelect,
33
+ });
34
+ if (!viz) {
35
+ throw new TRPCError({ code: "NOT_FOUND", message: "Visualisation not found" });
36
+ }
37
+ return viz;
38
+ }
39
+
40
+ function aiError(error: unknown): never {
41
+ throw new TRPCError({
42
+ code: "INTERNAL_SERVER_ERROR",
43
+ message: "Newton couldn't build that visualisation. Try rephrasing it.",
44
+ cause: error,
45
+ });
46
+ }
47
+
48
+ /** Teachers see every visualisation in the class; students only published ones. */
49
+ export async function listVisualizations(userId: string, classId: string) {
50
+ const isTeacher = !!(await isTeacherInClass(classId, userId));
51
+ return prisma.visualization.findMany({
52
+ where: { classId, ...(isTeacher ? {} : { published: true }) },
53
+ select: summarySelect,
54
+ orderBy: { updatedAt: "desc" },
55
+ });
56
+ }
57
+
58
+ export async function getVisualization(userId: string, classId: string, id: string) {
59
+ const isTeacher = !!(await isTeacherInClass(classId, userId));
60
+ const viz = await prisma.visualization.findFirst({
61
+ where: { id, classId, ...(isTeacher ? {} : { published: true }) },
62
+ select: detailSelect,
63
+ });
64
+ if (!viz) {
65
+ throw new TRPCError({ code: "NOT_FOUND", message: "Visualisation not found" });
66
+ }
67
+ return viz;
68
+ }
69
+
70
+ export async function createVisualization(
71
+ userId: string,
72
+ input: { classId: string; prompt: string; title?: string },
73
+ ) {
74
+ const cls = await getClassContext(input.classId);
75
+ const generated = await generateVisualization(input.prompt, {
76
+ className: cls?.name,
77
+ subject: cls?.subject,
78
+ attribution: { userId, classId: input.classId, schoolId: cls?.schoolId },
79
+ }).catch(aiError);
80
+
81
+ return prisma.visualization.create({
82
+ data: {
83
+ classId: input.classId,
84
+ createdById: userId,
85
+ title: input.title?.trim() || generated.title,
86
+ prompt: input.prompt,
87
+ html: generated.html,
88
+ },
89
+ select: detailSelect,
90
+ });
91
+ }
92
+
93
+ export async function reviseVisualizationRecord(
94
+ userId: string,
95
+ input: { classId: string; id: string; instruction: string },
96
+ ) {
97
+ const current = await findTeacherVisualization(input.classId, input.id);
98
+ const cls = await getClassContext(input.classId);
99
+ const revised = await reviseVisualization(current, input.instruction, {
100
+ className: cls?.name,
101
+ subject: cls?.subject,
102
+ attribution: { userId, classId: input.classId, schoolId: cls?.schoolId },
103
+ }).catch(aiError);
104
+
105
+ return prisma.visualization.update({
106
+ where: { id: current.id },
107
+ data: {
108
+ html: revised.html,
109
+ prompt: [current.prompt, input.instruction].filter(Boolean).join("\n\n"),
110
+ },
111
+ select: detailSelect,
112
+ });
113
+ }
114
+
115
+ export async function updateVisualization(input: {
116
+ classId: string;
117
+ id: string;
118
+ title?: string;
119
+ html?: string;
120
+ published?: boolean;
121
+ }) {
122
+ const current = await findTeacherVisualization(input.classId, input.id);
123
+ if (input.html !== undefined && input.html.length > MAX_VISUALIZATION_HTML_LENGTH) {
124
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Visualisation is too large" });
125
+ }
126
+ return prisma.visualization.update({
127
+ where: { id: current.id },
128
+ data: {
129
+ ...(input.title !== undefined ? { title: input.title } : {}),
130
+ ...(input.html !== undefined ? { html: input.html } : {}),
131
+ ...(input.published !== undefined ? { published: input.published } : {}),
132
+ },
133
+ select: detailSelect,
134
+ });
135
+ }
136
+
137
+ /** Deletes the visualisation and any module items that pointed at it. */
138
+ export async function deleteVisualization(input: { classId: string; id: string }) {
139
+ const current = await findTeacherVisualization(input.classId, input.id);
140
+ await prisma.$transaction([
141
+ prisma.courseModuleItem.deleteMany({
142
+ where: { itemType: "VISUALIZATION", itemId: current.id },
143
+ }),
144
+ prisma.visualization.delete({ where: { id: current.id } }),
145
+ ]);
146
+ return { id: current.id };
147
+ }
@@ -0,0 +1,25 @@
1
+ import type { TRPCError } from '@trpc/server';
2
+
3
+ /**
4
+ * Error codes that mean "the caller did something wrong" — noise in Sentry.
5
+ * Everything else (server faults, unhandled Prisma/AI failures) is reported.
6
+ */
7
+ const EXPECTED_CODES = new Set([
8
+ 'BAD_REQUEST',
9
+ 'PARSE_ERROR',
10
+ 'UNAUTHORIZED',
11
+ 'FORBIDDEN',
12
+ 'NOT_FOUND',
13
+ 'CONFLICT',
14
+ 'PRECONDITION_FAILED',
15
+ 'PAYLOAD_TOO_LARGE',
16
+ 'UNPROCESSABLE_CONTENT',
17
+ 'TOO_MANY_REQUESTS',
18
+ 'CLIENT_CLOSED_REQUEST',
19
+ 'METHOD_NOT_SUPPORTED',
20
+ 'UNSUPPORTED_MEDIA_TYPE',
21
+ ]);
22
+
23
+ export function isReportableTrpcError(error: Pick<TRPCError, 'code'>): boolean {
24
+ return !EXPECTED_CODES.has(error.code);
25
+ }
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+
4
+ const prismaMocks = vi.hoisted(() => ({
5
+ file: { findFirst: vi.fn() },
6
+ }));
7
+
8
+ vi.mock('../../../src/lib/prisma.js', () => ({ prisma: prismaMocks }));
9
+ // GCS client construction touches credentials at import — stub it out.
10
+ vi.mock('../../../src/lib/googleCloudStorage.js', () => ({
11
+ getSignedUrl: vi.fn(),
12
+ objectExists: vi.fn(),
13
+ }));
14
+ // env.ts validates process.env at import; the function under test never reads
15
+ // env, so a bare stub keeps the import graph from exiting the process.
16
+ vi.mock('../../../src/lib/config/env.js', () => ({ env: {} }));
17
+
18
+ import { validateDirectUploadSession } from '../../../src/lib/fileUpload.js';
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ describe('validateDirectUploadSession', () => {
25
+ test('rejects when no upload session id is supplied', async () => {
26
+ await expect(
27
+ validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: undefined }),
28
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' } satisfies Partial<TRPCError>);
29
+ expect(prismaMocks.file.findFirst).not.toHaveBeenCalled();
30
+ });
31
+
32
+ test('rejects when no pending record matches the path + session', async () => {
33
+ prismaMocks.file.findFirst.mockResolvedValue(null);
34
+ await expect(
35
+ validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
36
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' } satisfies Partial<TRPCError>);
37
+
38
+ // Capability is keyed on path + session + unexpired + not orphaned.
39
+ const where = prismaMocks.file.findFirst.mock.calls[0][0].where;
40
+ expect(where).toMatchObject({
41
+ path: 'a/b.png',
42
+ uploadSessionId: 'sess-1',
43
+ isOrphaned: false,
44
+ });
45
+ expect(where.uploadExpiresAt.gt).toBeInstanceOf(Date);
46
+ });
47
+
48
+ test('rejects a record that is no longer accepting data', async () => {
49
+ prismaMocks.file.findFirst.mockResolvedValue({
50
+ id: 'f1',
51
+ uploadStatus: 'COMPLETED',
52
+ });
53
+ await expect(
54
+ validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
55
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' } satisfies Partial<TRPCError>);
56
+ });
57
+
58
+ test('accepts a pending record for the exact path + session', async () => {
59
+ prismaMocks.file.findFirst.mockResolvedValue({
60
+ id: 'f1',
61
+ uploadStatus: 'PENDING',
62
+ });
63
+ await expect(
64
+ validateDirectUploadSession({ filePath: 'a/b.png', uploadSessionId: 'sess-1' }),
65
+ ).resolves.toMatchObject({ id: 'f1' });
66
+ });
67
+ });