@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/instrument.js +9 -4
- package/dist/instrument.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +15 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +43 -2
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +2 -2
- package/dist/pipelines/generateVisualization.d.ts +33 -0
- package/dist/pipelines/generateVisualization.d.ts.map +1 -0
- package/dist/pipelines/generateVisualization.js +79 -0
- package/dist/pipelines/generateVisualization.js.map +1 -0
- package/dist/routers/_app.d.ts +891 -53
- 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 +297 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +43 -2
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +16 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/visualization.d.ts +124 -0
- package/dist/routers/visualization.d.ts.map +1 -0
- package/dist/routers/visualization.js +41 -0
- package/dist/routers/visualization.js.map +1 -0
- package/dist/services/adminClass.d.ts +153 -0
- package/dist/services/adminClass.d.ts.map +1 -0
- package/dist/services/adminClass.js +196 -0
- package/dist/services/adminClass.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +11 -4
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +8 -0
- package/dist/services/adminMetrics.d.ts.map +1 -1
- package/dist/services/adminMetrics.js +30 -3
- package/dist/services/adminMetrics.js.map +1 -1
- package/dist/services/adminSpam.d.ts +76 -0
- package/dist/services/adminSpam.d.ts.map +1 -0
- package/dist/services/adminSpam.js +163 -0
- package/dist/services/adminSpam.js.map +1 -0
- package/dist/services/adminSql.d.ts +36 -0
- package/dist/services/adminSql.d.ts.map +1 -0
- package/dist/services/adminSql.js +172 -0
- package/dist/services/adminSql.js.map +1 -0
- package/dist/services/adminTelemetry.d.ts +56 -0
- package/dist/services/adminTelemetry.d.ts.map +1 -0
- package/dist/services/adminTelemetry.js +147 -0
- package/dist/services/adminTelemetry.js.map +1 -0
- package/dist/services/adminUser.d.ts +45 -0
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +48 -11
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +10 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +7 -3
- package/dist/services/fees.js.map +1 -1
- package/dist/services/module.d.ts.map +1 -1
- package/dist/services/module.js +23 -4
- package/dist/services/module.js.map +1 -1
- package/dist/services/visualization.d.ts +72 -0
- package/dist/services/visualization.d.ts.map +1 -0
- package/dist/services/visualization.js +122 -0
- package/dist/services/visualization.js.map +1 -0
- package/dist/utils/sentryTrpc.d.ts +3 -0
- package/dist/utils/sentryTrpc.d.ts.map +1 -0
- package/dist/utils/sentryTrpc.js +26 -0
- package/dist/utils/sentryTrpc.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +24 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/visualization.ts +60 -0
- package/src/services/adminClass.ts +222 -0
- package/src/services/adminInstitution.ts +9 -2
- package/src/services/adminMetrics.ts +35 -0
- package/src/services/adminSpam.ts +224 -0
- package/src/services/adminSql.ts +214 -0
- package/src/services/adminTelemetry.ts +194 -0
- package/src/services/adminUser.ts +61 -9
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/module.ts +29 -2
- package/src/services/visualization.ts +147 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/tests/unit/lib/fileUploadSession.test.ts +67 -0
- package/tests/unit/services/adminClass.test.ts +102 -0
- package/tests/unit/services/adminSpam.test.ts +108 -0
- package/tests/unit/services/adminSql.test.ts +56 -0
- package/tests/unit/services/adminTelemetry.test.ts +47 -0
- package/tests/unit/services/adminUser.test.ts +2 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- 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 {
|
|
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
|
-
|
|
13
|
-
enabled: true, // Explicitly disable in test environment
|
|
19
|
+
enabled: Boolean(env.SENTRY_DSN),
|
|
14
20
|
});
|
|
15
21
|
}
|
package/src/lib/fileUpload.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routers/_app.ts
CHANGED
|
@@ -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
|
package/src/routers/admin.ts
CHANGED
|
@@ -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
|
});
|
package/src/routers/class.ts
CHANGED
|
@@ -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 }) =>
|
|
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
|
+
}
|