@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
|
@@ -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
|
|
23
|
+
const filters: Prisma.UserWhereInput[] = [];
|
|
24
|
+
|
|
12
25
|
const q = input.query?.trim();
|
|
13
26
|
if (q) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
145
|
+
|
|
146
|
+
const classes = await listClassesForUserForAdmin(id);
|
|
147
|
+
return { ...user, classes, signals: classifyUser(user) };
|
|
96
148
|
}
|
package/src/services/auth.ts
CHANGED
|
@@ -182,7 +182,10 @@ export async function resendVerificationEmail(email: string) {
|
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/services/fees.ts
CHANGED
|
@@ -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: "
|
|
436
|
+
timeZone: "UTC",
|
|
433
437
|
year: "numeric",
|
|
434
438
|
month: "2-digit",
|
|
435
439
|
day: "2-digit",
|
package/src/services/module.ts
CHANGED
|
@@ -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
|
-
:
|
|
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 [
|
|
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
|
+
});
|