@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,163 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f1ebc28d-0fbe-5aac-8263-22c204e2adf3")}catch(e){}}();
|
|
3
|
+
import { prisma } from '../lib/prisma.js';
|
|
4
|
+
const DEFAULT_BLOCKED_DOMAINS = ['riverside.edu'];
|
|
5
|
+
const DEFAULT_BOT_PATTERNS = ['devin', 'automation-bot', 'qa-bot'];
|
|
6
|
+
const DEFAULT_DISPOSABLE_DOMAINS = [
|
|
7
|
+
'mailinator.com',
|
|
8
|
+
'guerrillamail.com',
|
|
9
|
+
'yopmail.com',
|
|
10
|
+
'tempmail.com',
|
|
11
|
+
'temp-mail.org',
|
|
12
|
+
'10minutemail.com',
|
|
13
|
+
'trashmail.com',
|
|
14
|
+
'sharklasers.com',
|
|
15
|
+
'dispostable.com',
|
|
16
|
+
];
|
|
17
|
+
function fromEnv(name, fallback) {
|
|
18
|
+
const raw = process.env[name];
|
|
19
|
+
if (!raw)
|
|
20
|
+
return fallback;
|
|
21
|
+
const parsed = raw
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
return parsed.length > 0 ? parsed : fallback;
|
|
26
|
+
}
|
|
27
|
+
export function spamRules() {
|
|
28
|
+
return {
|
|
29
|
+
blockedDomains: fromEnv('ADMIN_SPAM_DOMAINS', DEFAULT_BLOCKED_DOMAINS),
|
|
30
|
+
botPatterns: fromEnv('ADMIN_SPAM_BOT_PATTERNS', DEFAULT_BOT_PATTERNS),
|
|
31
|
+
disposableDomains: fromEnv('ADMIN_SPAM_DISPOSABLE_DOMAINS', DEFAULT_DISPOSABLE_DOMAINS),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function domainOf(email) {
|
|
35
|
+
const at = email.lastIndexOf('@');
|
|
36
|
+
return at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
function matchesDomain(domain, listed) {
|
|
39
|
+
return domain === listed || domain.endsWith(`.${listed}`);
|
|
40
|
+
}
|
|
41
|
+
export function classifyEmail(email, username) {
|
|
42
|
+
const rules = spamRules();
|
|
43
|
+
const normalisedEmail = (email ?? '').toLowerCase();
|
|
44
|
+
const normalisedUsername = (username ?? '').toLowerCase();
|
|
45
|
+
const domain = domainOf(normalisedEmail);
|
|
46
|
+
const reasons = [];
|
|
47
|
+
if (rules.blockedDomains.some((listed) => matchesDomain(domain, listed))) {
|
|
48
|
+
reasons.push('BLOCKED_DOMAIN');
|
|
49
|
+
}
|
|
50
|
+
if (rules.disposableDomains.some((listed) => matchesDomain(domain, listed))) {
|
|
51
|
+
reasons.push('DISPOSABLE_DOMAIN');
|
|
52
|
+
}
|
|
53
|
+
if (rules.botPatterns.some((pattern) => normalisedEmail.includes(pattern) ||
|
|
54
|
+
(normalisedUsername.length > 0 && normalisedUsername.includes(pattern)))) {
|
|
55
|
+
reasons.push('BOT_PATTERN');
|
|
56
|
+
}
|
|
57
|
+
return { suspected: reasons.length > 0, reasons };
|
|
58
|
+
}
|
|
59
|
+
export function classifyUser(user) {
|
|
60
|
+
const reasons = [...classifyEmail(user.email, user.username).reasons];
|
|
61
|
+
const counts = user._count;
|
|
62
|
+
if (!user.verified &&
|
|
63
|
+
counts &&
|
|
64
|
+
counts.teacherIn === 0 &&
|
|
65
|
+
counts.studentIn === 0 &&
|
|
66
|
+
(counts.submissions ?? 0) === 0) {
|
|
67
|
+
reasons.push('NEVER_VERIFIED_INACTIVE');
|
|
68
|
+
}
|
|
69
|
+
return { suspected: reasons.length > 0, reasons };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Prisma filter matching the rule-based part of {@link classifyUser}. The
|
|
73
|
+
* `NEVER_VERIFIED_INACTIVE` reason is intentionally excluded: brand new real
|
|
74
|
+
* signups look identical to it, so it only annotates rows and never drives
|
|
75
|
+
* list filtering or counts.
|
|
76
|
+
*/
|
|
77
|
+
export function suspectedUserWhere() {
|
|
78
|
+
const rules = spamRules();
|
|
79
|
+
const domains = [...rules.blockedDomains, ...rules.disposableDomains];
|
|
80
|
+
return {
|
|
81
|
+
OR: [
|
|
82
|
+
...domains.map((domain) => ({
|
|
83
|
+
email: { endsWith: `@${domain}`, mode: 'insensitive' },
|
|
84
|
+
})),
|
|
85
|
+
...domains.map((domain) => ({
|
|
86
|
+
email: { endsWith: `.${domain}`, mode: 'insensitive' },
|
|
87
|
+
})),
|
|
88
|
+
...rules.botPatterns.flatMap((pattern) => [
|
|
89
|
+
{ username: { contains: pattern, mode: 'insensitive' } },
|
|
90
|
+
{ email: { contains: pattern, mode: 'insensitive' } },
|
|
91
|
+
]),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function notSuspectedUserWhere() {
|
|
96
|
+
return { NOT: suspectedUserWhere() };
|
|
97
|
+
}
|
|
98
|
+
export async function countSuspectedUsers() {
|
|
99
|
+
return prisma.user.count({ where: suspectedUserWhere() });
|
|
100
|
+
}
|
|
101
|
+
export async function listUserEmailDomainsForAdmin(input) {
|
|
102
|
+
const rows = await prisma.$queryRaw `
|
|
103
|
+
SELECT lower(split_part(email, '@', 2)) AS domain,
|
|
104
|
+
count(*) AS total,
|
|
105
|
+
count(*) FILTER (WHERE verified) AS verified
|
|
106
|
+
FROM "User"
|
|
107
|
+
GROUP BY 1
|
|
108
|
+
ORDER BY count(*) DESC, 1 ASC
|
|
109
|
+
LIMIT ${Math.min(input.limit, 200)}
|
|
110
|
+
`;
|
|
111
|
+
const rules = spamRules();
|
|
112
|
+
const items = rows.map((row) => {
|
|
113
|
+
const domain = row.domain ?? '';
|
|
114
|
+
const suspected = rules.blockedDomains.some((listed) => matchesDomain(domain, listed)) ||
|
|
115
|
+
rules.disposableDomains.some((listed) => matchesDomain(domain, listed));
|
|
116
|
+
return {
|
|
117
|
+
domain: domain || '(none)',
|
|
118
|
+
total: Number(row.total),
|
|
119
|
+
verified: Number(row.verified),
|
|
120
|
+
suspected,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
items: input.suspectedOnly ? items.filter((item) => item.suspected) : items,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function getSpamOverviewForAdmin() {
|
|
128
|
+
const rules = spamRules();
|
|
129
|
+
const suspectedWhere = suspectedUserWhere();
|
|
130
|
+
const [totalUsers, suspectedUsers, suspectedVerified, domains, recent,] = await Promise.all([
|
|
131
|
+
prisma.user.count(),
|
|
132
|
+
prisma.user.count({ where: suspectedWhere }),
|
|
133
|
+
prisma.user.count({ where: { AND: [suspectedWhere, { verified: true }] } }),
|
|
134
|
+
listUserEmailDomainsForAdmin({ limit: 200, suspectedOnly: true }),
|
|
135
|
+
prisma.user.findMany({
|
|
136
|
+
where: suspectedWhere,
|
|
137
|
+
orderBy: { username: 'asc' },
|
|
138
|
+
take: 50,
|
|
139
|
+
select: {
|
|
140
|
+
id: true,
|
|
141
|
+
username: true,
|
|
142
|
+
email: true,
|
|
143
|
+
verified: true,
|
|
144
|
+
profile: { select: { createdAt: true } },
|
|
145
|
+
_count: { select: { teacherIn: true, studentIn: true, submissions: true } },
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
return {
|
|
150
|
+
rules,
|
|
151
|
+
totalUsers,
|
|
152
|
+
suspectedUsers,
|
|
153
|
+
suspectedVerified,
|
|
154
|
+
cleanUsers: totalUsers - suspectedUsers,
|
|
155
|
+
domains: domains.items,
|
|
156
|
+
recent: recent.map((user) => ({
|
|
157
|
+
...user,
|
|
158
|
+
signals: classifyUser(user),
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=adminSpam.js.map
|
|
163
|
+
//# debugId=f1ebc28d-0fbe-5aac-8263-22c204e2adf3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adminSpam.js","sources":["services/adminSpam.ts"],"sourceRoot":"/","sourcesContent":["import type { Prisma } from '@prisma/client';\nimport { prisma } from '../lib/prisma.js';\n\nexport type SpamReason =\n | 'BLOCKED_DOMAIN'\n | 'BOT_PATTERN'\n | 'DISPOSABLE_DOMAIN'\n | 'NEVER_VERIFIED_INACTIVE';\n\nexport type SpamSignals = {\n suspected: boolean;\n reasons: SpamReason[];\n};\n\nconst DEFAULT_BLOCKED_DOMAINS = ['riverside.edu'];\n\nconst DEFAULT_BOT_PATTERNS = ['devin', 'automation-bot', 'qa-bot'];\n\nconst DEFAULT_DISPOSABLE_DOMAINS = [\n 'mailinator.com',\n 'guerrillamail.com',\n 'yopmail.com',\n 'tempmail.com',\n 'temp-mail.org',\n '10minutemail.com',\n 'trashmail.com',\n 'sharklasers.com',\n 'dispostable.com',\n];\n\nfunction fromEnv(name: string, fallback: string[]): string[] {\n const raw = process.env[name];\n if (!raw) return fallback;\n const parsed = raw\n .split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter(Boolean);\n return parsed.length > 0 ? parsed : fallback;\n}\n\nexport function spamRules() {\n return {\n blockedDomains: fromEnv('ADMIN_SPAM_DOMAINS', DEFAULT_BLOCKED_DOMAINS),\n botPatterns: fromEnv('ADMIN_SPAM_BOT_PATTERNS', DEFAULT_BOT_PATTERNS),\n disposableDomains: fromEnv(\n 'ADMIN_SPAM_DISPOSABLE_DOMAINS',\n DEFAULT_DISPOSABLE_DOMAINS,\n ),\n };\n}\n\nfunction domainOf(email: string): string {\n const at = email.lastIndexOf('@');\n return at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();\n}\n\nfunction matchesDomain(domain: string, listed: string): boolean {\n return domain === listed || domain.endsWith(`.${listed}`);\n}\n\nexport type ClassifiableUser = {\n username: string;\n email: string;\n verified: boolean;\n _count?: { teacherIn: number; studentIn: number; submissions?: number };\n};\n\nexport function classifyEmail(\n email: string | null | undefined,\n username?: string | null,\n): SpamSignals {\n const rules = spamRules();\n const normalisedEmail = (email ?? '').toLowerCase();\n const normalisedUsername = (username ?? '').toLowerCase();\n const domain = domainOf(normalisedEmail);\n const reasons: SpamReason[] = [];\n\n if (rules.blockedDomains.some((listed) => matchesDomain(domain, listed))) {\n reasons.push('BLOCKED_DOMAIN');\n }\n if (rules.disposableDomains.some((listed) => matchesDomain(domain, listed))) {\n reasons.push('DISPOSABLE_DOMAIN');\n }\n if (\n rules.botPatterns.some(\n (pattern) =>\n normalisedEmail.includes(pattern) ||\n (normalisedUsername.length > 0 && normalisedUsername.includes(pattern)),\n )\n ) {\n reasons.push('BOT_PATTERN');\n }\n\n return { suspected: reasons.length > 0, reasons };\n}\n\nexport function classifyUser(user: ClassifiableUser): SpamSignals {\n const reasons = [...classifyEmail(user.email, user.username).reasons];\n const counts = user._count;\n if (\n !user.verified &&\n counts &&\n counts.teacherIn === 0 &&\n counts.studentIn === 0 &&\n (counts.submissions ?? 0) === 0\n ) {\n reasons.push('NEVER_VERIFIED_INACTIVE');\n }\n\n return { suspected: reasons.length > 0, reasons };\n}\n\n/**\n * Prisma filter matching the rule-based part of {@link classifyUser}. The\n * `NEVER_VERIFIED_INACTIVE` reason is intentionally excluded: brand new real\n * signups look identical to it, so it only annotates rows and never drives\n * list filtering or counts.\n */\nexport function suspectedUserWhere(): Prisma.UserWhereInput {\n const rules = spamRules();\n const domains = [...rules.blockedDomains, ...rules.disposableDomains];\n\n return {\n OR: [\n ...domains.map((domain) => ({\n email: { endsWith: `@${domain}`, mode: 'insensitive' as const },\n })),\n ...domains.map((domain) => ({\n email: { endsWith: `.${domain}`, mode: 'insensitive' as const },\n })),\n ...rules.botPatterns.flatMap((pattern) => [\n { username: { contains: pattern, mode: 'insensitive' as const } },\n { email: { contains: pattern, mode: 'insensitive' as const } },\n ]),\n ],\n };\n}\n\nexport function notSuspectedUserWhere(): Prisma.UserWhereInput {\n return { NOT: suspectedUserWhere() };\n}\n\nexport async function countSuspectedUsers(): Promise<number> {\n return prisma.user.count({ where: suspectedUserWhere() });\n}\n\ntype DomainRow = { domain: string | null; total: bigint; verified: bigint };\n\nexport async function listUserEmailDomainsForAdmin(input: {\n limit: number;\n suspectedOnly: boolean;\n}) {\n const rows = await prisma.$queryRaw<DomainRow[]>`\n SELECT lower(split_part(email, '@', 2)) AS domain,\n count(*) AS total,\n count(*) FILTER (WHERE verified) AS verified\n FROM \"User\"\n GROUP BY 1\n ORDER BY count(*) DESC, 1 ASC\n LIMIT ${Math.min(input.limit, 200)}\n `;\n\n const rules = spamRules();\n const items = rows.map((row) => {\n const domain = row.domain ?? '';\n const suspected =\n rules.blockedDomains.some((listed) => matchesDomain(domain, listed)) ||\n rules.disposableDomains.some((listed) => matchesDomain(domain, listed));\n return {\n domain: domain || '(none)',\n total: Number(row.total),\n verified: Number(row.verified),\n suspected,\n };\n });\n\n return {\n items: input.suspectedOnly ? items.filter((item) => item.suspected) : items,\n };\n}\n\nexport async function getSpamOverviewForAdmin() {\n const rules = spamRules();\n const suspectedWhere = suspectedUserWhere();\n\n const [\n totalUsers,\n suspectedUsers,\n suspectedVerified,\n domains,\n recent,\n ] = await Promise.all([\n prisma.user.count(),\n prisma.user.count({ where: suspectedWhere }),\n prisma.user.count({ where: { AND: [suspectedWhere, { verified: true }] } }),\n listUserEmailDomainsForAdmin({ limit: 200, suspectedOnly: true }),\n prisma.user.findMany({\n where: suspectedWhere,\n orderBy: { username: 'asc' },\n take: 50,\n select: {\n id: true,\n username: true,\n email: true,\n verified: true,\n profile: { select: { createdAt: true } },\n _count: { select: { teacherIn: true, studentIn: true, submissions: true } },\n },\n }),\n ]);\n\n return {\n rules,\n totalUsers,\n suspectedUsers,\n suspectedVerified,\n cleanUsers: totalUsers - suspectedUsers,\n domains: domains.items,\n recent: recent.map((user) => ({\n ...user,\n signals: classifyUser(user),\n })),\n };\n}\n"],"names":[],"mappings":"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","debug_id":"f1ebc28d-0fbe-5aac-8263-22c204e2adf3"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const SQL_MAX_ROWS = 500;
|
|
2
|
+
export declare const SQL_TIMEOUT_MS = 10000;
|
|
3
|
+
/**
|
|
4
|
+
* Validates that `sql` is a single read-only statement and returns it without
|
|
5
|
+
* its trailing semicolon. Throws a `BAD_REQUEST` otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertReadOnlySql(sql: string): string;
|
|
8
|
+
/** Wraps row-returning statements so the row cap is enforced by the database. */
|
|
9
|
+
export declare function buildBoundedQuery(statement: string, limit: number): string;
|
|
10
|
+
/** JSON-safe values: Prisma returns bigints, dates, buffers and Decimals raw. */
|
|
11
|
+
export declare function serializeValue(value: unknown): unknown;
|
|
12
|
+
export type AdminSqlResult = {
|
|
13
|
+
columns: string[];
|
|
14
|
+
rows: Record<string, unknown>[];
|
|
15
|
+
rowCount: number;
|
|
16
|
+
truncated: boolean;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function runAdminSql(input: {
|
|
20
|
+
sql: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
userId?: string;
|
|
23
|
+
}): Promise<AdminSqlResult>;
|
|
24
|
+
export type AdminSqlTable = {
|
|
25
|
+
table: string;
|
|
26
|
+
columns: {
|
|
27
|
+
name: string;
|
|
28
|
+
type: string;
|
|
29
|
+
nullable: boolean;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
/** Schema browser for the console sidebar. */
|
|
33
|
+
export declare function listSqlSchemaForAdmin(): Promise<{
|
|
34
|
+
tables: AdminSqlTable[];
|
|
35
|
+
}>;
|
|
36
|
+
//# sourceMappingURL=adminSql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adminSql.d.ts","sourceRoot":"/","sources":["services/adminSql.ts"],"names":[],"mappings":"AA2DA,eAAO,MAAM,YAAY,MAAM,CAAC;AAChC,eAAO,MAAM,cAAc,QAAS,CAAC;AAcrC;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAyBrD;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAI1E;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CActD;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,wBAAsB,WAAW,CAAC,KAAK,EAAE;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC,cAAc,CAAC,CAuC1B;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;CAC9D,CAAC;AAEF,8CAA8C;AAC9C,wBAAsB,qBAAqB,IAAI,OAAO,CAAC;IAAE,MAAM,EAAE,aAAa,EAAE,CAAA;CAAE,CAAC,CAyBlF"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="45b19f90-7667-5cbe-b383-62162ed3eb2a")}catch(e){}}();
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
import { prisma } from '../lib/prisma.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Read-only SQL console backing `admin.runSql`.
|
|
8
|
+
*
|
|
9
|
+
* Three independent guards, because a single one is not enough:
|
|
10
|
+
* 1. the statement is parsed/validated here (single read statement only),
|
|
11
|
+
* 2. it runs inside a `READ ONLY` transaction so the database rejects writes,
|
|
12
|
+
* 3. a statement timeout and row cap bound the blast radius of a bad query.
|
|
13
|
+
*/
|
|
14
|
+
const READ_STARTERS = ['select', 'with', 'explain', 'show', 'table', 'values'];
|
|
15
|
+
/** Statement keywords that must never appear, including inside CTEs. */
|
|
16
|
+
const FORBIDDEN_KEYWORDS = [
|
|
17
|
+
'insert',
|
|
18
|
+
'update',
|
|
19
|
+
'delete',
|
|
20
|
+
'merge',
|
|
21
|
+
'upsert',
|
|
22
|
+
'drop',
|
|
23
|
+
'alter',
|
|
24
|
+
'create',
|
|
25
|
+
'truncate',
|
|
26
|
+
'grant',
|
|
27
|
+
'revoke',
|
|
28
|
+
'comment',
|
|
29
|
+
'copy',
|
|
30
|
+
'vacuum',
|
|
31
|
+
'analyze',
|
|
32
|
+
'reindex',
|
|
33
|
+
'cluster',
|
|
34
|
+
'refresh',
|
|
35
|
+
'lock',
|
|
36
|
+
'listen',
|
|
37
|
+
'notify',
|
|
38
|
+
'call',
|
|
39
|
+
'do',
|
|
40
|
+
'set',
|
|
41
|
+
'reset',
|
|
42
|
+
'begin',
|
|
43
|
+
'commit',
|
|
44
|
+
'rollback',
|
|
45
|
+
'savepoint',
|
|
46
|
+
'prepare',
|
|
47
|
+
'execute',
|
|
48
|
+
'deallocate',
|
|
49
|
+
'discard',
|
|
50
|
+
'import',
|
|
51
|
+
'security',
|
|
52
|
+
'pg_read_file',
|
|
53
|
+
'pg_read_binary_file',
|
|
54
|
+
'pg_ls_dir',
|
|
55
|
+
'dblink',
|
|
56
|
+
'pg_sleep',
|
|
57
|
+
];
|
|
58
|
+
export const SQL_MAX_ROWS = 500;
|
|
59
|
+
export const SQL_TIMEOUT_MS = 10000;
|
|
60
|
+
function stripCommentsAndStrings(sql) {
|
|
61
|
+
return sql
|
|
62
|
+
.replace(/--[^\n]*/g, ' ')
|
|
63
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
64
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
65
|
+
.replace(/"(?:[^"]|"")*"/g, '""');
|
|
66
|
+
}
|
|
67
|
+
function badRequest(message) {
|
|
68
|
+
return new TRPCError({ code: 'BAD_REQUEST', message });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validates that `sql` is a single read-only statement and returns it without
|
|
72
|
+
* its trailing semicolon. Throws a `BAD_REQUEST` otherwise.
|
|
73
|
+
*/
|
|
74
|
+
export function assertReadOnlySql(sql) {
|
|
75
|
+
const statement = sql.trim().replace(/;\s*$/, '').trim();
|
|
76
|
+
if (!statement)
|
|
77
|
+
throw badRequest('Query is empty.');
|
|
78
|
+
const sanitized = stripCommentsAndStrings(statement).toLowerCase();
|
|
79
|
+
if (sanitized.includes(';')) {
|
|
80
|
+
throw badRequest('Only a single statement may be executed.');
|
|
81
|
+
}
|
|
82
|
+
const firstWord = sanitized.trim().split(/\s+/)[0]?.replace(/^\(+/, '') ?? '';
|
|
83
|
+
if (!READ_STARTERS.includes(firstWord)) {
|
|
84
|
+
throw badRequest(`Only ${READ_STARTERS.map((word) => word.toUpperCase()).join('/')} queries are allowed.`);
|
|
85
|
+
}
|
|
86
|
+
const forbidden = FORBIDDEN_KEYWORDS.find((keyword) => new RegExp(`\\b${keyword}\\b`).test(sanitized));
|
|
87
|
+
if (forbidden) {
|
|
88
|
+
throw badRequest(`Keyword "${forbidden.toUpperCase()}" is not allowed in the SQL console.`);
|
|
89
|
+
}
|
|
90
|
+
return statement;
|
|
91
|
+
}
|
|
92
|
+
/** Wraps row-returning statements so the row cap is enforced by the database. */
|
|
93
|
+
export function buildBoundedQuery(statement, limit) {
|
|
94
|
+
const firstWord = stripCommentsAndStrings(statement).trim().toLowerCase().split(/\s+/)[0] ?? '';
|
|
95
|
+
if (firstWord === 'explain' || firstWord === 'show')
|
|
96
|
+
return statement;
|
|
97
|
+
return `SELECT * FROM (${statement}) AS admin_sql_console LIMIT ${limit}`;
|
|
98
|
+
}
|
|
99
|
+
/** JSON-safe values: Prisma returns bigints, dates, buffers and Decimals raw. */
|
|
100
|
+
export function serializeValue(value) {
|
|
101
|
+
if (value === null || value === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
if (typeof value === 'bigint')
|
|
104
|
+
return value.toString();
|
|
105
|
+
if (value instanceof Date)
|
|
106
|
+
return value.toISOString();
|
|
107
|
+
if (Buffer.isBuffer(value))
|
|
108
|
+
return `\\x${value.toString('hex')}`;
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value.map(serializeValue);
|
|
111
|
+
if (typeof value === 'object') {
|
|
112
|
+
const source = value;
|
|
113
|
+
if (typeof source.toFixed === 'function')
|
|
114
|
+
return String(value);
|
|
115
|
+
return Object.fromEntries(Object.entries(source).map(([key, entry]) => [key, serializeValue(entry)]));
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
export async function runAdminSql(input) {
|
|
120
|
+
const limit = Math.min(Math.max(input.limit ?? SQL_MAX_ROWS, 1), SQL_MAX_ROWS);
|
|
121
|
+
const statement = assertReadOnlySql(input.sql);
|
|
122
|
+
const query = buildBoundedQuery(statement, limit + 1);
|
|
123
|
+
logger.warn('Admin SQL console query', { userId: input.userId, sql: statement, limit });
|
|
124
|
+
const started = Date.now();
|
|
125
|
+
let rows;
|
|
126
|
+
try {
|
|
127
|
+
rows = await prisma.$transaction(async (tx) => {
|
|
128
|
+
await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');
|
|
129
|
+
await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = ${SQL_TIMEOUT_MS}`);
|
|
130
|
+
return (await tx.$queryRawUnsafe(query));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
throw new TRPCError({
|
|
135
|
+
code: 'BAD_REQUEST',
|
|
136
|
+
message: error instanceof Error ? error.message : 'Query failed',
|
|
137
|
+
cause: error,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const durationMs = Date.now() - started;
|
|
141
|
+
const truncated = rows.length > limit;
|
|
142
|
+
const capped = truncated ? rows.slice(0, limit) : rows;
|
|
143
|
+
return {
|
|
144
|
+
columns: capped.length > 0 ? Object.keys(capped[0]) : [],
|
|
145
|
+
rows: capped.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, serializeValue(value)]))),
|
|
146
|
+
rowCount: capped.length,
|
|
147
|
+
truncated,
|
|
148
|
+
durationMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Schema browser for the console sidebar. */
|
|
152
|
+
export async function listSqlSchemaForAdmin() {
|
|
153
|
+
const rows = (await prisma.$queryRawUnsafe(`
|
|
154
|
+
SELECT table_name, column_name, data_type, is_nullable
|
|
155
|
+
FROM information_schema.columns
|
|
156
|
+
WHERE table_schema = 'public'
|
|
157
|
+
ORDER BY table_name, ordinal_position
|
|
158
|
+
`));
|
|
159
|
+
const tables = new Map();
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const table = tables.get(row.table_name) ?? { table: row.table_name, columns: [] };
|
|
162
|
+
table.columns.push({
|
|
163
|
+
name: row.column_name,
|
|
164
|
+
type: row.data_type,
|
|
165
|
+
nullable: row.is_nullable === 'YES',
|
|
166
|
+
});
|
|
167
|
+
tables.set(row.table_name, table);
|
|
168
|
+
}
|
|
169
|
+
return { tables: [...tables.values()] };
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=adminSql.js.map
|
|
172
|
+
//# debugId=45b19f90-7667-5cbe-b383-62162ed3eb2a
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adminSql.js","sources":["services/adminSql.ts"],"sourceRoot":"/","sourcesContent":["import { TRPCError } from '@trpc/server';\nimport { prisma } from '../lib/prisma.js';\nimport { logger } from '../utils/logger.js';\n\n/**\n * Read-only SQL console backing `admin.runSql`.\n *\n * Three independent guards, because a single one is not enough:\n * 1. the statement is parsed/validated here (single read statement only),\n * 2. it runs inside a `READ ONLY` transaction so the database rejects writes,\n * 3. a statement timeout and row cap bound the blast radius of a bad query.\n */\n\nconst READ_STARTERS = ['select', 'with', 'explain', 'show', 'table', 'values'];\n\n/** Statement keywords that must never appear, including inside CTEs. */\nconst FORBIDDEN_KEYWORDS = [\n 'insert',\n 'update',\n 'delete',\n 'merge',\n 'upsert',\n 'drop',\n 'alter',\n 'create',\n 'truncate',\n 'grant',\n 'revoke',\n 'comment',\n 'copy',\n 'vacuum',\n 'analyze',\n 'reindex',\n 'cluster',\n 'refresh',\n 'lock',\n 'listen',\n 'notify',\n 'call',\n 'do',\n 'set',\n 'reset',\n 'begin',\n 'commit',\n 'rollback',\n 'savepoint',\n 'prepare',\n 'execute',\n 'deallocate',\n 'discard',\n 'import',\n 'security',\n 'pg_read_file',\n 'pg_read_binary_file',\n 'pg_ls_dir',\n 'dblink',\n 'pg_sleep',\n];\n\nexport const SQL_MAX_ROWS = 500;\nexport const SQL_TIMEOUT_MS = 10_000;\n\nfunction stripCommentsAndStrings(sql: string): string {\n return sql\n .replace(/--[^\\n]*/g, ' ')\n .replace(/\\/\\*[\\s\\S]*?\\*\\//g, ' ')\n .replace(/'(?:[^']|'')*'/g, \"''\")\n .replace(/\"(?:[^\"]|\"\")*\"/g, '\"\"');\n}\n\nfunction badRequest(message: string): TRPCError {\n return new TRPCError({ code: 'BAD_REQUEST', message });\n}\n\n/**\n * Validates that `sql` is a single read-only statement and returns it without\n * its trailing semicolon. Throws a `BAD_REQUEST` otherwise.\n */\nexport function assertReadOnlySql(sql: string): string {\n const statement = sql.trim().replace(/;\\s*$/, '').trim();\n if (!statement) throw badRequest('Query is empty.');\n\n const sanitized = stripCommentsAndStrings(statement).toLowerCase();\n\n if (sanitized.includes(';')) {\n throw badRequest('Only a single statement may be executed.');\n }\n\n const firstWord = sanitized.trim().split(/\\s+/)[0]?.replace(/^\\(+/, '') ?? '';\n if (!READ_STARTERS.includes(firstWord)) {\n throw badRequest(\n `Only ${READ_STARTERS.map((word) => word.toUpperCase()).join('/')} queries are allowed.`,\n );\n }\n\n const forbidden = FORBIDDEN_KEYWORDS.find((keyword) =>\n new RegExp(`\\\\b${keyword}\\\\b`).test(sanitized),\n );\n if (forbidden) {\n throw badRequest(`Keyword \"${forbidden.toUpperCase()}\" is not allowed in the SQL console.`);\n }\n\n return statement;\n}\n\n/** Wraps row-returning statements so the row cap is enforced by the database. */\nexport function buildBoundedQuery(statement: string, limit: number): string {\n const firstWord = stripCommentsAndStrings(statement).trim().toLowerCase().split(/\\s+/)[0] ?? '';\n if (firstWord === 'explain' || firstWord === 'show') return statement;\n return `SELECT * FROM (${statement}) AS admin_sql_console LIMIT ${limit}`;\n}\n\n/** JSON-safe values: Prisma returns bigints, dates, buffers and Decimals raw. */\nexport function serializeValue(value: unknown): unknown {\n if (value === null || value === undefined) return null;\n if (typeof value === 'bigint') return value.toString();\n if (value instanceof Date) return value.toISOString();\n if (Buffer.isBuffer(value)) return `\\\\x${value.toString('hex')}`;\n if (Array.isArray(value)) return value.map(serializeValue);\n if (typeof value === 'object') {\n const source = value as Record<string, unknown>;\n if (typeof source.toFixed === 'function') return String(value);\n return Object.fromEntries(\n Object.entries(source).map(([key, entry]) => [key, serializeValue(entry)]),\n );\n }\n return value;\n}\n\nexport type AdminSqlResult = {\n columns: string[];\n rows: Record<string, unknown>[];\n rowCount: number;\n truncated: boolean;\n durationMs: number;\n};\n\nexport async function runAdminSql(input: {\n sql: string;\n limit?: number;\n userId?: string;\n}): Promise<AdminSqlResult> {\n const limit = Math.min(Math.max(input.limit ?? SQL_MAX_ROWS, 1), SQL_MAX_ROWS);\n const statement = assertReadOnlySql(input.sql);\n const query = buildBoundedQuery(statement, limit + 1);\n\n logger.warn('Admin SQL console query', { userId: input.userId, sql: statement, limit });\n\n const started = Date.now();\n let rows: Record<string, unknown>[];\n try {\n rows = await prisma.$transaction(async (tx) => {\n await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');\n await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = ${SQL_TIMEOUT_MS}`);\n return (await tx.$queryRawUnsafe(query)) as Record<string, unknown>[];\n });\n } catch (error) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: error instanceof Error ? error.message : 'Query failed',\n cause: error,\n });\n }\n\n const durationMs = Date.now() - started;\n const truncated = rows.length > limit;\n const capped = truncated ? rows.slice(0, limit) : rows;\n\n return {\n columns: capped.length > 0 ? Object.keys(capped[0]) : [],\n rows: capped.map(\n (row) =>\n Object.fromEntries(\n Object.entries(row).map(([key, value]) => [key, serializeValue(value)]),\n ) as Record<string, unknown>,\n ),\n rowCount: capped.length,\n truncated,\n durationMs,\n };\n}\n\nexport type AdminSqlTable = {\n table: string;\n columns: { name: string; type: string; nullable: boolean }[];\n};\n\n/** Schema browser for the console sidebar. */\nexport async function listSqlSchemaForAdmin(): Promise<{ tables: AdminSqlTable[] }> {\n const rows = (await prisma.$queryRawUnsafe(`\n SELECT table_name, column_name, data_type, is_nullable\n FROM information_schema.columns\n WHERE table_schema = 'public'\n ORDER BY table_name, ordinal_position\n `)) as {\n table_name: string;\n column_name: string;\n data_type: string;\n is_nullable: string;\n }[];\n\n const tables = new Map<string, AdminSqlTable>();\n for (const row of rows) {\n const table = tables.get(row.table_name) ?? { table: row.table_name, columns: [] };\n table.columns.push({\n name: row.column_name,\n type: row.data_type,\n nullable: row.is_nullable === 'YES',\n });\n tables.set(row.table_name, table);\n }\n\n return { tables: [...tables.values()] };\n}\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C;;;;;;;GAOG;AAEH,MAAM,aAAa,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;AAE/E,wEAAwE;AACxE,MAAM,kBAAkB,GAAG;IACzB,QAAQ;IACR,QAAQ;IACR,QAAQ;IACR,OAAO;IACP,QAAQ;IACR,MAAM;IACN,OAAO;IACP,QAAQ;IACR,UAAU;IACV,OAAO;IACP,QAAQ;IACR,SAAS;IACT,MAAM;IACN,QAAQ;IACR,SAAS;IACT,SAAS;IACT,SAAS;IACT,SAAS;IACT,MAAM;IACN,QAAQ;IACR,QAAQ;IACR,MAAM;IACN,IAAI;IACJ,KAAK;IACL,OAAO;IACP,OAAO;IACP,QAAQ;IACR,UAAU;IACV,WAAW;IACX,SAAS;IACT,SAAS;IACT,YAAY;IACZ,SAAS;IACT,QAAQ;IACR,UAAU;IACV,cAAc;IACd,qBAAqB;IACrB,WAAW;IACX,QAAQ;IACR,UAAU;CACX,CAAC;AAEF,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAC;AAChC,MAAM,CAAC,MAAM,cAAc,GAAG,KAAM,CAAC;AAErC,SAAS,uBAAuB,CAAC,GAAW;IAC1C,OAAO,GAAG;SACP,OAAO,CAAC,WAAW,EAAE,GAAG,CAAC;SACzB,OAAO,CAAC,mBAAmB,EAAE,GAAG,CAAC;SACjC,OAAO,CAAC,iBAAiB,EAAE,IAAI,CAAC;SAChC,OAAO,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;AACtC,CAAC;AAED,SAAS,UAAU,CAAC,OAAe;IACjC,OAAO,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACzD,IAAI,CAAC,SAAS;QAAE,MAAM,UAAU,CAAC,iBAAiB,CAAC,CAAC;IAEpD,MAAM,SAAS,GAAG,uBAAuB,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC;IAEnE,IAAI,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5B,MAAM,UAAU,CAAC,0CAA0C,CAAC,CAAC;IAC/D,CAAC;IAED,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;IAC9E,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACvC,MAAM,UAAU,CACd,QAAQ,aAAa,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,uBAAuB,CACzF,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CACpD,IAAI,MAAM,CAAC,MAAM,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAC/C,CAAC;IACF,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,UAAU,CAAC,YAAY,SAAS,CAAC,WAAW,EAAE,sCAAsC,CAAC,CAAC;IAC9F,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,iBAAiB,CAAC,SAAiB,EAAE,KAAa;IAChE,MAAM,SAAS,GAAG,uBAAuB,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAChG,IAAI,SAAS,KAAK,SAAS,IAAI,SAAS,KAAK,MAAM;QAAE,OAAO,SAAS,CAAC;IACtE,OAAO,kBAAkB,SAAS,gCAAgC,KAAK,EAAE,CAAC;AAC5E,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACvD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;IACvD,IAAI,KAAK,YAAY,IAAI;QAAE,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC;IACtD,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,MAAM,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;IACjE,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAC3D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,KAAgC,CAAC;QAChD,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,UAAU;YAAE,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QAC/D,OAAO,MAAM,CAAC,WAAW,CACvB,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAC3E,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAUD,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,KAIjC;IACC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,YAAY,EAAE,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IAC/E,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,iBAAiB,CAAC,SAAS,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;IAEtD,MAAM,CAAC,IAAI,CAAC,yBAAyB,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;IAExF,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC3B,IAAI,IAA+B,CAAC;IACpC,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;YAC5C,MAAM,EAAE,CAAC,iBAAiB,CAAC,2BAA2B,CAAC,CAAC;YACxD,MAAM,EAAE,CAAC,iBAAiB,CAAC,iCAAiC,cAAc,EAAE,CAAC,CAAC;YAC9E,OAAO,CAAC,MAAM,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,CAA8B,CAAC;QACxE,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,aAAa;YACnB,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,cAAc;YAChE,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;IACL,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;IACxC,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAEvD,OAAO;QACL,OAAO,EAAE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;QACxD,IAAI,EAAE,MAAM,CAAC,GAAG,CACd,CAAC,GAAG,EAAE,EAAE,CACN,MAAM,CAAC,WAAW,CAChB,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAC7C,CAC/B;QACD,QAAQ,EAAE,MAAM,CAAC,MAAM;QACvB,SAAS;QACT,UAAU;KACX,CAAC;AACJ,CAAC;AAOD,8CAA8C;AAC9C,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,MAAM,IAAI,GAAG,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC;;;;;GAK1C,CAAC,CAKC,CAAC;IAEJ,MAAM,MAAM,GAAG,IAAI,GAAG,EAAyB,CAAC;IAChD,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;QACnF,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;YACjB,IAAI,EAAE,GAAG,CAAC,WAAW;YACrB,IAAI,EAAE,GAAG,CAAC,SAAS;YACnB,QAAQ,EAAE,GAAG,CAAC,WAAW,KAAK,KAAK;SACpC,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IACpC,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;AAC1C,CAAC","debug_id":"45b19f90-7667-5cbe-b383-62162ed3eb2a"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type DailyPoint = {
|
|
2
|
+
date: string;
|
|
3
|
+
signups: number;
|
|
4
|
+
logins: number;
|
|
5
|
+
assignments: number;
|
|
6
|
+
submissions: number;
|
|
7
|
+
messages: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Per-day platform activity for the last `days` days, zero-filled so the admin
|
|
11
|
+
* chart always has one point per day even when nothing happened.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getActivitySeriesForAdmin(input: {
|
|
14
|
+
days: number;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
points: DailyPoint[];
|
|
17
|
+
}>;
|
|
18
|
+
/** Content inventory plus AI/job health, i.e. "what is actually on the platform". */
|
|
19
|
+
export declare function getContentTelemetryForAdmin(): Promise<{
|
|
20
|
+
content: {
|
|
21
|
+
assignments: number;
|
|
22
|
+
assignmentsLast7d: number;
|
|
23
|
+
submissions: number;
|
|
24
|
+
submissionsLast7d: number;
|
|
25
|
+
gradedSubmissions: number;
|
|
26
|
+
announcements: number;
|
|
27
|
+
announcementsLast7d: number;
|
|
28
|
+
discussions: number;
|
|
29
|
+
messages: number;
|
|
30
|
+
messagesLast7d: number;
|
|
31
|
+
worksheets: number;
|
|
32
|
+
events: number;
|
|
33
|
+
modules: number;
|
|
34
|
+
files: number;
|
|
35
|
+
storageBytes: number;
|
|
36
|
+
};
|
|
37
|
+
ai: {
|
|
38
|
+
calls7d: number;
|
|
39
|
+
failures7d: number;
|
|
40
|
+
tokens7d: number;
|
|
41
|
+
byModel: {
|
|
42
|
+
model: string;
|
|
43
|
+
calls: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
jobs: {
|
|
48
|
+
failedLast24h: number;
|
|
49
|
+
byType: {
|
|
50
|
+
total: number;
|
|
51
|
+
failed: number;
|
|
52
|
+
type: string;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
}>;
|
|
56
|
+
//# sourceMappingURL=adminTelemetry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adminTelemetry.d.ts","sourceRoot":"/","sources":["services/adminTelemetry.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAkCF;;;GAGG;AACH,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC,CA6BpC;AAED,qFAAqF;AACrF,wBAAsB,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAkEH,MAAM;oBAAU,MAAM;;;;GA6CnE"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="2b411e03-e7aa-506c-883f-1affe1c5e1c5")}catch(e){}}();
|
|
3
|
+
import { Prisma } from '@prisma/client';
|
|
4
|
+
import { prisma } from '../lib/prisma.js';
|
|
5
|
+
function dayKey(date) {
|
|
6
|
+
return date.toISOString().slice(0, 10);
|
|
7
|
+
}
|
|
8
|
+
async function dailyCounts(table, column, since, extra = Prisma.empty) {
|
|
9
|
+
const rows = await prisma.$queryRaw `
|
|
10
|
+
SELECT date_trunc('day', ${column}) AS day, count(*) AS total
|
|
11
|
+
FROM ${table}
|
|
12
|
+
WHERE ${column} >= ${since} ${extra}
|
|
13
|
+
GROUP BY 1
|
|
14
|
+
`;
|
|
15
|
+
return new Map(rows.map((row) => [dayKey(row.day), Number(row.total)]));
|
|
16
|
+
}
|
|
17
|
+
async function dailyDistinctLogins(since) {
|
|
18
|
+
const rows = await prisma.$queryRaw `
|
|
19
|
+
SELECT date_trunc('day', "createdAt") AS day,
|
|
20
|
+
count(DISTINCT "userId") AS total
|
|
21
|
+
FROM "Session"
|
|
22
|
+
WHERE "createdAt" >= ${since} AND "type" = 'AUTH' AND "userId" IS NOT NULL
|
|
23
|
+
GROUP BY 1
|
|
24
|
+
`;
|
|
25
|
+
return new Map(rows.map((row) => [dayKey(row.day), Number(row.total)]));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Per-day platform activity for the last `days` days, zero-filled so the admin
|
|
29
|
+
* chart always has one point per day even when nothing happened.
|
|
30
|
+
*/
|
|
31
|
+
export async function getActivitySeriesForAdmin(input) {
|
|
32
|
+
const days = Math.min(Math.max(input.days, 1), 90);
|
|
33
|
+
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000);
|
|
34
|
+
since.setUTCHours(0, 0, 0, 0);
|
|
35
|
+
const [signups, logins, assignments, submissions, messages] = await Promise.all([
|
|
36
|
+
dailyCounts(Prisma.sql `"UserProfile"`, Prisma.sql `"createdAt"`, since),
|
|
37
|
+
dailyDistinctLogins(since),
|
|
38
|
+
dailyCounts(Prisma.sql `"Assignment"`, Prisma.sql `"createdAt"`, since),
|
|
39
|
+
dailyCounts(Prisma.sql `"Submission"`, Prisma.sql `"createdAt"`, since),
|
|
40
|
+
dailyCounts(Prisma.sql `"Message"`, Prisma.sql `"createdAt"`, since),
|
|
41
|
+
]);
|
|
42
|
+
const points = [];
|
|
43
|
+
for (let offset = 0; offset < days; offset += 1) {
|
|
44
|
+
const date = new Date(since.getTime() + offset * 24 * 60 * 60 * 1000);
|
|
45
|
+
const key = dayKey(date);
|
|
46
|
+
points.push({
|
|
47
|
+
date: key,
|
|
48
|
+
signups: signups.get(key) ?? 0,
|
|
49
|
+
logins: logins.get(key) ?? 0,
|
|
50
|
+
assignments: assignments.get(key) ?? 0,
|
|
51
|
+
submissions: submissions.get(key) ?? 0,
|
|
52
|
+
messages: messages.get(key) ?? 0,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { points };
|
|
56
|
+
}
|
|
57
|
+
/** Content inventory plus AI/job health, i.e. "what is actually on the platform". */
|
|
58
|
+
export async function getContentTelemetryForAdmin() {
|
|
59
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
60
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
61
|
+
const [assignments, assignmentsLast7d, submissions, submissionsLast7d, gradedSubmissions, announcements, announcementsLast7d, discussions, messages, messagesLast7d, worksheets, events, modules, files, fileSize, aiCalls7d, aiFailures7d, aiTokens7d, aiByModel7d, runJobsByType7d, failedJobsLast24h,] = await Promise.all([
|
|
62
|
+
prisma.assignment.count(),
|
|
63
|
+
prisma.assignment.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
64
|
+
prisma.submission.count(),
|
|
65
|
+
prisma.submission.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
66
|
+
prisma.submission.count({ where: { gradeReceived: { not: null } } }),
|
|
67
|
+
prisma.announcement.count(),
|
|
68
|
+
prisma.announcement.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
69
|
+
prisma.discussion.count(),
|
|
70
|
+
prisma.message.count(),
|
|
71
|
+
prisma.message.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
72
|
+
prisma.worksheet.count(),
|
|
73
|
+
prisma.event.count(),
|
|
74
|
+
prisma.module.count(),
|
|
75
|
+
prisma.file.count(),
|
|
76
|
+
prisma.file.aggregate({ _sum: { size: true } }),
|
|
77
|
+
prisma.aiUsageLog.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
78
|
+
prisma.aiUsageLog.count({
|
|
79
|
+
where: { createdAt: { gte: weekAgo }, success: false },
|
|
80
|
+
}),
|
|
81
|
+
prisma.aiUsageLog.aggregate({
|
|
82
|
+
where: { createdAt: { gte: weekAgo } },
|
|
83
|
+
_sum: { totalTokens: true },
|
|
84
|
+
}),
|
|
85
|
+
prisma.aiUsageLog.groupBy({
|
|
86
|
+
by: ['model'],
|
|
87
|
+
where: { createdAt: { gte: weekAgo } },
|
|
88
|
+
_count: { _all: true },
|
|
89
|
+
_sum: { totalTokens: true },
|
|
90
|
+
}),
|
|
91
|
+
prisma.runJob.groupBy({
|
|
92
|
+
by: ['type', 'status'],
|
|
93
|
+
where: { createdAt: { gte: weekAgo } },
|
|
94
|
+
_count: { _all: true },
|
|
95
|
+
}),
|
|
96
|
+
prisma.runJob.count({
|
|
97
|
+
where: { status: 'FAILED', createdAt: { gte: dayAgo } },
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
const jobsByType = new Map();
|
|
101
|
+
for (const row of runJobsByType7d) {
|
|
102
|
+
const entry = jobsByType.get(row.type) ?? { total: 0, failed: 0 };
|
|
103
|
+
entry.total += row._count._all;
|
|
104
|
+
if (row.status === 'FAILED')
|
|
105
|
+
entry.failed += row._count._all;
|
|
106
|
+
jobsByType.set(row.type, entry);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
content: {
|
|
110
|
+
assignments,
|
|
111
|
+
assignmentsLast7d,
|
|
112
|
+
submissions,
|
|
113
|
+
submissionsLast7d,
|
|
114
|
+
gradedSubmissions,
|
|
115
|
+
announcements,
|
|
116
|
+
announcementsLast7d,
|
|
117
|
+
discussions,
|
|
118
|
+
messages,
|
|
119
|
+
messagesLast7d,
|
|
120
|
+
worksheets,
|
|
121
|
+
events,
|
|
122
|
+
modules,
|
|
123
|
+
files,
|
|
124
|
+
storageBytes: Number(fileSize._sum.size ?? 0),
|
|
125
|
+
},
|
|
126
|
+
ai: {
|
|
127
|
+
calls7d: aiCalls7d,
|
|
128
|
+
failures7d: aiFailures7d,
|
|
129
|
+
tokens7d: aiTokens7d._sum.totalTokens ?? 0,
|
|
130
|
+
byModel: aiByModel7d
|
|
131
|
+
.map((row) => ({
|
|
132
|
+
model: row.model,
|
|
133
|
+
calls: row._count._all,
|
|
134
|
+
tokens: row._sum.totalTokens ?? 0,
|
|
135
|
+
}))
|
|
136
|
+
.sort((a, b) => b.calls - a.calls),
|
|
137
|
+
},
|
|
138
|
+
jobs: {
|
|
139
|
+
failedLast24h: failedJobsLast24h,
|
|
140
|
+
byType: [...jobsByType.entries()]
|
|
141
|
+
.map(([type, counts]) => ({ type, ...counts }))
|
|
142
|
+
.sort((a, b) => b.total - a.total),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=adminTelemetry.js.map
|
|
147
|
+
//# debugId=2b411e03-e7aa-506c-883f-1affe1c5e1c5
|