@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
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // Must be the first import: Sentry only instruments libraries that are loaded
2
+ // after `Sentry.init()` has run.
3
+ import "./instrument.js";
4
+
1
5
  import express from 'express';
2
6
  import type { Request, Response } from 'express';
3
7
  import { createServer } from 'http';
@@ -11,11 +15,12 @@ import { findUserBySessionToken } from './models/auth.js';
11
15
  import { logger } from './utils/logger.js';
12
16
  import { setupSocketHandlers } from './socket/handlers.js';
13
17
  import { bucket } from './lib/googleCloudStorage.js';
14
- import { validateDirectUploadIntent } from './lib/fileUpload.js';
18
+ import { validateDirectUploadSession } from './lib/fileUpload.js';
15
19
  import { prisma } from './lib/prisma.js';
16
20
  import { pusher } from './lib/pusher.js';
17
21
  import { connectRedis, disconnectRedis } from './lib/redis.js';
18
22
  import { createCorsOriginMatcher } from './lib/config/cors.js';
23
+ import { isReportableTrpcError } from './utils/sentryTrpc.js';
19
24
 
20
25
  import { authLimiter, generalLimiter, helmetConfig, uploadLimiter } from './middleware/security.js';
21
26
 
@@ -25,7 +30,6 @@ import compression from 'compression';
25
30
  import { v4 as uuidv4 } from 'uuid';
26
31
 
27
32
 
28
- import "./instrument.js";
29
33
  import { openAIClient } from './utils/inference.js';
30
34
  import { aiStreamRouter } from './routes/aiStream.js';
31
35
 
@@ -551,7 +555,7 @@ function getUploadSessionId(req: Request) {
551
555
  }
552
556
 
553
557
  // File upload endpoint for secure file uploads (supports both POST and PUT)
554
- app.post('/api/upload/:filePath', async (req, res) => {
558
+ app.post('/api/upload/:filePath', uploadLimiter, async (req, res) => {
555
559
  await handleFileUpload(req, res);
556
560
  });
557
561
 
@@ -575,7 +579,14 @@ async function handleFileUpload(req: Request, res: Response) {
575
579
  }
576
580
 
577
581
  res.header('Access-Control-Allow-Credentials', 'true');
578
-
582
+
583
+ // Gate the write on the server-issued upload session for this exact path.
584
+ // Without this the proxy streams the body to any GCS key a caller names.
585
+ await validateDirectUploadSession({
586
+ filePath,
587
+ uploadSessionId: getUploadSessionId(req),
588
+ });
589
+
579
590
  const contentType = req.headers['content-type'] || 'application/octet-stream';
580
591
  const file = bucket.file(filePath);
581
592
 
@@ -647,6 +658,21 @@ app.use(
647
658
  createContext: async ({ req, res }: { req: Request; res: Response }) => {
648
659
  return createTRPCContext({ req, res });
649
660
  },
661
+ // tRPC replies with a JSON error envelope instead of throwing into Express,
662
+ // so `setupExpressErrorHandler` never sees procedure failures. Without this
663
+ // hook Sentry receives nothing for the entire API.
664
+ onError: ({ error, path, type, ctx }) => {
665
+ if (!isReportableTrpcError(error)) return;
666
+ Sentry.withScope((scope) => {
667
+ scope.setTags({
668
+ 'trpc.path': path ?? 'unknown',
669
+ 'trpc.type': type,
670
+ 'trpc.code': error.code,
671
+ });
672
+ if (ctx?.user?.id) scope.setUser({ id: ctx.user.id });
673
+ Sentry.captureException(error.cause ?? error);
674
+ });
675
+ },
650
676
  })
651
677
  );
652
678
 
package/src/instrument.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  import * as Sentry from "@sentry/node";
2
2
  import { env } from "./lib/config/env.js";
3
+ import { logger } from "./utils/logger.js";
3
4
 
4
5
  // Only initialize Sentry in non-test environments
5
6
  if (env.NODE_ENV !== 'test') {
7
+ if (!env.SENTRY_DSN) {
8
+ // Without a DSN the SDK silently drops everything, which looks identical
9
+ // to "no errors happened" — say so once at boot instead.
10
+ logger.warn('SENTRY_DSN is not set: error reporting is disabled');
11
+ }
12
+
6
13
  Sentry.init({
7
14
  dsn: env.SENTRY_DSN,
8
15
  environment: env.NODE_ENV || 'development',
9
16
  // Setting this option to true will send default PII data to Sentry.
10
17
  // For example, automatic IP address collection on events
11
18
  sendDefaultPii: false,
12
- // @todo: disable in test environment
13
- enabled: true, // Explicitly disable in test environment
19
+ enabled: Boolean(env.SENTRY_DSN),
14
20
  });
15
21
  }
@@ -76,6 +76,55 @@ export async function validateDirectUploadIntent(input: {
76
76
  return fileRecord;
77
77
  }
78
78
 
79
+ /**
80
+ * Capability check for the `/api/upload/:filePath` proxy. The browser uploads
81
+ * with no auth header (only the server-issued `uploadSessionId` in the URL),
82
+ * so the session id + path pair *is* the capability: it maps to exactly one
83
+ * pending, unexpired file record that the server created for the requesting
84
+ * owner. Without this, the proxy would write the request body to any GCS
85
+ * object path a caller names.
86
+ */
87
+ export async function validateDirectUploadSession(input: {
88
+ filePath: string;
89
+ uploadSessionId: string | undefined;
90
+ }) {
91
+ if (!input.uploadSessionId) {
92
+ throw new TRPCError({
93
+ code: 'FORBIDDEN',
94
+ message: 'Missing upload session',
95
+ });
96
+ }
97
+
98
+ const fileRecord = await prisma.file.findFirst({
99
+ where: {
100
+ path: input.filePath,
101
+ uploadSessionId: input.uploadSessionId,
102
+ uploadExpiresAt: { gt: new Date() },
103
+ isOrphaned: false,
104
+ },
105
+ select: {
106
+ id: true,
107
+ uploadStatus: true,
108
+ },
109
+ });
110
+
111
+ if (!fileRecord) {
112
+ throw new TRPCError({
113
+ code: 'FORBIDDEN',
114
+ message: 'Invalid or expired upload intent',
115
+ });
116
+ }
117
+
118
+ if (!['PENDING', 'UPLOADING'].includes(fileRecord.uploadStatus)) {
119
+ throw new TRPCError({
120
+ code: 'BAD_REQUEST',
121
+ message: 'Upload is no longer accepting data',
122
+ });
123
+ }
124
+
125
+ return fileRecord;
126
+ }
127
+
79
128
  // DEPRECATED: These functions are no longer used - files are uploaded directly to GCS
80
129
  // Use createDirectUploadFile() and createDirectUploadFiles() instead
81
130
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Newton visualisation generator: turns a teacher's description into a
3
+ * self-contained interactive HTML document (sliders, canvas/SVG, labels)
4
+ * that the frontend renders inside a sandboxed iframe.
5
+ */
6
+ import { z } from "zod";
7
+ import { inference, type AiUsageAttribution } from "../utils/inference.js";
8
+
9
+ export const VISUALIZATION_MODEL = "gpt-5-mini";
10
+ export const MAX_VISUALIZATION_HTML_LENGTH = 200_000;
11
+
12
+ export const generatedVisualizationSchema = z.object({
13
+ title: z
14
+ .string()
15
+ .describe("Short student-facing title, at most 80 characters."),
16
+ html: z
17
+ .string()
18
+ .describe(
19
+ "A complete, self-contained HTML document starting with <!DOCTYPE html>.",
20
+ ),
21
+ });
22
+
23
+ export type GeneratedVisualization = z.infer<typeof generatedVisualizationSchema>;
24
+
25
+ export const VISUALIZATION_SYSTEM_PROMPT = `You are Newton, the teaching assistant inside the Studious LMS.
26
+ A teacher wants an interactive visualisation to attach to a lesson for students.
27
+
28
+ Return ONE complete, self-contained HTML document:
29
+ - All CSS in a <style> tag and all JavaScript in inline <script> tags. No external scripts, stylesheets, fonts, images or network requests (no fetch, XMLHttpRequest, WebSocket, import()). It runs in a sandboxed iframe with no network access.
30
+ - No localStorage, sessionStorage, cookies, alert, prompt or confirm.
31
+ - Use <canvas> or inline <svg> for the visual. Give students real controls (sliders, toggles, buttons, draggable points) that update the visual live, and show the key values numerically next to each control.
32
+ - Label axes, units and important quantities. Include a one-sentence instruction at the top telling students what to explore.
33
+ - Responsive: fill the viewport width, work from 320px to 1200px wide, no horizontal scroll. Use a clean neutral look with system-ui font and support prefers-color-scheme: dark.
34
+ - The science/maths must be correct. Prefer accurate formulas over approximations, and clamp inputs to physically sensible ranges.
35
+ - Keep it focused on what the teacher asked for; no quizzes, scoring or chat.`;
36
+
37
+ interface GenerateOptions {
38
+ attribution?: AiUsageAttribution;
39
+ className?: string | null;
40
+ subject?: string | null;
41
+ }
42
+
43
+ function classContext(options: GenerateOptions) {
44
+ const parts = [
45
+ options.subject ? `Subject: ${options.subject}` : null,
46
+ options.className ? `Class: ${options.className}` : null,
47
+ ].filter(Boolean);
48
+ return parts.length ? `${parts.join("\n")}\n\n` : "";
49
+ }
50
+
51
+ function assertValidHtml(result: GeneratedVisualization | null | undefined) {
52
+ if (!result?.html || !/<html[\s>]/i.test(result.html)) {
53
+ throw new Error("Newton did not return an HTML document");
54
+ }
55
+ if (result.html.length > MAX_VISUALIZATION_HTML_LENGTH) {
56
+ throw new Error("Generated visualisation is too large");
57
+ }
58
+ return {
59
+ title: result.title.trim().slice(0, 120) || "Visualisation",
60
+ html: result.html,
61
+ };
62
+ }
63
+
64
+ export async function generateVisualization(
65
+ prompt: string,
66
+ options: GenerateOptions = {},
67
+ ): Promise<GeneratedVisualization> {
68
+ const result = await inference<GeneratedVisualization>(
69
+ [
70
+ { role: "system", content: VISUALIZATION_SYSTEM_PROMPT },
71
+ {
72
+ role: "user",
73
+ content: `${classContext(options)}Teacher request:\n${prompt}`,
74
+ },
75
+ ],
76
+ generatedVisualizationSchema,
77
+ {
78
+ reason: "visualization.generate",
79
+ model: VISUALIZATION_MODEL,
80
+ attribution: options.attribution,
81
+ },
82
+ );
83
+ return assertValidHtml(result);
84
+ }
85
+
86
+ export async function reviseVisualization(
87
+ current: { title: string; html: string },
88
+ instruction: string,
89
+ options: GenerateOptions = {},
90
+ ): Promise<GeneratedVisualization> {
91
+ const result = await inference<GeneratedVisualization>(
92
+ [
93
+ { role: "system", content: VISUALIZATION_SYSTEM_PROMPT },
94
+ {
95
+ role: "user",
96
+ content: `${classContext(options)}Current visualisation "${current.title}":\n\n${current.html}\n\nRevise it as the teacher asks, keeping everything else working. Return the full updated document.\n\nTeacher request:\n${instruction}`,
97
+ },
98
+ ],
99
+ generatedVisualizationSchema,
100
+ {
101
+ reason: "visualization.revise",
102
+ model: VISUALIZATION_MODEL,
103
+ attribution: options.attribution,
104
+ },
105
+ );
106
+ return assertValidHtml(result);
107
+ }
@@ -36,6 +36,7 @@ import { marketplaceRouter } from "./marketplace.js";
36
36
  import { moduleItemRouter } from "./moduleItem.js";
37
37
  import { academicCalendarRouter } from "./academicCalendar.js";
38
38
  import { parentRouter } from "./parent.js";
39
+ import { visualizationRouter } from "./visualization.js";
39
40
 
40
41
  export const appRouter = createTRPCRouter({
41
42
  class: classRouter,
@@ -73,6 +74,7 @@ export const appRouter = createTRPCRouter({
73
74
  moduleItem: moduleItemRouter,
74
75
  academicCalendar: academicCalendarRouter,
75
76
  parent: parentRouter,
77
+ visualization: visualizationRouter,
76
78
  });
77
79
 
78
80
  // Export type router type definition
@@ -15,6 +15,23 @@ import {
15
15
  } from '../services/adminInstitution.js';
16
16
  import { getOverviewMetricsForAdmin } from '../services/adminMetrics.js';
17
17
  import { getUserForAdmin, searchUsersForAdmin } from '../services/adminUser.js';
18
+ import {
19
+ getSpamOverviewForAdmin,
20
+ listUserEmailDomainsForAdmin,
21
+ } from '../services/adminSpam.js';
22
+ import {
23
+ getClassForAdmin,
24
+ listClassesForAdmin,
25
+ } from '../services/adminClass.js';
26
+ import {
27
+ getActivitySeriesForAdmin,
28
+ getContentTelemetryForAdmin,
29
+ } from '../services/adminTelemetry.js';
30
+ import {
31
+ SQL_MAX_ROWS,
32
+ listSqlSchemaForAdmin,
33
+ runAdminSql,
34
+ } from '../services/adminSql.js';
18
35
 
19
36
  export const adminRouter = createTRPCRouter({
20
37
  getOverviewMetrics: adminProcedure.query(() => getOverviewMetricsForAdmin()),
@@ -88,6 +105,12 @@ export const adminRouter = createTRPCRouter({
88
105
  page: z.number().int().min(0).default(0),
89
106
  pageSize: z.number().int().min(1).max(100).default(25),
90
107
  query: z.string().optional(),
108
+ emailDomain: z.string().optional(),
109
+ schoolId: z.string().uuid().optional(),
110
+ verified: z.boolean().optional(),
111
+ spam: z.enum(['all', 'suspected', 'clean']).default('all'),
112
+ activity: z.enum(['all', 'active', 'inactive']).default('all'),
113
+ sort: z.enum(['username', 'recent', 'activity']).default('username'),
91
114
  }),
92
115
  )
93
116
  .query(({ input }) => searchUsersForAdmin(input)),
@@ -95,4 +118,53 @@ export const adminRouter = createTRPCRouter({
95
118
  getUser: adminProcedure
96
119
  .input(z.object({ id: z.string().uuid() }))
97
120
  .query(({ input }) => getUserForAdmin(input.id)),
121
+
122
+ listClasses: adminProcedure
123
+ .input(
124
+ z.object({
125
+ page: z.number().int().min(0).default(0),
126
+ pageSize: z.number().int().min(1).max(100).default(25),
127
+ query: z.string().optional(),
128
+ schoolId: z.string().uuid().optional(),
129
+ membership: z.enum(['all', 'populated', 'empty']).default('all'),
130
+ sort: z.enum(['name', 'students', 'assignments']).default('name'),
131
+ }),
132
+ )
133
+ .query(({ input }) => listClassesForAdmin(input)),
134
+
135
+ getClass: adminProcedure
136
+ .input(z.object({ id: z.string().uuid() }))
137
+ .query(({ input }) => getClassForAdmin(input.id)),
138
+
139
+ getContentTelemetry: adminProcedure.query(() =>
140
+ getContentTelemetryForAdmin(),
141
+ ),
142
+
143
+ getActivitySeries: adminProcedure
144
+ .input(z.object({ days: z.number().int().min(1).max(90).default(14) }))
145
+ .query(({ input }) => getActivitySeriesForAdmin(input)),
146
+
147
+ listSqlSchema: adminProcedure.query(() => listSqlSchemaForAdmin()),
148
+
149
+ runSql: adminProcedure
150
+ .input(
151
+ z.object({
152
+ sql: z.string().min(1).max(10_000),
153
+ limit: z.number().int().min(1).max(SQL_MAX_ROWS).default(SQL_MAX_ROWS),
154
+ }),
155
+ )
156
+ .mutation(({ input, ctx }) =>
157
+ runAdminSql({ sql: input.sql, limit: input.limit, userId: ctx.user?.id }),
158
+ ),
159
+
160
+ getSpamOverview: adminProcedure.query(() => getSpamOverviewForAdmin()),
161
+
162
+ listUserEmailDomains: adminProcedure
163
+ .input(
164
+ z.object({
165
+ limit: z.number().int().min(1).max(200).default(50),
166
+ suspectedOnly: z.boolean().default(false),
167
+ }),
168
+ )
169
+ .query(({ input }) => listUserEmailDomainsForAdmin(input)),
98
170
  });
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { TRPCError } from "@trpc/server";
2
3
  import {
3
4
  createTRPCRouter,
4
5
  protectedProcedure,
@@ -95,7 +96,19 @@ export const classRouter = createTRPCRouter({
95
96
 
96
97
  delete: protectedTeacherProcedure
97
98
  .input(z.object({ classId: z.string(), id: z.string() }))
98
- .mutation(({ input }) => deleteClass(input.id)),
99
+ .mutation(({ input }) => {
100
+ // `protectedTeacherProcedure` authorizes the caller against `classId`.
101
+ // Deleting `input.id` unchecked let a teacher of one class delete any
102
+ // other class by id; require the two to match so the authorized class
103
+ // is the one deleted.
104
+ if (input.id !== input.classId) {
105
+ throw new TRPCError({
106
+ code: "FORBIDDEN",
107
+ message: "Class id does not match the authorized class.",
108
+ });
109
+ }
110
+ return deleteClass(input.classId);
111
+ }),
99
112
  addStudent: protectedTeacherProcedure
100
113
  .input(z.object({ classId: z.string(), studentId: z.string() }))
101
114
  .mutation(({ input }) => addStudent(input.classId, input.studentId)),
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import {
3
+ createTRPCRouter,
4
+ protectedClassMemberProcedure,
5
+ protectedTeacherProcedure,
6
+ } from "../trpc.js";
7
+ import {
8
+ createVisualization,
9
+ deleteVisualization,
10
+ getVisualization,
11
+ listVisualizations,
12
+ reviseVisualizationRecord,
13
+ updateVisualization,
14
+ } from "../services/visualization.js";
15
+
16
+ export const visualizationRouter = createTRPCRouter({
17
+ list: protectedClassMemberProcedure
18
+ .input(z.object({ classId: z.string() }))
19
+ .query(({ ctx, input }) => listVisualizations(ctx.user!.id, input.classId)),
20
+
21
+ get: protectedClassMemberProcedure
22
+ .input(z.object({ classId: z.string(), id: z.string() }))
23
+ .query(({ ctx, input }) => getVisualization(ctx.user!.id, input.classId, input.id)),
24
+
25
+ generate: protectedTeacherProcedure
26
+ .input(
27
+ z.object({
28
+ classId: z.string(),
29
+ prompt: z.string().trim().min(3).max(4000),
30
+ title: z.string().trim().max(120).optional(),
31
+ }),
32
+ )
33
+ .mutation(({ ctx, input }) => createVisualization(ctx.user!.id, input)),
34
+
35
+ revise: protectedTeacherProcedure
36
+ .input(
37
+ z.object({
38
+ classId: z.string(),
39
+ id: z.string(),
40
+ instruction: z.string().trim().min(3).max(4000),
41
+ }),
42
+ )
43
+ .mutation(({ ctx, input }) => reviseVisualizationRecord(ctx.user!.id, input)),
44
+
45
+ update: protectedTeacherProcedure
46
+ .input(
47
+ z.object({
48
+ classId: z.string(),
49
+ id: z.string(),
50
+ title: z.string().trim().min(1).max(120).optional(),
51
+ html: z.string().min(1).optional(),
52
+ published: z.boolean().optional(),
53
+ }),
54
+ )
55
+ .mutation(({ input }) => updateVisualization(input)),
56
+
57
+ delete: protectedTeacherProcedure
58
+ .input(z.object({ classId: z.string(), id: z.string() }))
59
+ .mutation(({ input }) => deleteVisualization(input)),
60
+ });
@@ -0,0 +1,222 @@
1
+ import type { Prisma } from '@prisma/client';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { prisma } from '../lib/prisma.js';
4
+ import { classifyUser } from './adminSpam.js';
5
+
6
+ export type AdminClassMembership = 'all' | 'populated' | 'empty';
7
+ export type AdminClassSort = 'name' | 'students' | 'assignments';
8
+
9
+ export async function listClassesForAdmin(input: {
10
+ page: number;
11
+ pageSize: number;
12
+ query?: string;
13
+ schoolId?: string;
14
+ membership?: AdminClassMembership;
15
+ sort?: AdminClassSort;
16
+ }) {
17
+ const skip = input.page * input.pageSize;
18
+ const filters: Prisma.ClassWhereInput[] = [];
19
+
20
+ const q = input.query?.trim();
21
+ if (q) {
22
+ filters.push({
23
+ OR: [
24
+ { name: { contains: q, mode: 'insensitive' } },
25
+ { subject: { contains: q, mode: 'insensitive' } },
26
+ { section: { contains: q, mode: 'insensitive' } },
27
+ ],
28
+ });
29
+ }
30
+
31
+ if (input.schoolId) filters.push({ schoolId: input.schoolId });
32
+ if (input.membership === 'populated') filters.push({ students: { some: {} } });
33
+ if (input.membership === 'empty') filters.push({ students: { none: {} } });
34
+
35
+ const where: Prisma.ClassWhereInput = filters.length ? { AND: filters } : {};
36
+
37
+ const orderBy: Prisma.ClassOrderByWithRelationInput[] =
38
+ input.sort === 'students'
39
+ ? [{ students: { _count: 'desc' } }, { name: 'asc' }]
40
+ : input.sort === 'assignments'
41
+ ? [{ assignments: { _count: 'desc' } }, { name: 'asc' }]
42
+ : [{ name: 'asc' }];
43
+
44
+ const [items, total] = await Promise.all([
45
+ prisma.class.findMany({
46
+ where,
47
+ orderBy,
48
+ skip,
49
+ take: input.pageSize,
50
+ select: {
51
+ id: true,
52
+ name: true,
53
+ subject: true,
54
+ section: true,
55
+ school: { select: { id: true, name: true } },
56
+ department: { select: { id: true, name: true } },
57
+ teachers: { select: { id: true, username: true }, take: 3 },
58
+ assignments: {
59
+ orderBy: { createdAt: 'desc' },
60
+ take: 1,
61
+ select: { createdAt: true },
62
+ },
63
+ _count: {
64
+ select: {
65
+ students: true,
66
+ teachers: true,
67
+ assignments: true,
68
+ announcements: true,
69
+ discussions: true,
70
+ worksheets: true,
71
+ events: true,
72
+ modules: true,
73
+ },
74
+ },
75
+ },
76
+ }),
77
+ prisma.class.count({ where }),
78
+ ]);
79
+
80
+ return {
81
+ items: items.map(({ assignments, ...rest }) => ({
82
+ ...rest,
83
+ lastAssignmentAt: assignments[0]?.createdAt ?? null,
84
+ })),
85
+ total,
86
+ page: input.page,
87
+ pageSize: input.pageSize,
88
+ pageCount: Math.max(1, Math.ceil(total / input.pageSize)),
89
+ };
90
+ }
91
+
92
+ const MEMBER_SELECT = {
93
+ id: true,
94
+ username: true,
95
+ email: true,
96
+ verified: true,
97
+ _count: { select: { teacherIn: true, studentIn: true, submissions: true } },
98
+ } satisfies Prisma.UserSelect;
99
+
100
+ export async function getClassForAdmin(id: string) {
101
+ const cls = await prisma.class.findUnique({
102
+ where: { id },
103
+ select: {
104
+ id: true,
105
+ name: true,
106
+ subject: true,
107
+ section: true,
108
+ color: true,
109
+ school: { select: { id: true, name: true } },
110
+ department: { select: { id: true, name: true } },
111
+ teachers: { orderBy: { username: 'asc' }, select: MEMBER_SELECT },
112
+ students: {
113
+ orderBy: { username: 'asc' },
114
+ take: 200,
115
+ select: MEMBER_SELECT,
116
+ },
117
+ assignments: {
118
+ orderBy: { createdAt: 'desc' },
119
+ take: 20,
120
+ select: {
121
+ id: true,
122
+ title: true,
123
+ type: true,
124
+ graded: true,
125
+ dueDate: true,
126
+ createdAt: true,
127
+ teacher: { select: { id: true, username: true } },
128
+ _count: { select: { submissions: true } },
129
+ },
130
+ },
131
+ announcements: {
132
+ orderBy: { createdAt: 'desc' },
133
+ take: 10,
134
+ select: {
135
+ id: true,
136
+ createdAt: true,
137
+ remarks: true,
138
+ teacher: { select: { id: true, username: true } },
139
+ },
140
+ },
141
+ _count: {
142
+ select: {
143
+ students: true,
144
+ teachers: true,
145
+ assignments: true,
146
+ announcements: true,
147
+ discussions: true,
148
+ worksheets: true,
149
+ events: true,
150
+ modules: true,
151
+ sections: true,
152
+ },
153
+ },
154
+ },
155
+ });
156
+
157
+ if (!cls) {
158
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Class not found' });
159
+ }
160
+
161
+ // Per-student submission counts scoped to this class, so a roster row shows
162
+ // engagement here rather than platform-wide totals.
163
+ const submissionsByStudent = await prisma.submission.groupBy({
164
+ by: ['studentId'],
165
+ where: { assignment: { classId: id } },
166
+ _count: { _all: true },
167
+ });
168
+ const submissionCount = new Map(
169
+ submissionsByStudent.map((row) => [row.studentId, row._count._all]),
170
+ );
171
+
172
+ const withSignals = (user: (typeof cls.students)[number]) => ({
173
+ ...user,
174
+ signals: classifyUser(user),
175
+ submissionsInClass: submissionCount.get(user.id) ?? 0,
176
+ });
177
+
178
+ return {
179
+ ...cls,
180
+ teachers: cls.teachers.map(withSignals),
181
+ students: cls.students.map(withSignals),
182
+ announcements: cls.announcements.map((announcement) => ({
183
+ ...announcement,
184
+ remarks:
185
+ announcement.remarks.length > 160
186
+ ? `${announcement.remarks.slice(0, 160)}…`
187
+ : announcement.remarks,
188
+ })),
189
+ suspectedMembers: [...cls.teachers, ...cls.students].filter(
190
+ (user) => classifyUser(user).suspected,
191
+ ).length,
192
+ };
193
+ }
194
+
195
+ /** Classes a single user teaches or studies in, for the user detail page. */
196
+ export async function listClassesForUserForAdmin(userId: string) {
197
+ const select = {
198
+ id: true,
199
+ name: true,
200
+ subject: true,
201
+ section: true,
202
+ school: { select: { id: true, name: true } },
203
+ _count: { select: { students: true, assignments: true } },
204
+ } satisfies Prisma.ClassSelect;
205
+
206
+ const [teaching, enrolled] = await Promise.all([
207
+ prisma.class.findMany({
208
+ where: { teachers: { some: { id: userId } } },
209
+ orderBy: { name: 'asc' },
210
+ take: 100,
211
+ select,
212
+ }),
213
+ prisma.class.findMany({
214
+ where: { students: { some: { id: userId } } },
215
+ orderBy: { name: 'asc' },
216
+ take: 100,
217
+ select,
218
+ }),
219
+ ]);
220
+
221
+ return { teaching, enrolled };
222
+ }