@studious-lms/server 1.26.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/.env.example +3 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- 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/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- 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/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1118 -70
- 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 -1
- 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/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +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/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- 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 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +53 -13
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- 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/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.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/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.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/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.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/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- 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/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +32 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/labChat.ts +5 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routers/visualization.ts +60 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- 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 +66 -11
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +29 -2
- package/src/services/parentPortal.ts +85 -0
- package/src/services/studentProgress.ts +135 -0
- package/src/services/visualization.ts +147 -0
- package/src/utils/inference.ts +85 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- 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 +77 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
- package/tests/unit/utils/sentryTrpc.test.ts +15 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Prisma } from '@prisma/client';
|
|
2
|
+
import { prisma } from '../lib/prisma.js';
|
|
3
|
+
|
|
4
|
+
export type SpamReason =
|
|
5
|
+
| 'BLOCKED_DOMAIN'
|
|
6
|
+
| 'BOT_PATTERN'
|
|
7
|
+
| 'DISPOSABLE_DOMAIN'
|
|
8
|
+
| 'NEVER_VERIFIED_INACTIVE';
|
|
9
|
+
|
|
10
|
+
export type SpamSignals = {
|
|
11
|
+
suspected: boolean;
|
|
12
|
+
reasons: SpamReason[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BLOCKED_DOMAINS = ['riverside.edu'];
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BOT_PATTERNS = ['devin', 'automation-bot', 'qa-bot'];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DISPOSABLE_DOMAINS = [
|
|
20
|
+
'mailinator.com',
|
|
21
|
+
'guerrillamail.com',
|
|
22
|
+
'yopmail.com',
|
|
23
|
+
'tempmail.com',
|
|
24
|
+
'temp-mail.org',
|
|
25
|
+
'10minutemail.com',
|
|
26
|
+
'trashmail.com',
|
|
27
|
+
'sharklasers.com',
|
|
28
|
+
'dispostable.com',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function fromEnv(name: string, fallback: string[]): string[] {
|
|
32
|
+
const raw = process.env[name];
|
|
33
|
+
if (!raw) return fallback;
|
|
34
|
+
const parsed = raw
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
return parsed.length > 0 ? parsed : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function spamRules() {
|
|
42
|
+
return {
|
|
43
|
+
blockedDomains: fromEnv('ADMIN_SPAM_DOMAINS', DEFAULT_BLOCKED_DOMAINS),
|
|
44
|
+
botPatterns: fromEnv('ADMIN_SPAM_BOT_PATTERNS', DEFAULT_BOT_PATTERNS),
|
|
45
|
+
disposableDomains: fromEnv(
|
|
46
|
+
'ADMIN_SPAM_DISPOSABLE_DOMAINS',
|
|
47
|
+
DEFAULT_DISPOSABLE_DOMAINS,
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function domainOf(email: string): string {
|
|
53
|
+
const at = email.lastIndexOf('@');
|
|
54
|
+
return at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchesDomain(domain: string, listed: string): boolean {
|
|
58
|
+
return domain === listed || domain.endsWith(`.${listed}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ClassifiableUser = {
|
|
62
|
+
username: string;
|
|
63
|
+
email: string;
|
|
64
|
+
verified: boolean;
|
|
65
|
+
_count?: { teacherIn: number; studentIn: number; submissions?: number };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function classifyEmail(
|
|
69
|
+
email: string | null | undefined,
|
|
70
|
+
username?: string | null,
|
|
71
|
+
): SpamSignals {
|
|
72
|
+
const rules = spamRules();
|
|
73
|
+
const normalisedEmail = (email ?? '').toLowerCase();
|
|
74
|
+
const normalisedUsername = (username ?? '').toLowerCase();
|
|
75
|
+
const domain = domainOf(normalisedEmail);
|
|
76
|
+
const reasons: SpamReason[] = [];
|
|
77
|
+
|
|
78
|
+
if (rules.blockedDomains.some((listed) => matchesDomain(domain, listed))) {
|
|
79
|
+
reasons.push('BLOCKED_DOMAIN');
|
|
80
|
+
}
|
|
81
|
+
if (rules.disposableDomains.some((listed) => matchesDomain(domain, listed))) {
|
|
82
|
+
reasons.push('DISPOSABLE_DOMAIN');
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
rules.botPatterns.some(
|
|
86
|
+
(pattern) =>
|
|
87
|
+
normalisedEmail.includes(pattern) ||
|
|
88
|
+
(normalisedUsername.length > 0 && normalisedUsername.includes(pattern)),
|
|
89
|
+
)
|
|
90
|
+
) {
|
|
91
|
+
reasons.push('BOT_PATTERN');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { suspected: reasons.length > 0, reasons };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function classifyUser(user: ClassifiableUser): SpamSignals {
|
|
98
|
+
const reasons = [...classifyEmail(user.email, user.username).reasons];
|
|
99
|
+
const counts = user._count;
|
|
100
|
+
if (
|
|
101
|
+
!user.verified &&
|
|
102
|
+
counts &&
|
|
103
|
+
counts.teacherIn === 0 &&
|
|
104
|
+
counts.studentIn === 0 &&
|
|
105
|
+
(counts.submissions ?? 0) === 0
|
|
106
|
+
) {
|
|
107
|
+
reasons.push('NEVER_VERIFIED_INACTIVE');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { suspected: reasons.length > 0, reasons };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prisma filter matching the rule-based part of {@link classifyUser}. The
|
|
115
|
+
* `NEVER_VERIFIED_INACTIVE` reason is intentionally excluded: brand new real
|
|
116
|
+
* signups look identical to it, so it only annotates rows and never drives
|
|
117
|
+
* list filtering or counts.
|
|
118
|
+
*/
|
|
119
|
+
export function suspectedUserWhere(): Prisma.UserWhereInput {
|
|
120
|
+
const rules = spamRules();
|
|
121
|
+
const domains = [...rules.blockedDomains, ...rules.disposableDomains];
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
OR: [
|
|
125
|
+
...domains.map((domain) => ({
|
|
126
|
+
email: { endsWith: `@${domain}`, mode: 'insensitive' as const },
|
|
127
|
+
})),
|
|
128
|
+
...domains.map((domain) => ({
|
|
129
|
+
email: { endsWith: `.${domain}`, mode: 'insensitive' as const },
|
|
130
|
+
})),
|
|
131
|
+
...rules.botPatterns.flatMap((pattern) => [
|
|
132
|
+
{ username: { contains: pattern, mode: 'insensitive' as const } },
|
|
133
|
+
{ email: { contains: pattern, mode: 'insensitive' as const } },
|
|
134
|
+
]),
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function notSuspectedUserWhere(): Prisma.UserWhereInput {
|
|
140
|
+
return { NOT: suspectedUserWhere() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function countSuspectedUsers(): Promise<number> {
|
|
144
|
+
return prisma.user.count({ where: suspectedUserWhere() });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type DomainRow = { domain: string | null; total: bigint; verified: bigint };
|
|
148
|
+
|
|
149
|
+
export async function listUserEmailDomainsForAdmin(input: {
|
|
150
|
+
limit: number;
|
|
151
|
+
suspectedOnly: boolean;
|
|
152
|
+
}) {
|
|
153
|
+
const rows = await prisma.$queryRaw<DomainRow[]>`
|
|
154
|
+
SELECT lower(split_part(email, '@', 2)) AS domain,
|
|
155
|
+
count(*) AS total,
|
|
156
|
+
count(*) FILTER (WHERE verified) AS verified
|
|
157
|
+
FROM "User"
|
|
158
|
+
GROUP BY 1
|
|
159
|
+
ORDER BY count(*) DESC, 1 ASC
|
|
160
|
+
LIMIT ${Math.min(input.limit, 200)}
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
const rules = spamRules();
|
|
164
|
+
const items = rows.map((row) => {
|
|
165
|
+
const domain = row.domain ?? '';
|
|
166
|
+
const suspected =
|
|
167
|
+
rules.blockedDomains.some((listed) => matchesDomain(domain, listed)) ||
|
|
168
|
+
rules.disposableDomains.some((listed) => matchesDomain(domain, listed));
|
|
169
|
+
return {
|
|
170
|
+
domain: domain || '(none)',
|
|
171
|
+
total: Number(row.total),
|
|
172
|
+
verified: Number(row.verified),
|
|
173
|
+
suspected,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
items: input.suspectedOnly ? items.filter((item) => item.suspected) : items,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function getSpamOverviewForAdmin() {
|
|
183
|
+
const rules = spamRules();
|
|
184
|
+
const suspectedWhere = suspectedUserWhere();
|
|
185
|
+
|
|
186
|
+
const [
|
|
187
|
+
totalUsers,
|
|
188
|
+
suspectedUsers,
|
|
189
|
+
suspectedVerified,
|
|
190
|
+
domains,
|
|
191
|
+
recent,
|
|
192
|
+
] = await Promise.all([
|
|
193
|
+
prisma.user.count(),
|
|
194
|
+
prisma.user.count({ where: suspectedWhere }),
|
|
195
|
+
prisma.user.count({ where: { AND: [suspectedWhere, { verified: true }] } }),
|
|
196
|
+
listUserEmailDomainsForAdmin({ limit: 200, suspectedOnly: true }),
|
|
197
|
+
prisma.user.findMany({
|
|
198
|
+
where: suspectedWhere,
|
|
199
|
+
orderBy: { username: 'asc' },
|
|
200
|
+
take: 50,
|
|
201
|
+
select: {
|
|
202
|
+
id: true,
|
|
203
|
+
username: true,
|
|
204
|
+
email: true,
|
|
205
|
+
verified: true,
|
|
206
|
+
profile: { select: { createdAt: true } },
|
|
207
|
+
_count: { select: { teacherIn: true, studentIn: true, submissions: true } },
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
rules,
|
|
214
|
+
totalUsers,
|
|
215
|
+
suspectedUsers,
|
|
216
|
+
suspectedVerified,
|
|
217
|
+
cleanUsers: totalUsers - suspectedUsers,
|
|
218
|
+
domains: domains.items,
|
|
219
|
+
recent: recent.map((user) => ({
|
|
220
|
+
...user,
|
|
221
|
+
signals: classifyUser(user),
|
|
222
|
+
})),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { prisma } from '../lib/prisma.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read-only SQL console backing `admin.runSql`.
|
|
7
|
+
*
|
|
8
|
+
* Three independent guards, because a single one is not enough:
|
|
9
|
+
* 1. the statement is parsed/validated here (single read statement only),
|
|
10
|
+
* 2. it runs inside a `READ ONLY` transaction so the database rejects writes,
|
|
11
|
+
* 3. a statement timeout and row cap bound the blast radius of a bad query.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const READ_STARTERS = ['select', 'with', 'explain', 'show', 'table', 'values'];
|
|
15
|
+
|
|
16
|
+
/** Statement keywords that must never appear, including inside CTEs. */
|
|
17
|
+
const FORBIDDEN_KEYWORDS = [
|
|
18
|
+
'insert',
|
|
19
|
+
'update',
|
|
20
|
+
'delete',
|
|
21
|
+
'merge',
|
|
22
|
+
'upsert',
|
|
23
|
+
'drop',
|
|
24
|
+
'alter',
|
|
25
|
+
'create',
|
|
26
|
+
'truncate',
|
|
27
|
+
'grant',
|
|
28
|
+
'revoke',
|
|
29
|
+
'comment',
|
|
30
|
+
'copy',
|
|
31
|
+
'vacuum',
|
|
32
|
+
'analyze',
|
|
33
|
+
'reindex',
|
|
34
|
+
'cluster',
|
|
35
|
+
'refresh',
|
|
36
|
+
'lock',
|
|
37
|
+
'listen',
|
|
38
|
+
'notify',
|
|
39
|
+
'call',
|
|
40
|
+
'do',
|
|
41
|
+
'set',
|
|
42
|
+
'reset',
|
|
43
|
+
'begin',
|
|
44
|
+
'commit',
|
|
45
|
+
'rollback',
|
|
46
|
+
'savepoint',
|
|
47
|
+
'prepare',
|
|
48
|
+
'execute',
|
|
49
|
+
'deallocate',
|
|
50
|
+
'discard',
|
|
51
|
+
'import',
|
|
52
|
+
'security',
|
|
53
|
+
'pg_read_file',
|
|
54
|
+
'pg_read_binary_file',
|
|
55
|
+
'pg_ls_dir',
|
|
56
|
+
'dblink',
|
|
57
|
+
'pg_sleep',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export const SQL_MAX_ROWS = 500;
|
|
61
|
+
export const SQL_TIMEOUT_MS = 10_000;
|
|
62
|
+
|
|
63
|
+
function stripCommentsAndStrings(sql: string): string {
|
|
64
|
+
return sql
|
|
65
|
+
.replace(/--[^\n]*/g, ' ')
|
|
66
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
67
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
68
|
+
.replace(/"(?:[^"]|"")*"/g, '""');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function badRequest(message: string): TRPCError {
|
|
72
|
+
return new TRPCError({ code: 'BAD_REQUEST', message });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validates that `sql` is a single read-only statement and returns it without
|
|
77
|
+
* its trailing semicolon. Throws a `BAD_REQUEST` otherwise.
|
|
78
|
+
*/
|
|
79
|
+
export function assertReadOnlySql(sql: string): string {
|
|
80
|
+
const statement = sql.trim().replace(/;\s*$/, '').trim();
|
|
81
|
+
if (!statement) throw badRequest('Query is empty.');
|
|
82
|
+
|
|
83
|
+
const sanitized = stripCommentsAndStrings(statement).toLowerCase();
|
|
84
|
+
|
|
85
|
+
if (sanitized.includes(';')) {
|
|
86
|
+
throw badRequest('Only a single statement may be executed.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const firstWord = sanitized.trim().split(/\s+/)[0]?.replace(/^\(+/, '') ?? '';
|
|
90
|
+
if (!READ_STARTERS.includes(firstWord)) {
|
|
91
|
+
throw badRequest(
|
|
92
|
+
`Only ${READ_STARTERS.map((word) => word.toUpperCase()).join('/')} queries are allowed.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const forbidden = FORBIDDEN_KEYWORDS.find((keyword) =>
|
|
97
|
+
new RegExp(`\\b${keyword}\\b`).test(sanitized),
|
|
98
|
+
);
|
|
99
|
+
if (forbidden) {
|
|
100
|
+
throw badRequest(`Keyword "${forbidden.toUpperCase()}" is not allowed in the SQL console.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return statement;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Wraps row-returning statements so the row cap is enforced by the database. */
|
|
107
|
+
export function buildBoundedQuery(statement: string, limit: number): string {
|
|
108
|
+
const firstWord = stripCommentsAndStrings(statement).trim().toLowerCase().split(/\s+/)[0] ?? '';
|
|
109
|
+
if (firstWord === 'explain' || firstWord === 'show') return statement;
|
|
110
|
+
return `SELECT * FROM (${statement}) AS admin_sql_console LIMIT ${limit}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** JSON-safe values: Prisma returns bigints, dates, buffers and Decimals raw. */
|
|
114
|
+
export function serializeValue(value: unknown): unknown {
|
|
115
|
+
if (value === null || value === undefined) return null;
|
|
116
|
+
if (typeof value === 'bigint') return value.toString();
|
|
117
|
+
if (value instanceof Date) return value.toISOString();
|
|
118
|
+
if (Buffer.isBuffer(value)) return `\\x${value.toString('hex')}`;
|
|
119
|
+
if (Array.isArray(value)) return value.map(serializeValue);
|
|
120
|
+
if (typeof value === 'object') {
|
|
121
|
+
const source = value as Record<string, unknown>;
|
|
122
|
+
if (typeof source.toFixed === 'function') return String(value);
|
|
123
|
+
return Object.fromEntries(
|
|
124
|
+
Object.entries(source).map(([key, entry]) => [key, serializeValue(entry)]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type AdminSqlResult = {
|
|
131
|
+
columns: string[];
|
|
132
|
+
rows: Record<string, unknown>[];
|
|
133
|
+
rowCount: number;
|
|
134
|
+
truncated: boolean;
|
|
135
|
+
durationMs: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export async function runAdminSql(input: {
|
|
139
|
+
sql: string;
|
|
140
|
+
limit?: number;
|
|
141
|
+
userId?: string;
|
|
142
|
+
}): Promise<AdminSqlResult> {
|
|
143
|
+
const limit = Math.min(Math.max(input.limit ?? SQL_MAX_ROWS, 1), SQL_MAX_ROWS);
|
|
144
|
+
const statement = assertReadOnlySql(input.sql);
|
|
145
|
+
const query = buildBoundedQuery(statement, limit + 1);
|
|
146
|
+
|
|
147
|
+
logger.warn('Admin SQL console query', { userId: input.userId, sql: statement, limit });
|
|
148
|
+
|
|
149
|
+
const started = Date.now();
|
|
150
|
+
let rows: Record<string, unknown>[];
|
|
151
|
+
try {
|
|
152
|
+
rows = await prisma.$transaction(async (tx) => {
|
|
153
|
+
await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');
|
|
154
|
+
await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = ${SQL_TIMEOUT_MS}`);
|
|
155
|
+
return (await tx.$queryRawUnsafe(query)) as Record<string, unknown>[];
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new TRPCError({
|
|
159
|
+
code: 'BAD_REQUEST',
|
|
160
|
+
message: error instanceof Error ? error.message : 'Query failed',
|
|
161
|
+
cause: error,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const durationMs = Date.now() - started;
|
|
166
|
+
const truncated = rows.length > limit;
|
|
167
|
+
const capped = truncated ? rows.slice(0, limit) : rows;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
columns: capped.length > 0 ? Object.keys(capped[0]) : [],
|
|
171
|
+
rows: capped.map(
|
|
172
|
+
(row) =>
|
|
173
|
+
Object.fromEntries(
|
|
174
|
+
Object.entries(row).map(([key, value]) => [key, serializeValue(value)]),
|
|
175
|
+
) as Record<string, unknown>,
|
|
176
|
+
),
|
|
177
|
+
rowCount: capped.length,
|
|
178
|
+
truncated,
|
|
179
|
+
durationMs,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type AdminSqlTable = {
|
|
184
|
+
table: string;
|
|
185
|
+
columns: { name: string; type: string; nullable: boolean }[];
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Schema browser for the console sidebar. */
|
|
189
|
+
export async function listSqlSchemaForAdmin(): Promise<{ tables: AdminSqlTable[] }> {
|
|
190
|
+
const rows = (await prisma.$queryRawUnsafe(`
|
|
191
|
+
SELECT table_name, column_name, data_type, is_nullable
|
|
192
|
+
FROM information_schema.columns
|
|
193
|
+
WHERE table_schema = 'public'
|
|
194
|
+
ORDER BY table_name, ordinal_position
|
|
195
|
+
`)) as {
|
|
196
|
+
table_name: string;
|
|
197
|
+
column_name: string;
|
|
198
|
+
data_type: string;
|
|
199
|
+
is_nullable: string;
|
|
200
|
+
}[];
|
|
201
|
+
|
|
202
|
+
const tables = new Map<string, AdminSqlTable>();
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
const table = tables.get(row.table_name) ?? { table: row.table_name, columns: [] };
|
|
205
|
+
table.columns.push({
|
|
206
|
+
name: row.column_name,
|
|
207
|
+
type: row.data_type,
|
|
208
|
+
nullable: row.is_nullable === 'YES',
|
|
209
|
+
});
|
|
210
|
+
tables.set(row.table_name, table);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { tables: [...tables.values()] };
|
|
214
|
+
}
|
|
@@ -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
|
+
}
|