@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.
Files changed (222) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +31 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/instrument.js +9 -4
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/fileUpload.d.ts +15 -0
  10. package/dist/lib/fileUpload.d.ts.map +1 -1
  11. package/dist/lib/fileUpload.js +43 -2
  12. package/dist/lib/fileUpload.js.map +1 -1
  13. package/dist/models/cloudStorage.d.ts +11 -0
  14. package/dist/models/cloudStorage.d.ts.map +1 -1
  15. package/dist/models/cloudStorage.js +18 -2
  16. package/dist/models/cloudStorage.js.map +1 -1
  17. package/dist/models/conversation.d.ts +1 -0
  18. package/dist/models/conversation.d.ts.map +1 -1
  19. package/dist/models/labChat.d.ts +40 -0
  20. package/dist/models/labChat.d.ts.map +1 -1
  21. package/dist/models/labChat.js +32 -2
  22. package/dist/models/labChat.js.map +1 -1
  23. package/dist/models/message.d.ts +3 -0
  24. package/dist/models/message.d.ts.map +1 -1
  25. package/dist/pipelines/aiLabChat.d.ts +2 -0
  26. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +60 -16
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  30. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.js +30 -2
  32. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +30 -5
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/generateVisualization.d.ts +33 -0
  37. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  38. package/dist/pipelines/generateVisualization.js +79 -0
  39. package/dist/pipelines/generateVisualization.js.map +1 -0
  40. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +13 -14
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +1118 -70
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/_app.js +4 -2
  47. package/dist/routers/_app.js.map +1 -1
  48. package/dist/routers/admin.d.ts +297 -1
  49. package/dist/routers/admin.d.ts.map +1 -1
  50. package/dist/routers/admin.js +43 -2
  51. package/dist/routers/admin.js.map +1 -1
  52. package/dist/routers/class.d.ts.map +1 -1
  53. package/dist/routers/class.js +16 -3
  54. package/dist/routers/class.js.map +1 -1
  55. package/dist/routers/conversation.d.ts +1 -0
  56. package/dist/routers/conversation.d.ts.map +1 -1
  57. package/dist/routers/labChat.d.ts +10 -0
  58. package/dist/routers/labChat.d.ts.map +1 -1
  59. package/dist/routers/labChat.js +6 -3
  60. package/dist/routers/labChat.js.map +1 -1
  61. package/dist/routers/message.d.ts +37 -4
  62. package/dist/routers/message.d.ts.map +1 -1
  63. package/dist/routers/message.js +24 -7
  64. package/dist/routers/message.js.map +1 -1
  65. package/dist/routers/module.d.ts +1 -1
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routers/visualization.d.ts +124 -0
  75. package/dist/routers/visualization.d.ts.map +1 -0
  76. package/dist/routers/visualization.js +41 -0
  77. package/dist/routers/visualization.js.map +1 -0
  78. package/dist/routes/aiStream.js +32 -2
  79. package/dist/routes/aiStream.js.map +1 -1
  80. package/dist/seedDatabase.d.ts.map +1 -1
  81. package/dist/seedDatabase.js +107 -3
  82. package/dist/seedDatabase.js.map +1 -1
  83. package/dist/services/adminClass.d.ts +153 -0
  84. package/dist/services/adminClass.d.ts.map +1 -0
  85. package/dist/services/adminClass.js +196 -0
  86. package/dist/services/adminClass.js.map +1 -0
  87. package/dist/services/adminInstitution.d.ts +2 -0
  88. package/dist/services/adminInstitution.d.ts.map +1 -1
  89. package/dist/services/adminInstitution.js +11 -4
  90. package/dist/services/adminInstitution.js.map +1 -1
  91. package/dist/services/adminMetrics.d.ts +8 -0
  92. package/dist/services/adminMetrics.d.ts.map +1 -1
  93. package/dist/services/adminMetrics.js +30 -3
  94. package/dist/services/adminMetrics.js.map +1 -1
  95. package/dist/services/adminSpam.d.ts +76 -0
  96. package/dist/services/adminSpam.d.ts.map +1 -0
  97. package/dist/services/adminSpam.js +163 -0
  98. package/dist/services/adminSpam.js.map +1 -0
  99. package/dist/services/adminSql.d.ts +36 -0
  100. package/dist/services/adminSql.d.ts.map +1 -0
  101. package/dist/services/adminSql.js +172 -0
  102. package/dist/services/adminSql.js.map +1 -0
  103. package/dist/services/adminTelemetry.d.ts +56 -0
  104. package/dist/services/adminTelemetry.d.ts.map +1 -0
  105. package/dist/services/adminTelemetry.js +147 -0
  106. package/dist/services/adminTelemetry.js.map +1 -0
  107. package/dist/services/adminUser.d.ts +45 -1
  108. package/dist/services/adminUser.d.ts.map +1 -1
  109. package/dist/services/adminUser.js +53 -13
  110. package/dist/services/adminUser.js.map +1 -1
  111. package/dist/services/aiChatStream.d.ts +15 -0
  112. package/dist/services/aiChatStream.d.ts.map +1 -1
  113. package/dist/services/aiChatStream.js +144 -74
  114. package/dist/services/aiChatStream.js.map +1 -1
  115. package/dist/services/aiGeneration.d.ts +4 -0
  116. package/dist/services/aiGeneration.d.ts.map +1 -0
  117. package/dist/services/aiGeneration.js +88 -0
  118. package/dist/services/aiGeneration.js.map +1 -0
  119. package/dist/services/aiProposalTools.d.ts +4 -1
  120. package/dist/services/aiProposalTools.d.ts.map +1 -1
  121. package/dist/services/aiProposalTools.js +128 -3
  122. package/dist/services/aiProposalTools.js.map +1 -1
  123. package/dist/services/aiProposals.d.ts +70 -0
  124. package/dist/services/aiProposals.d.ts.map +1 -0
  125. package/dist/services/aiProposals.js +363 -0
  126. package/dist/services/aiProposals.js.map +1 -0
  127. package/dist/services/auth.d.ts.map +1 -1
  128. package/dist/services/auth.js +10 -4
  129. package/dist/services/auth.js.map +1 -1
  130. package/dist/services/conversation.d.ts +1 -0
  131. package/dist/services/conversation.d.ts.map +1 -1
  132. package/dist/services/fees.d.ts.map +1 -1
  133. package/dist/services/fees.js +7 -3
  134. package/dist/services/fees.js.map +1 -1
  135. package/dist/services/labChat.d.ts +10 -0
  136. package/dist/services/labChat.d.ts.map +1 -1
  137. package/dist/services/labChat.js +52 -3
  138. package/dist/services/labChat.js.map +1 -1
  139. package/dist/services/message.d.ts +24 -8
  140. package/dist/services/message.d.ts.map +1 -1
  141. package/dist/services/message.js +170 -117
  142. package/dist/services/message.js.map +1 -1
  143. package/dist/services/module.d.ts.map +1 -1
  144. package/dist/services/module.js +23 -4
  145. package/dist/services/module.js.map +1 -1
  146. package/dist/services/parentPortal.d.ts +14 -0
  147. package/dist/services/parentPortal.d.ts.map +1 -1
  148. package/dist/services/parentPortal.js +72 -2
  149. package/dist/services/parentPortal.js.map +1 -1
  150. package/dist/services/studentProgress.d.ts +50 -0
  151. package/dist/services/studentProgress.d.ts.map +1 -1
  152. package/dist/services/studentProgress.js +107 -2
  153. package/dist/services/studentProgress.js.map +1 -1
  154. package/dist/services/visualization.d.ts +72 -0
  155. package/dist/services/visualization.d.ts.map +1 -0
  156. package/dist/services/visualization.js +122 -0
  157. package/dist/services/visualization.js.map +1 -0
  158. package/dist/utils/inference.d.ts +10 -0
  159. package/dist/utils/inference.d.ts.map +1 -1
  160. package/dist/utils/inference.js +77 -2
  161. package/dist/utils/inference.js.map +1 -1
  162. package/dist/utils/sentryTrpc.d.ts +3 -0
  163. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  164. package/dist/utils/sentryTrpc.js +26 -0
  165. package/dist/utils/sentryTrpc.js.map +1 -0
  166. package/dist/workers/processors/createRunJobWorker.js +17 -16
  167. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  168. package/package.json +1 -1
  169. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  170. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  171. package/prisma/schema.prisma +32 -0
  172. package/src/index.ts +30 -4
  173. package/src/instrument.ts +8 -2
  174. package/src/lib/fileUpload.ts +49 -0
  175. package/src/models/cloudStorage.ts +19 -0
  176. package/src/models/labChat.ts +34 -0
  177. package/src/pipelines/aiLabChat.ts +79 -18
  178. package/src/pipelines/aiLabChatContract.ts +35 -0
  179. package/src/pipelines/aiNewtonChat.ts +34 -2
  180. package/src/pipelines/generateVisualization.ts +107 -0
  181. package/src/pipelines/labChatPrompt.ts +11 -12
  182. package/src/routers/_app.ts +2 -0
  183. package/src/routers/admin.ts +72 -0
  184. package/src/routers/class.ts +14 -1
  185. package/src/routers/labChat.ts +5 -0
  186. package/src/routers/message.ts +34 -5
  187. package/src/routers/parent.ts +11 -0
  188. package/src/routers/studentProgress.ts +15 -0
  189. package/src/routers/visualization.ts +60 -0
  190. package/src/routes/aiStream.ts +32 -0
  191. package/src/seedDatabase.ts +107 -2
  192. package/src/services/adminClass.ts +222 -0
  193. package/src/services/adminInstitution.ts +9 -2
  194. package/src/services/adminMetrics.ts +35 -0
  195. package/src/services/adminSpam.ts +224 -0
  196. package/src/services/adminSql.ts +214 -0
  197. package/src/services/adminTelemetry.ts +194 -0
  198. package/src/services/adminUser.ts +66 -11
  199. package/src/services/aiChatStream.ts +83 -7
  200. package/src/services/aiGeneration.ts +92 -0
  201. package/src/services/aiProposalTools.ts +166 -1
  202. package/src/services/aiProposals.ts +428 -0
  203. package/src/services/auth.ts +8 -2
  204. package/src/services/fees.ts +5 -1
  205. package/src/services/labChat.ts +65 -0
  206. package/src/services/message.ts +205 -142
  207. package/src/services/module.ts +29 -2
  208. package/src/services/parentPortal.ts +85 -0
  209. package/src/services/studentProgress.ts +135 -0
  210. package/src/services/visualization.ts +147 -0
  211. package/src/utils/inference.ts +85 -0
  212. package/src/utils/sentryTrpc.ts +25 -0
  213. package/src/workers/processors/createRunJobWorker.ts +17 -17
  214. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  215. package/tests/unit/services/adminClass.test.ts +102 -0
  216. package/tests/unit/services/adminSpam.test.ts +108 -0
  217. package/tests/unit/services/adminSql.test.ts +56 -0
  218. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  219. package/tests/unit/services/adminUser.test.ts +77 -0
  220. package/tests/unit/services/visualization.test.ts +130 -0
  221. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
  222. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -0,0 +1,76 @@
1
+ import type { Prisma } from '@prisma/client';
2
+ export type SpamReason = 'BLOCKED_DOMAIN' | 'BOT_PATTERN' | 'DISPOSABLE_DOMAIN' | 'NEVER_VERIFIED_INACTIVE';
3
+ export type SpamSignals = {
4
+ suspected: boolean;
5
+ reasons: SpamReason[];
6
+ };
7
+ export declare function spamRules(): {
8
+ blockedDomains: string[];
9
+ botPatterns: string[];
10
+ disposableDomains: string[];
11
+ };
12
+ export type ClassifiableUser = {
13
+ username: string;
14
+ email: string;
15
+ verified: boolean;
16
+ _count?: {
17
+ teacherIn: number;
18
+ studentIn: number;
19
+ submissions?: number;
20
+ };
21
+ };
22
+ export declare function classifyEmail(email: string | null | undefined, username?: string | null): SpamSignals;
23
+ export declare function classifyUser(user: ClassifiableUser): SpamSignals;
24
+ /**
25
+ * Prisma filter matching the rule-based part of {@link classifyUser}. The
26
+ * `NEVER_VERIFIED_INACTIVE` reason is intentionally excluded: brand new real
27
+ * signups look identical to it, so it only annotates rows and never drives
28
+ * list filtering or counts.
29
+ */
30
+ export declare function suspectedUserWhere(): Prisma.UserWhereInput;
31
+ export declare function notSuspectedUserWhere(): Prisma.UserWhereInput;
32
+ export declare function countSuspectedUsers(): Promise<number>;
33
+ export declare function listUserEmailDomainsForAdmin(input: {
34
+ limit: number;
35
+ suspectedOnly: boolean;
36
+ }): Promise<{
37
+ items: {
38
+ domain: string;
39
+ total: number;
40
+ verified: number;
41
+ suspected: boolean;
42
+ }[];
43
+ }>;
44
+ export declare function getSpamOverviewForAdmin(): Promise<{
45
+ rules: {
46
+ blockedDomains: string[];
47
+ botPatterns: string[];
48
+ disposableDomains: string[];
49
+ };
50
+ totalUsers: number;
51
+ suspectedUsers: number;
52
+ suspectedVerified: number;
53
+ cleanUsers: number;
54
+ domains: {
55
+ domain: string;
56
+ total: number;
57
+ verified: number;
58
+ suspected: boolean;
59
+ }[];
60
+ recent: {
61
+ signals: SpamSignals;
62
+ id: string;
63
+ username: string;
64
+ email: string;
65
+ verified: boolean;
66
+ profile: {
67
+ createdAt: Date;
68
+ } | null;
69
+ _count: {
70
+ teacherIn: number;
71
+ studentIn: number;
72
+ submissions: number;
73
+ };
74
+ }[];
75
+ }>;
76
+ //# sourceMappingURL=adminSpam.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adminSpam.d.ts","sourceRoot":"/","sources":["services/adminSpam.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAG7C,MAAM,MAAM,UAAU,GAClB,gBAAgB,GAChB,aAAa,GACb,mBAAmB,GACnB,yBAAyB,CAAC;AAE9B,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,UAAU,EAAE,CAAC;CACvB,CAAC;AA4BF,wBAAgB,SAAS;;;;EASxB;AAWD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE,CAAC;AAEF,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GACvB,WAAW,CAwBb;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CAchE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAAC,cAAc,CAkB1D;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAAC,cAAc,CAE7D;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAE3D;AAID,wBAAsB,4BAA4B,CAAC,KAAK,EAAE;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,OAAO,CAAC;CACxB;;;;;;;GA4BA;AAED,wBAAsB,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0C5C"}
@@ -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":"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","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"}